summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-01-03 01:34:23 -0500
committerDax Raad <[email protected]>2026-01-03 01:34:23 -0500
commit47c670aea9f10c4f1c74fe5d494a4564be3ed777 (patch)
tree536b42fdc1a97d4fb491615c432e2ecff0a3720c
parent2b66b31d96ab9314af7845e02807fbaf2fbe5d0d (diff)
downloadopencode-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.tsx69
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/textarea-keybindings.ts73
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx105
-rw-r--r--packages/opencode/src/permission/next.ts18
-rw-r--r--packages/opencode/src/server/server.ts3
-rw-r--r--packages/opencode/test/permission/next.test.ts4
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts2
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts1
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