diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 2c99f2a5ef61..b2a4a5eadc7a 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -52,6 +52,36 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", }) +const PermissionPromptResponse = Schema.Literals(["once", "always", "reject"]).annotate({ + description: "Default response selected in permission prompts", +}) + +const PermissionPromptConfirmationAction = Schema.Literals(["never", "always"]).annotate({ + description: "Whether to show an extra confirmation dialog after selecting a permission response", +}) + +const PermissionPromptConfirmationResponse = Schema.Struct({ + once: Schema.optional(PermissionPromptConfirmationAction), + always: Schema.optional(PermissionPromptConfirmationAction), + reject: Schema.optional(PermissionPromptConfirmationAction), +}) + +const PermissionPromptConfirmationRule = Schema.Union([ + PermissionPromptConfirmationAction, + Schema.Record(Schema.String, PermissionPromptConfirmationAction), +]) + +const PermissionPromptConfirmation = Schema.Struct({ + default: Schema.optional(PermissionPromptConfirmationAction), + response: Schema.optional(PermissionPromptConfirmationResponse), + permission: Schema.optional(Schema.Record(Schema.String, PermissionPromptConfirmationRule)), +}).annotate({ description: "Permission prompt confirmation settings" }) + +export const PermissionPrompt = Schema.Struct({ + default_response: Schema.optional(PermissionPromptResponse), + confirmation: Schema.optional(PermissionPromptConfirmation), +}).annotate({ description: "Permission prompt behavior settings" }) + export const Attention = Schema.Struct({ enabled: Schema.optional(Schema.Boolean), notifications: Schema.optional(Schema.Boolean), @@ -75,4 +105,5 @@ export const TuiInfo = Schema.Struct({ scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), + permission_prompt: Schema.optional(PermissionPrompt), }) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 0d4be41dfc0d..ef82fccfecca 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -29,13 +29,14 @@ const log = Log.create({ service: "tui.config" }) export const Info = TuiInfo export type Info = DeepMutable> +type PermissionPromptInfo = NonNullable type Acc = { result: Info plugin_origins: ConfigPlugin.Origin[] } -export type Resolved = Omit & { +export type Resolved = Omit & { attention: { enabled: boolean notifications: boolean @@ -44,6 +45,9 @@ export type Resolved = Omit & sound_pack: string sounds: Partial> } + permission_prompt: PermissionPromptInfo & { + default_response: NonNullable + } keybinds: TuiKeybind.BindingLookupView leader_timeout: number // Internal resolved plugin list used by runtime loading. @@ -253,6 +257,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: bindingDefaults: TuiKeybind.bindingDefaults(), }), leader_timeout: acc.result.leader_timeout ?? KeymapLeaderTimeoutDefault, + permission_prompt: { + ...acc.result.permission_prompt, + default_response: acc.result.permission_prompt?.default_response ?? "once", + }, plugin_origins: acc.plugin_origins.length ? acc.plugin_origins : undefined, } 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 ecbc2f9fd2f2..5fd449c27a51 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -17,8 +17,12 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap" import { usePathFormatter } from "../../context/path-format" +import { Wildcard } from "@opencode-ai/core/util/wildcard" +import type { TuiConfig } from "../../config/tui" -type PermissionStage = "permission" | "always" | "reject" +type PermissionResponse = "once" | "always" | "reject" +type PermissionStage = "permission" | "confirm" | "reject" +type ConfirmationAction = "never" | "always" function filetype(input?: string) { if (!input) return "none" @@ -117,8 +121,10 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const sync = useSync() const [store, setStore] = createStore({ stage: "permission" as PermissionStage, + response: "once" as PermissionResponse, }) const pathFormatter = usePathFormatter() + const tuiConfig = useTuiConfig() const session = createMemo(() => sync.data.session.find((s) => s.id === props.request.sessionID)) @@ -136,16 +142,35 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { const { theme } = useTheme() + const reply = (response: PermissionResponse) => { + void sdk.client.permission.reply({ + reply: response, + requestID: props.request.id, + workspace: project.workspace.current(), + }) + } + + const shouldConfirm = (response: PermissionResponse) => + confirmationAction(tuiConfig.permission_prompt.confirmation, props.request, response) === "always" + return ( - + - + + + + + + + This will allow the following patterns until OpenCode is restarted @@ -168,11 +193,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { onSelect={(option) => { setStore("stage", "permission") if (option === "cancel") return - void sdk.client.permission.reply({ - reply: "always", - requestID: props.request.id, - workspace: project.workspace.current(), - }) + reply(store.response) }} /> @@ -406,11 +427,16 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { header={header()} body={current.body} options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + defaultOption={tuiConfig.permission_prompt.default_response} escapeKey="reject" fullscreen onSelect={(option) => { if (option === "always") { - setStore("stage", "always") + if (shouldConfirm(option)) { + setStore({ stage: "confirm", response: option }) + return + } + reply(option) return } if (option === "reject") { @@ -418,18 +444,18 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { setStore("stage", "reject") return } - void sdk.client.permission.reply({ - reply: "reject", - requestID: props.request.id, - workspace: project.workspace.current(), - }) + if (shouldConfirm(option)) { + setStore({ stage: "confirm", response: option }) + return + } + reply(option) + return + } + if (shouldConfirm(option)) { + setStore({ stage: "confirm", response: option }) return } - void sdk.client.permission.reply({ - reply: "once", - requestID: props.request.id, - workspace: project.workspace.current(), - }) + reply(option) }} /> ) @@ -441,6 +467,29 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { ) } +function confirmationTitle(response: PermissionResponse) { + if (response === "always") return "Always allow" + if (response === "reject") return "Reject permission" + return "Allow once" +} + +export function confirmationAction( + confirmation: TuiConfig.Resolved["permission_prompt"]["confirmation"], + request: PermissionRequest, + response: PermissionResponse, +): ConfirmationAction { + if (!confirmation) return response === "always" ? "always" : "never" + + const rule = confirmation.permission?.[request.permission] + if (!rule) return confirmation.response?.[response] ?? confirmation.default ?? "never" + if (typeof rule === "string") return rule + + return Object.entries(rule).reduce( + (result, [pattern, value]) => (request.patterns.some((item) => Wildcard.match(item, pattern)) ? value : result), + confirmation.response?.[response] ?? confirmation.default ?? "never", + ) +} + function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: () => void }) { let input: TextareaRenderable const { theme } = useTheme() @@ -527,6 +576,7 @@ function Prompt>(props: { header?: JSX.Element body: JSX.Element options: T + defaultOption?: keyof T escapeKey?: keyof T fullscreen?: boolean onSelect: (option: keyof T) => void @@ -536,7 +586,7 @@ function Prompt>(props: { const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ - selected: keys[0], + selected: props.defaultOption && keys.includes(props.defaultOption) ? props.defaultOption : keys[0], expanded: false, }) const narrow = createMemo(() => dimensions().width < 80) diff --git a/packages/opencode/test/cli/tui/permission-prompt.test.ts b/packages/opencode/test/cli/tui/permission-prompt.test.ts new file mode 100644 index 000000000000..0745a438b007 --- /dev/null +++ b/packages/opencode/test/cli/tui/permission-prompt.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import { confirmationAction } from "@/cli/cmd/tui/routes/session/permission" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import type { TuiConfig } from "@/cli/cmd/tui/config/tui" + +function request(input: Partial = {}): PermissionRequest { + return { + id: "per_test", + sessionID: "ses_test", + permission: "bash", + patterns: ["touch test"], + metadata: {}, + always: ["touch *"], + ...input, + } +} + +test("permission prompt confirms allow always by default", () => { + expect(confirmationAction(undefined, request(), "once")).toBe("never") + expect(confirmationAction(undefined, request(), "always")).toBe("always") + expect(confirmationAction(undefined, request(), "reject")).toBe("never") +}) + +test("permission prompt confirmation supports response defaults", () => { + const confirmation: TuiConfig.Resolved["permission_prompt"]["confirmation"] = { + default: "never", + response: { + reject: "always", + }, + } + + expect(confirmationAction(confirmation, request(), "always")).toBe("never") + expect(confirmationAction(confirmation, request(), "reject")).toBe("always") +}) + +test("permission prompt confirmation supports permission pattern overrides", () => { + const confirmation: TuiConfig.Resolved["permission_prompt"]["confirmation"] = { + default: "never", + permission: { + bash: { + "*": "never", + "rm *": "always", + }, + }, + } + + expect(confirmationAction(confirmation, request({ patterns: ["touch test"] }), "always")).toBe("never") + expect(confirmationAction(confirmation, request({ patterns: ["rm test"] }), "always")).toBe("always") +}) diff --git a/packages/opencode/test/fixture/tui-runtime.ts b/packages/opencode/test/fixture/tui-runtime.ts index 75fc2fdc44b2..c316bb833aef 100644 --- a/packages/opencode/test/fixture/tui-runtime.ts +++ b/packages/opencode/test/fixture/tui-runtime.ts @@ -5,10 +5,11 @@ import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind" type PluginSpec = string | [string, Record] -type ResolvedInput = Omit & { +type ResolvedInput = Omit & { attention?: Partial keybinds?: Partial leader_timeout?: number + permission_prompt?: Partial } export function createTuiResolvedKeybinds(input: Partial = {}): TuiConfig.Resolved["keybinds"] { @@ -32,6 +33,10 @@ export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Re sounds: {}, ...input.attention, }, + permission_prompt: { + default_response: "once", + ...input.permission_prompt, + }, keybinds: createTuiResolvedKeybinds(keybinds), leader_timeout: input.leader_timeout ?? 2000, } diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index cd668a3beff8..08560d981462 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -370,6 +370,18 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`). }, "diff_style": "auto", "mouse": true, + "permission_prompt": { + "default_response": "always", + "confirmation": { + "default": "never", + "permission": { + "bash": { + "rm *": "always", + "git push*": "always" + } + } + } + }, "attention": { "enabled": true, "notifications": true, @@ -396,10 +408,40 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.** - `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout. - `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved. +- `permission_prompt.default_response` - Controls the response selected by default in permission prompts. Can be `"once"`, `"always"`, or `"reject"`. Defaults to `"once"`. +- `permission_prompt.confirmation` - Controls whether a second confirmation dialog is shown after selecting a permission response. If omitted, OpenCode keeps the built-in behavior of confirming `"always"` responses. - `attention` - Configures TUI desktop notifications and sounds. Disabled by default. Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. +### Permission prompt + +Use `permission_prompt` to customize permission prompt defaults. This only changes the TUI prompt behavior; permission rules are still controlled by `permission` in `opencode.json`. + +```json title="tui.json" +{ + "$schema": "https://opencode.ai/tui.json", + "permission_prompt": { + "default_response": "always", + "confirmation": { + "default": "never", + "permission": { + "bash": { + "rm *": "always", + "git push*": "always" + } + } + } + } +} +``` + +- `default_response` - Selects which response is highlighted by default: `"once"`, `"always"`, or `"reject"`. +- `confirmation.default` - Sets the default second-confirmation behavior. Use `"never"` to skip confirmations by default, or `"always"` to require them. +- `confirmation.response` - Overrides confirmation behavior by response, for example `{ "always": "never", "reject": "always" }`. +- `confirmation.permission` - Overrides confirmation behavior by permission name. Values can be `"never"`, `"always"`, or pattern rules such as `{ "rm *": "always" }`. +- Pattern rules use the same wildcard matching style as permissions, and the last matching rule wins. + ### Attention The TUI can request attention for questions, permissions, session errors, and completed sessions. Enable it with `attention.enabled`; built-in events play sounds when triggered, and non-subagent events request desktop notifications only when the terminal is blurred.