diff options
| author | Dax Raad <[email protected]> | 2026-01-03 01:34:23 -0500 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2026-01-03 01:34:23 -0500 |
| commit | 47c670aea9f10c4f1c74fe5d494a4564be3ed777 (patch) | |
| tree | 536b42fdc1a97d4fb491615c432e2ecff0a3720c | |
| parent | 2b66b31d96ab9314af7845e02807fbaf2fbe5d0d (diff) | |
| download | opencode-47c670aea9f10c4f1c74fe5d494a4564be3ed777.tar.gz opencode-47c670aea9f10c4f1c74fe5d494a4564be3ed777.zip | |
tui: add reject message support to permission dialogs for better user feedback
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 69 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts | 73 | ||||
| -rw-r--r-- | packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx | 105 | ||||
| -rw-r--r-- | packages/opencode/src/permission/next.ts | 18 | ||||
| -rw-r--r-- | packages/opencode/src/server/server.ts | 3 | ||||
| -rw-r--r-- | packages/opencode/test/permission/next.test.ts | 4 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 | ||||
| -rw-r--r-- | packages/sdk/js/src/v2/gen/types.gen.ts | 1 |
8 files changed, 196 insertions, 79 deletions
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index ed0f50b2c..fcb920c30 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,4 +1,4 @@ -import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg, type KeyBinding } from "@opentui/core" +import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core" import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import { useLocal } from "@tui/context/local" @@ -10,7 +10,6 @@ import { useSync } from "@tui/context/sync" import { Identifier } from "@/id/id" import { createStore, produce } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" -import { Keybind } from "@/util/keybind" import { usePromptHistory, type PromptInfo } from "./history" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" @@ -30,6 +29,7 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" +import { useTextareaKeybindings } from "../textarea-keybindings" export type PromptProps = { sessionID?: string @@ -53,61 +53,6 @@ export type PromptRef = { const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] -const TEXTAREA_ACTIONS = [ - "submit", - "newline", - "move-left", - "move-right", - "move-up", - "move-down", - "select-left", - "select-right", - "select-up", - "select-down", - "line-home", - "line-end", - "select-line-home", - "select-line-end", - "visual-line-home", - "visual-line-end", - "select-visual-line-home", - "select-visual-line-end", - "buffer-home", - "buffer-end", - "select-buffer-home", - "select-buffer-end", - "delete-line", - "delete-to-line-end", - "delete-to-line-start", - "backspace", - "delete", - "undo", - "redo", - "word-forward", - "word-backward", - "select-word-forward", - "select-word-backward", - "delete-word-forward", - "delete-word-backward", -] as const - -function mapTextareaKeybindings( - keybinds: Record<string, Keybind.Info[]>, - action: (typeof TEXTAREA_ACTIONS)[number], -): KeyBinding[] { - const configKey = `input_${action.replace(/-/g, "_")}` - const bindings = keybinds[configKey] - if (!bindings) return [] - return bindings.map((binding) => ({ - name: binding.name, - ctrl: binding.ctrl || undefined, - meta: binding.meta || undefined, - shift: binding.shift || undefined, - super: binding.super || undefined, - action, - })) -} - export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable @@ -139,15 +84,7 @@ export function Prompt(props: PromptProps) { } } - const textareaKeybindings = createMemo(() => { - const keybinds = keybind.all - - return [ - { name: "return", action: "submit" }, - { name: "return", meta: true, action: "newline" }, - ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)), - ] satisfies KeyBinding[] - }) + const textareaKeybindings = useTextareaKeybindings() const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! diff --git a/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts b/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts new file mode 100644 index 000000000..36ab03de5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts @@ -0,0 +1,73 @@ +import { createMemo } from "solid-js" +import type { KeyBinding } from "@opentui/core" +import { useKeybind } from "../context/keybind" +import { Keybind } from "@/util/keybind" + +const TEXTAREA_ACTIONS = [ + "submit", + "newline", + "move-left", + "move-right", + "move-up", + "move-down", + "select-left", + "select-right", + "select-up", + "select-down", + "line-home", + "line-end", + "select-line-home", + "select-line-end", + "visual-line-home", + "visual-line-end", + "select-visual-line-home", + "select-visual-line-end", + "buffer-home", + "buffer-end", + "select-buffer-home", + "select-buffer-end", + "delete-line", + "delete-to-line-end", + "delete-to-line-start", + "backspace", + "delete", + "undo", + "redo", + "word-forward", + "word-backward", + "select-word-forward", + "select-word-backward", + "delete-word-forward", + "delete-word-backward", +] as const + +function mapTextareaKeybindings( + keybinds: Record<string, Keybind.Info[]>, + action: (typeof TEXTAREA_ACTIONS)[number], +): KeyBinding[] { + const configKey = `input_${action.replace(/-/g, "_")}` + const bindings = keybinds[configKey] + if (!bindings) return [] + return bindings.map((binding) => ({ + name: binding.name, + ctrl: binding.ctrl || undefined, + meta: binding.meta || undefined, + shift: binding.shift || undefined, + super: binding.super || undefined, + action, + })) +} + +export function useTextareaKeybindings() { + const keybind = useKeybind() + + return createMemo(() => { + const keybinds = keybind.all + + return [ + { name: "return", action: "submit" }, + { name: "return", meta: true, action: "newline" }, + ...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)), + ] satisfies KeyBinding[] + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 76efa72a5..095c45cef 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,16 +1,20 @@ import { createStore } from "solid-js/store" import { createMemo, For, Match, Show, Switch } from "solid-js" import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" +import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" import { useTheme } from "../../context/theme" import type { PermissionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" +import { useTextareaKeybindings } from "../../component/textarea-keybindings" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Locale } from "@/util/locale" +type PermissionStage = "permission" | "always" | "reject" + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { @@ -101,9 +105,11 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const sdk = useSDK() const sync = useSync() const [store, setStore] = createStore({ - always: false, + stage: "permission" as PermissionStage, }) + const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) + const input = createMemo(() => { const tool = props.request.tool if (!tool) return {} @@ -120,7 +126,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { return ( <Switch> - <Match when={store.always}> + <Match when={store.stage === "always"}> <Prompt title="Always allow" body={ @@ -148,7 +154,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { options={{ confirm: "Confirm", cancel: "Cancel" }} escapeKey="cancel" onSelect={(option) => { - setStore("always", false) + setStore("stage", "permission") if (option === "cancel") return sdk.client.permission.reply({ reply: "always", @@ -157,7 +163,19 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { }} /> </Match> - <Match when={!store.always}> + <Match when={store.stage === "reject"}> + <RejectPrompt + onConfirm={(message) => { + sdk.client.permission.reply({ + reply: "reject", + requestID: props.request.id, + message: message || undefined, + }) + }} + onCancel={() => setStore("stage", "permission")} + /> + </Match> + <Match when={store.stage === "permission"}> <Prompt title="Permission required" body={ @@ -215,11 +233,21 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { escapeKey="reject" onSelect={(option) => { if (option === "always") { - setStore("always", true) + setStore("stage", "always") return } + if (option === "reject") { + if (session()?.parentID) { + setStore("stage", "reject") + return + } + sdk.client.permission.reply({ + reply: "reject", + requestID: props.request.id, + }) + } sdk.client.permission.reply({ - reply: option as "once" | "reject", + reply: "once", requestID: props.request.id, }) }} @@ -229,6 +257,71 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { ) } +function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { + let input: TextareaRenderable + const { theme } = useTheme() + const keybind = useKeybind() + const textareaKeybindings = useTextareaKeybindings() + + useKeyboard((evt) => { + if (evt.name === "escape" || keybind.match("app_exit", evt)) { + evt.preventDefault() + props.onCancel() + return + } + if (evt.name === "return") { + evt.preventDefault() + props.onConfirm(input.plainText) + } + }) + + return ( + <box + backgroundColor={theme.backgroundPanel} + border={["left"]} + borderColor={theme.error} + customBorderChars={SplitBorder.customBorderChars} + > + <box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}> + <box flexDirection="row" gap={1} paddingLeft={1}> + <text fg={theme.error}>{"△"}</text> + <text fg={theme.text}>Reject permission</text> + </box> + <box paddingLeft={1}> + <text fg={theme.textMuted}>Tell OpenCode what to do differently</text> + </box> + </box> + <box + flexDirection="row" + flexShrink={0} + paddingTop={1} + paddingLeft={2} + paddingRight={3} + paddingBottom={1} + backgroundColor={theme.backgroundElement} + justifyContent="space-between" + > + <textarea + ref={(val: TextareaRenderable) => (input = val)} + focused + textColor={theme.text} + focusedTextColor={theme.text} + cursorColor={theme.primary} + keyBindings={textareaKeybindings()} + /> + <box flexDirection="row" gap={2} flexShrink={0} marginLeft={1}> + <text fg={theme.text}> + enter <span style={{ fg: theme.textMuted }}>confirm</span> + </text> + <text fg={theme.text}> + esc <span style={{ fg: theme.textMuted }}>cancel</span> + </text> + </box> + </box> + </box> + ) +} + function Prompt<const T extends Record<string, string>>(props: { title: string body: JSX.Element diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 6223d54f2..6d18caefb 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -124,7 +124,7 @@ export namespace PermissionNext { const rule = evaluate(request.permission, pattern, ruleset, s.approved) log.info("evaluated", { permission: request.permission, pattern, action: rule }) if (rule.action === "deny") - throw new AutoRejectedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) + throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission))) if (rule.action === "ask") { const id = input.id ?? Identifier.ascending("permission") return new Promise<void>((resolve, reject) => { @@ -149,6 +149,7 @@ export namespace PermissionNext { z.object({ requestID: Identifier.schema("permission"), reply: Reply, + message: z.string().optional(), }), async (input) => { const s = await state() @@ -161,7 +162,7 @@ export namespace PermissionNext { reply: input.reply, }) if (input.reply === "reject") { - existing.reject(new RejectedError()) + existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError()) // Reject all other pending permissions for this session const sessionID = existing.info.sessionID for (const [id, pending] of Object.entries(s.pending)) { @@ -238,13 +239,22 @@ export namespace PermissionNext { return result } + /** User rejected without message - halts execution */ export class RejectedError extends Error { constructor() { - super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`) + super(`The user rejected permission to use this specific tool call.`) } } - export class AutoRejectedError extends Error { + /** User rejected with message - continues with guidance */ + export class CorrectedError extends Error { + constructor(message: string) { + super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`) + } + } + + /** Auto-rejected by config rule - halts execution */ + export class DeniedError extends Error { constructor(public readonly ruleset: Ruleset) { super( `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index bc727b028..7057a9a95 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1631,13 +1631,14 @@ export namespace Server { requestID: z.string(), }), ), - validator("json", z.object({ reply: PermissionNext.Reply })), + validator("json", z.object({ reply: PermissionNext.Reply, message: z.string().optional() })), async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") await PermissionNext.reply({ requestID: params.requestID, reply: json.reply, + message: json.message, }) return c.json(true) }, diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index f654ca924..04754e761 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -451,7 +451,7 @@ test("ask - throws RejectedError when action is deny", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], }), - ).rejects.toBeInstanceOf(PermissionNext.AutoRejectedError) + ).rejects.toBeInstanceOf(PermissionNext.DeniedError) }, }) }) @@ -628,7 +628,7 @@ test("ask - checks all patterns and stops on first deny", async () => { { permission: "bash", pattern: "rm *", action: "deny" }, ], }), - ).rejects.toBeInstanceOf(PermissionNext.AutoRejectedError) + ).rejects.toBeInstanceOf(PermissionNext.DeniedError) }, }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 702af6324..01de8c183 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1706,6 +1706,7 @@ export class Permission extends HeyApiClient { requestID: string directory?: string reply?: "once" | "always" | "reject" + message?: string }, options?: Options<never, ThrowOnError>, ) { @@ -1717,6 +1718,7 @@ export class Permission extends HeyApiClient { { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "body", key: "reply" }, + { in: "body", key: "message" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f083dc85d..8589364fb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3449,6 +3449,7 @@ export type PermissionRespondResponse = PermissionRespondResponses[keyof Permiss export type PermissionReplyData = { body?: { reply: "once" | "always" | "reject" + message?: string } path: { requestID: string |
