Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/opencode/src/cli/cmd/tui/config/tui-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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),
})
10 changes: 9 additions & 1 deletion packages/opencode/src/cli/cmd/tui/config/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ const log = Log.create({ service: "tui.config" })

export const Info = TuiInfo
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
type PermissionPromptInfo = NonNullable<Info["permission_prompt"]>

type Acc = {
result: Info
plugin_origins: ConfigPlugin.Origin[]
}

export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout" | "permission_prompt"> & {
attention: {
enabled: boolean
notifications: boolean
Expand All @@ -44,6 +45,9 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> &
sound_pack: string
sounds: Partial<Record<TuiAttentionSoundName, string>>
}
permission_prompt: PermissionPromptInfo & {
default_response: NonNullable<PermissionPromptInfo["default_response"]>
}
keybinds: TuiKeybind.BindingLookupView
leader_timeout: number
// Internal resolved plugin list used by runtime loading.
Expand Down Expand Up @@ -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,
}

Expand Down
92 changes: 71 additions & 21 deletions packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))

Expand All @@ -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 (
<Switch>
<Match when={store.stage === "always"}>
<Match when={store.stage === "confirm"}>
<Prompt
title="Always allow"
title={confirmationTitle(store.response)}
body={
<Switch>
<Match when={props.request.always.length === 1 && props.request.always[0] === "*"}>
<Match
when={store.response === "always" && props.request.always.length === 1 && props.request.always[0] === "*"}
>
<TextBody title={"This will allow " + props.request.permission + " until OpenCode is restarted."} />
</Match>
<Match when={store.response === "once"}>
<TextBody title="This will allow this request once." />
</Match>
<Match when={store.response === "reject"}>
<TextBody title="This will reject this request." />
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={theme.textMuted}>This will allow the following patterns until OpenCode is restarted</text>
Expand All @@ -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)
}}
/>
</Match>
Expand Down Expand Up @@ -406,30 +427,35 @@ 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") {
if (session()?.parentID) {
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)
}}
/>
)
Expand All @@ -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()
Expand Down Expand Up @@ -527,6 +576,7 @@ function Prompt<const T extends Record<string, string>>(props: {
header?: JSX.Element
body: JSX.Element
options: T
defaultOption?: keyof T
escapeKey?: keyof T
fullscreen?: boolean
onSelect: (option: keyof T) => void
Expand All @@ -536,7 +586,7 @@ function Prompt<const T extends Record<string, string>>(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)
Expand Down
49 changes: 49 additions & 0 deletions packages/opencode/test/cli/tui/permission-prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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")
})
7 changes: 6 additions & 1 deletion packages/opencode/test/fixture/tui-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>]
type ResolvedInput = Omit<TuiConfig.Resolved, "attention" | "keybinds" | "leader_timeout"> & {
type ResolvedInput = Omit<TuiConfig.Resolved, "attention" | "keybinds" | "leader_timeout" | "permission_prompt"> & {
attention?: Partial<TuiConfig.Resolved["attention"]>
keybinds?: Partial<TuiKeybind.Keybinds>
leader_timeout?: number
permission_prompt?: Partial<TuiConfig.Resolved["permission_prompt"]>
}

export function createTuiResolvedKeybinds(input: Partial<TuiKeybind.Keybinds> = {}): TuiConfig.Resolved["keybinds"] {
Expand All @@ -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,
}
Expand Down
42 changes: 42 additions & 0 deletions packages/web/src/content/docs/tui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
Loading