From 7d722f13197a83a7df7715408bd74b69f998d78e Mon Sep 17 00:00:00 2001 From: Minh Nguyen <44449792+quickbeard@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:39:28 +0700 Subject: [PATCH 1/3] feat(tui): require consent before enabling auto-approve permissions Launching with --auto (or its hidden aliases) no longer enables auto-approve silently: a confirm dialog explains the risk and the mode turns on only after the user confirms, followed by a warning toast reusing the same message. Enabling auto mode via shift+tab goes through the same one-time gate; consent is remembered for the rest of the run, so later toggles are silent. Declining or dismissing stays in manual mode. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 11 ++++++++++ DIVERGENCES.md | 1 + packages/tui/src/app.tsx | 28 ++++++++++++++++++++++++- packages/tui/src/context/permission.tsx | 19 ++++++++++------- 4 files changed, 51 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e44e529c0..fb4bab15ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ file is the human-readable summary of what changed and why. ## Unreleased +### TUI + +- **Auto-approve now asks for consent** — launching with `--auto` (or its + hidden aliases) no longer enables auto-approve silently: a confirm dialog + explains that permission requests not explicitly denied will be approved + automatically, and the mode turns on only after you confirm, followed by a + warning toast with the same message. Switching into auto mode with + `shift+tab` goes through the same dialog and toast the first time; consent is + remembered for the rest of the run, so later toggles are instant. Declining + or dismissing the dialog stays in manual mode. + ## 1.18.4-2 ### TUI diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 801100d274..471b120fbe 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -162,6 +162,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | | `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. | | `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Permission-mode indicator + shift+tab cycle. keybind.ts: added `permission_mode_cycle: keybind("shift+tab", ...)` + its `permission.mode` CommandMap entry; `agent_cycle_reverse` default `"shift+tab"` → `"none"` (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray `manual` (`theme.textMuted`) in normal mode, red `auto` (`theme.error`) in auto mode (upstream only showed gray `auto`); the idle footer's shortcut hints gain a `shift+tab modes` text (via a `modeShortcut` `useCommandShortcut("permission.mode")` memo) left of `tab agents`, in the same no-usage `Match` arm. app.tsx: `"permission.mode"` added to `appBindingCommands` so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the `permission_mode_cycle` definition + mapping, the `agent_cycle_reverse` `"none"` default, the badge `Show` block, the `modeShortcut` memo + hint text, and the `appBindingCommands` entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. | +| `packages/tui/src/context/permission.tsx`, `packages/tui/src/app.tsx` | Auto-approve consent gate. permission.tsx: mode always starts `"normal"` (upstream initialized it from `args.auto`); `toggle()` replaced by `set(mode)` plus a per-run `consented` flag + `consent()`. app.tsx: shared `enableAutoApprove()` (module consts `AUTO_APPROVE_TITLE`/`AUTO_APPROVE_MESSAGE`) shows a `DialogConfirm` and, on confirm, a warning toast reusing the same title/message, then sets the mode; both entry points funnel through it — an `onMount` when `args.auto` is set, and the `permission.mode` command (shift+tab / palette), which enables via the gate and disables directly. Consent is asked once per run; later toggles are silent. | Auto-approve is dangerous, so `--auto` (and its hidden aliases) and the first shift+tab into auto require explicit confirmation; declining or dismissing the dialog stays in manual mode. | Keep the consent flag + gate flow and the `toggle()` removal; take upstream's surrounding changes. If upstream adds new writers of the permission mode, route their enable path through `enableAutoApprove()`. | ## Known rebrand gaps diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 9a32730b72..3054f525e3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -145,6 +145,10 @@ const appBindingCommands = [ "permission.mode", ] as const +const AUTO_APPROVE_TITLE = "Auto-approve Permissions" +const AUTO_APPROVE_MESSAGE = + "Permission requests that are not explicitly denied will be approved automatically. CoDev Code can run commands and edit files without asking." + export type TuiInput = { url: string args: Args @@ -492,7 +496,25 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } }) + // Auto-approve is dangerous, so both entry points (--auto and the shift+tab + // permission-mode cycle) funnel through this one consent gate: a confirm + // dialog plus a warning toast, shown once per run — later toggles are silent. + async function enableAutoApprove() { + if (!local.permission.consented) { + const choice = await DialogConfirm.show(dialog, AUTO_APPROVE_TITLE, AUTO_APPROVE_MESSAGE) + if (choice !== true) return + local.permission.consent() + toast.show({ variant: "warning", title: AUTO_APPROVE_TITLE, message: AUTO_APPROVE_MESSAGE }) + } + local.permission.set("auto") + } + const args = useArgs() + onMount(() => { + if (!args.auto) return + void enableAutoApprove() + }) + onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) @@ -1044,8 +1066,12 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi local.permission.mode === "auto" ? "Disable auto-approve permissions" : "Enable auto-approve permissions", category: "System", run: () => { - local.permission.toggle() dialog.clear() + if (local.permission.mode === "auto") { + local.permission.set("normal") + return + } + void enableAutoApprove() }, }, ].map((command) => ({ diff --git a/packages/tui/src/context/permission.tsx b/packages/tui/src/context/permission.tsx index 13a6ba8223..6f9242da8e 100644 --- a/packages/tui/src/context/permission.tsx +++ b/packages/tui/src/context/permission.tsx @@ -1,5 +1,4 @@ import { createStore } from "solid-js/store" -import { useArgs } from "./args" import { createSimpleContext } from "./helper" export type PermissionMode = "auto" | "normal" @@ -7,20 +6,26 @@ export type PermissionMode = "auto" | "normal" export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ name: "Permission", init: () => { - const args = useArgs() - const [store, setStore] = createStore<{ mode: PermissionMode }>({ - mode: args.auto ? "auto" : "normal", + // Auto-approve requires explicit consent, so `--auto` no longer sets the + // mode here — app.tsx asks on startup and enables it only after the user + // confirms (enableAutoApprove). + const [store, setStore] = createStore<{ mode: PermissionMode; consented: boolean }>({ + mode: "normal", + consented: false, }) return { get mode() { return store.mode }, + get consented() { + return store.consented + }, + consent() { + setStore("consented", true) + }, set(mode: PermissionMode) { setStore("mode", mode) }, - toggle() { - setStore("mode", (mode) => (mode === "auto" ? "normal" : "auto")) - }, } }, }) From 4a2962793d4f8eeb70f9b5d8bda93096236383f4 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 30 Jul 2026 08:02:58 +0700 Subject: [PATCH 2/3] fix(tui): open the auto-approve consent dialog on cancel The consent dialog inherited DialogConfirm's confirm-first default, so a stray return granted auto-approve. Add an opt-in `defaultChoice` prop and start this dialog on cancel, name the risk as "dangerous" in the shared message that both the dialog and the warning toast render, and cover the gate with a view test: the prompt on `--auto`, return declining, and consent being remembered for the rest of the run. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +- DIVERGENCES.md | 2 +- packages/tui/src/app.tsx | 4 +- packages/tui/src/ui/dialog-confirm.tsx | 14 +- .../test/auto-approve-consent.view.test.tsx | 171 ++++++++++++++++++ 5 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 packages/tui/test/auto-approve-consent.view.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4bab15ff..15f0815dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,13 @@ file is the human-readable summary of what changed and why. - **Auto-approve now asks for consent** — launching with `--auto` (or its hidden aliases) no longer enables auto-approve silently: a confirm dialog - explains that permission requests not explicitly denied will be approved - automatically, and the mode turns on only after you confirm, followed by a - warning toast with the same message. Switching into auto mode with - `shift+tab` goes through the same dialog and toast the first time; consent is - remembered for the rest of the run, so later toggles are instant. Declining - or dismissing the dialog stays in manual mode. + warns that permission requests not explicitly denied will be approved + automatically and that this is dangerous, and the mode turns on only after + you confirm, followed by a warning toast with the same message. The dialog + opens on Cancel, so a stray return declines instead of granting it. + Switching into auto mode with `shift+tab` goes through the same dialog and + toast the first time; consent is remembered for the rest of the run, so later + toggles are instant. Declining or dismissing the dialog stays in manual mode. ## 1.18.4-2 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 471b120fbe..07884b91f4 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -162,7 +162,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | | `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. | | `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Permission-mode indicator + shift+tab cycle. keybind.ts: added `permission_mode_cycle: keybind("shift+tab", ...)` + its `permission.mode` CommandMap entry; `agent_cycle_reverse` default `"shift+tab"` → `"none"` (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray `manual` (`theme.textMuted`) in normal mode, red `auto` (`theme.error`) in auto mode (upstream only showed gray `auto`); the idle footer's shortcut hints gain a `shift+tab modes` text (via a `modeShortcut` `useCommandShortcut("permission.mode")` memo) left of `tab agents`, in the same no-usage `Match` arm. app.tsx: `"permission.mode"` added to `appBindingCommands` so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the `permission_mode_cycle` definition + mapping, the `agent_cycle_reverse` `"none"` default, the badge `Show` block, the `modeShortcut` memo + hint text, and the `appBindingCommands` entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. | -| `packages/tui/src/context/permission.tsx`, `packages/tui/src/app.tsx` | Auto-approve consent gate. permission.tsx: mode always starts `"normal"` (upstream initialized it from `args.auto`); `toggle()` replaced by `set(mode)` plus a per-run `consented` flag + `consent()`. app.tsx: shared `enableAutoApprove()` (module consts `AUTO_APPROVE_TITLE`/`AUTO_APPROVE_MESSAGE`) shows a `DialogConfirm` and, on confirm, a warning toast reusing the same title/message, then sets the mode; both entry points funnel through it — an `onMount` when `args.auto` is set, and the `permission.mode` command (shift+tab / palette), which enables via the gate and disables directly. Consent is asked once per run; later toggles are silent. | Auto-approve is dangerous, so `--auto` (and its hidden aliases) and the first shift+tab into auto require explicit confirmation; declining or dismissing the dialog stays in manual mode. | Keep the consent flag + gate flow and the `toggle()` removal; take upstream's surrounding changes. If upstream adds new writers of the permission mode, route their enable path through `enableAutoApprove()`. | +| `packages/tui/src/context/permission.tsx`, `packages/tui/src/app.tsx`, `packages/tui/src/ui/dialog-confirm.tsx` | Auto-approve consent gate. permission.tsx: mode always starts `"normal"` (upstream initialized it from `args.auto`); `toggle()` replaced by `set(mode)` plus a per-run `consented` flag + `consent()`. app.tsx: shared `enableAutoApprove()` (module consts `AUTO_APPROVE_TITLE`/`AUTO_APPROVE_MESSAGE`, the message names the risk as "dangerous" so the dialog and the toast both carry it) shows a `DialogConfirm` opened on cancel and, on confirm, a warning toast reusing the same title/message, then sets the mode; both entry points funnel through it — an `onMount` when `args.auto` is set, and the `permission.mode` command (shift+tab / palette), which enables via the gate and disables directly. Consent is asked once per run; later toggles are silent. dialog-confirm.tsx: optional `defaultChoice` prop + 5th `DialogConfirm.show` arg selecting the initially-active button; omitted keeps upstream's confirm-first default (fork-only test file: `packages/tui/test/auto-approve-consent.view.test.tsx`). | Auto-approve is dangerous, so `--auto` (and its hidden aliases) and the first shift+tab into auto require explicit confirmation; declining or dismissing the dialog stays in manual mode, and opening on cancel keeps a stray return from granting it. | Keep the consent flag + gate flow, the `toggle()` removal, and the `defaultChoice` prop; take upstream's surrounding changes. If upstream adds new writers of the permission mode, route their enable path through `enableAutoApprove()`. | ## Known rebrand gaps diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 3054f525e3..de7c3431a8 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -147,7 +147,7 @@ const appBindingCommands = [ const AUTO_APPROVE_TITLE = "Auto-approve Permissions" const AUTO_APPROVE_MESSAGE = - "Permission requests that are not explicitly denied will be approved automatically. CoDev Code can run commands and edit files without asking." + "Permission requests that are not explicitly denied will be approved automatically. This is dangerous: CoDev Code can run commands and edit files without asking." export type TuiInput = { url: string @@ -501,7 +501,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi // dialog plus a warning toast, shown once per run — later toggles are silent. async function enableAutoApprove() { if (!local.permission.consented) { - const choice = await DialogConfirm.show(dialog, AUTO_APPROVE_TITLE, AUTO_APPROVE_MESSAGE) + const choice = await DialogConfirm.show(dialog, AUTO_APPROVE_TITLE, AUTO_APPROVE_MESSAGE, undefined, "cancel") if (choice !== true) return local.permission.consent() toast.show({ variant: "warning", title: AUTO_APPROVE_TITLE, message: AUTO_APPROVE_MESSAGE }) diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index a09847af2f..34041a2026 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -12,6 +12,9 @@ export type DialogConfirmProps = { onConfirm?: () => void onCancel?: () => void label?: string + // Fork: a dangerous confirm (auto-approve) starts on cancel so a stray return + // can't grant it; omitted means upstream's confirm-first behavior. + defaultChoice?: "confirm" | "cancel" } export type DialogConfirmResult = boolean | undefined @@ -20,7 +23,7 @@ export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const { theme } = useTheme() const [store, setStore] = createStore({ - active: "confirm" as "confirm" | "cancel", + active: props.defaultChoice ?? ("confirm" as "confirm" | "cancel"), }) useBindings(() => ({ @@ -90,7 +93,13 @@ export function DialogConfirm(props: DialogConfirmProps) { ) } -DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => { +DialogConfirm.show = ( + dialog: DialogContext, + title: string, + message: string, + label?: string, + defaultChoice?: "confirm" | "cancel", +) => { return new Promise((resolve) => { dialog.replace( () => ( @@ -100,6 +109,7 @@ DialogConfirm.show = (dialog: DialogContext, title: string, message: string, lab onConfirm={() => resolve(true)} onCancel={() => resolve(false)} label={label} + defaultChoice={defaultChoice} /> ), () => resolve(undefined), diff --git a/packages/tui/test/auto-approve-consent.view.test.tsx b/packages/tui/test/auto-approve-consent.view.test.tsx new file mode 100644 index 0000000000..54804ad23c --- /dev/null +++ b/packages/tui/test/auto-approve-consent.view.test.tsx @@ -0,0 +1,171 @@ +import { expect, mock, test } from "bun:test" +import type { TuiPluginApi, TuiSlotPlugin } from "@opencode-ai/plugin/tui" +import { createTestRenderer } from "@opentui/core/testing" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Global } from "@opencode-ai/core/global" +import { createTuiResolvedConfig } from "./fixture/tui-runtime" +import { createEventSource, createFetch, directory, json, worktree } from "./fixture/tui-sdk" + +const DIALOG = "Auto-approve Permissions" +const MANUAL = /Build\s+manual/ +const AUTO = /Build\s+auto\b/ + +const session = { + id: "ses_auto", + title: "Demo session", + slug: "auto", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, +} + +// A primary agent so the prompt footer renders the agent name plus the +// permission-mode badge, and a paid model so the configure gate stays closed. +const agent = { name: "build", mode: "primary", permission: {}, options: {} } + +async function waitFrame(setup: Awaited>, fn: (frame: string) => boolean) { + const start = Date.now() + while (true) { + await setup.renderOnce() + const frame = setup.captureCharFrame() + if (fn(frame)) return frame + if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame) + await Bun.sleep(25) + } +} + +// Boots the real app with `--auto` against a fetch fixture; the sidebar +// builtins are registered so the chat view (and its footer badge) paints. +async function boot() { + const setup = await createTestRenderer({ width: 100, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const calls = createFetch((url) => { + if (url.pathname === "/session") return json([session]) + if (url.pathname.startsWith(`/session/${session.id}/message`)) return json([]) + if (url.pathname === `/session/${session.id}`) return json(session) + if (url.pathname === "/project/proj_test/directories") return json([]) + if (url.pathname === "/agent") return json([agent]) + if (url.pathname === "/api/agent") + return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [agent] }) + if (url.pathname === "/config/providers") + return json({ + providers: [{ id: "test", name: "Test", models: { m1: { name: "M1", cost: { input: 1 } } } }], + default: { test: "m1" }, + }) + return undefined + }, events) + let api: TuiPluginApi | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + const { run } = await import("../src/app") + const task = Effect.runPromise( + run({ + url: "http://test", + directory, + config: createTuiResolvedConfig({ plugin_enabled: {} }), + fetch: calls.fetch, + events: events.source, + args: { continue: true, auto: true }, + pluginHost: { + async start(input) { + const hostSlots = input.runtime.setupSlots(input.api) + const { createBuiltinPlugins } = await import("../src/feature-plugins/builtins") + const sidebar = createBuiltinPlugins({ experimentalEventSystem: false }).filter((plugin) => + plugin.id.startsWith("internal:sidebar-"), + ) + for (const plugin of sidebar) { + const scoped = { + ...input.api, + slots: { + register: (registration: TuiSlotPlugin) => { + hostSlots.register({ ...registration, id: plugin.id }) + return plugin.id + }, + }, + } as typeof input.api + await plugin.tui(scoped, undefined, {} as never) + } + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + return { + setup, + task, + api: () => api, + async stop() { + api?.keymap.dispatchCommand("app.exit") + await task + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + }, + } +} + +test("--auto asks for consent and stays manual until it is given", async () => { + const boot_ = await boot() + try { + const frame = await waitFrame(boot_.setup, (f) => f.includes(DIALOG) && f.includes("Confirm")) + // The warning has to name the risk, and the mode must not flip before the + // user answers. + expect(frame).toContain("dangerous") + expect(frame).toMatch(MANUAL) + expect(frame).not.toMatch(AUTO) + } finally { + await boot_.stop() + } +}, 60_000) + +test("return at the consent dialog declines because it opens on cancel", async () => { + const boot_ = await boot() + try { + await waitFrame(boot_.setup, (f) => f.includes(DIALOG) && f.includes("Confirm")) + + // No arrow keys: a stray return must land on cancel, leaving manual mode + // and showing no warning toast. + boot_.setup.mockInput.pressEnter() + const frame = await waitFrame(boot_.setup, (f) => !f.includes(DIALOG)) + expect(frame).toMatch(MANUAL) + expect(frame).not.toMatch(AUTO) + expect(frame).not.toContain("dangerous") + } finally { + await boot_.stop() + } +}, 60_000) + +test("confirming enables auto-approve, warns, and is remembered for the run", async () => { + const boot_ = await boot() + try { + await waitFrame(boot_.setup, (f) => f.includes(DIALOG) && f.includes("Confirm")) + + boot_.setup.mockInput.pressArrow("right") + boot_.setup.mockInput.pressEnter() + const enabled = await waitFrame(boot_.setup, (f) => AUTO.test(f) && !f.includes("Confirm")) + // The dialog is gone, so the surviving warning is the toast. + expect(enabled).toContain("dangerous") + + // Disabling goes straight through, and re-enabling no longer asks: consent + // is remembered for the rest of the run. The toast reuses the dialog title, + // so "Confirm" (a button, dialog-only) is what proves no dialog reopened. + boot_.api()?.keymap.dispatchCommand("permission.mode") + const disabled = await waitFrame(boot_.setup, (f) => MANUAL.test(f)) + expect(disabled).not.toContain("Confirm") + + boot_.api()?.keymap.dispatchCommand("permission.mode") + const reenabled = await waitFrame(boot_.setup, (f) => AUTO.test(f)) + expect(reenabled).not.toContain("Confirm") + } finally { + await boot_.stop() + } +}, 60_000) From 6475e700dba3bd5f2bfbe229b6837fbfbec2dcef Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 30 Jul 2026 08:16:11 +0700 Subject: [PATCH 3/3] feat(tui): redden "dangerous" in the auto-approve warning The consent dialog and its toast now render the risk word in theme.error, the same red as the `auto` badge, so the warning reads as a danger signal. The message is passed as an accessor because renderables can only be created during render and the gate runs outside it, which widens DialogConfirm's and Toast's `message` to accept one. The toast no longer replays the dialog: it restates the mode in a single line, since it is only on screen for a few seconds. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +-- DIVERGENCES.md | 2 +- packages/tui/src/app.tsx | 25 ++++++++++++--- packages/tui/src/ui/dialog-confirm.tsx | 11 ++++--- packages/tui/src/ui/toast.tsx | 15 +++++++-- .../test/auto-approve-consent.view.test.tsx | 32 ++++++++++++++++--- 6 files changed, 72 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15f0815dba..73ac88cf06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ file is the human-readable summary of what changed and why. - **Auto-approve now asks for consent** — launching with `--auto` (or its hidden aliases) no longer enables auto-approve silently: a confirm dialog warns that permission requests not explicitly denied will be approved - automatically and that this is dangerous, and the mode turns on only after - you confirm, followed by a warning toast with the same message. The dialog + automatically and that this is dangerous — the word in red, like the `auto` + badge — and the mode turns on only after you confirm, followed by a warning + toast restating it in one line. The dialog opens on Cancel, so a stray return declines instead of granting it. Switching into auto mode with `shift+tab` goes through the same dialog and toast the first time; consent is remembered for the rest of the run, so later diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 07884b91f4..82fc7222f7 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -162,7 +162,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. | | `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. | | `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Permission-mode indicator + shift+tab cycle. keybind.ts: added `permission_mode_cycle: keybind("shift+tab", ...)` + its `permission.mode` CommandMap entry; `agent_cycle_reverse` default `"shift+tab"` → `"none"` (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray `manual` (`theme.textMuted`) in normal mode, red `auto` (`theme.error`) in auto mode (upstream only showed gray `auto`); the idle footer's shortcut hints gain a `shift+tab modes` text (via a `modeShortcut` `useCommandShortcut("permission.mode")` memo) left of `tab agents`, in the same no-usage `Match` arm. app.tsx: `"permission.mode"` added to `appBindingCommands` so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the `permission_mode_cycle` definition + mapping, the `agent_cycle_reverse` `"none"` default, the badge `Show` block, the `modeShortcut` memo + hint text, and the `appBindingCommands` entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. | -| `packages/tui/src/context/permission.tsx`, `packages/tui/src/app.tsx`, `packages/tui/src/ui/dialog-confirm.tsx` | Auto-approve consent gate. permission.tsx: mode always starts `"normal"` (upstream initialized it from `args.auto`); `toggle()` replaced by `set(mode)` plus a per-run `consented` flag + `consent()`. app.tsx: shared `enableAutoApprove()` (module consts `AUTO_APPROVE_TITLE`/`AUTO_APPROVE_MESSAGE`, the message names the risk as "dangerous" so the dialog and the toast both carry it) shows a `DialogConfirm` opened on cancel and, on confirm, a warning toast reusing the same title/message, then sets the mode; both entry points funnel through it — an `onMount` when `args.auto` is set, and the `permission.mode` command (shift+tab / palette), which enables via the gate and disables directly. Consent is asked once per run; later toggles are silent. dialog-confirm.tsx: optional `defaultChoice` prop + 5th `DialogConfirm.show` arg selecting the initially-active button; omitted keeps upstream's confirm-first default (fork-only test file: `packages/tui/test/auto-approve-consent.view.test.tsx`). | Auto-approve is dangerous, so `--auto` (and its hidden aliases) and the first shift+tab into auto require explicit confirmation; declining or dismissing the dialog stays in manual mode, and opening on cancel keeps a stray return from granting it. | Keep the consent flag + gate flow, the `toggle()` removal, and the `defaultChoice` prop; take upstream's surrounding changes. If upstream adds new writers of the permission mode, route their enable path through `enableAutoApprove()`. | +| `packages/tui/src/context/permission.tsx`, `packages/tui/src/app.tsx`, `packages/tui/src/ui/dialog-confirm.tsx`, `packages/tui/src/ui/toast.tsx` | Auto-approve consent gate. permission.tsx: mode always starts `"normal"` (upstream initialized it from `args.auto`); `toggle()` replaced by `set(mode)` plus a per-run `consented` flag + `consent()`. app.tsx: shared `enableAutoApprove()` (title in the `AUTO_APPROVE_TITLE` module const, body from an `autoApproveMessage` accessor, and a shorter one-line `autoApproveToast` accessor for the toast, which only has a few seconds on screen; both redden the word "dangerous" with `theme.error` and are passed unevaluated because renderables can only be created during render) shows a `DialogConfirm` opened on cancel and, on confirm, a warning toast under the same title, then sets the mode; both entry points funnel through it — an `onMount` when `args.auto` is set, and the `permission.mode` command (shift+tab / palette), which enables via the gate and disables directly. Consent is asked once per run; later toggles are silent. dialog-confirm.tsx: optional `defaultChoice` prop + 5th `DialogConfirm.show` arg selecting the initially-active button (omitted keeps upstream's confirm-first default); `message` widened to `JSX.Element \| (() => JSX.Element)` and called at the render site. toast.tsx: same `message` widening, resolved by a `message()` helper in `Toast()` (fork-only test file: `packages/tui/test/auto-approve-consent.view.test.tsx`). | Auto-approve is dangerous, so `--auto` (and its hidden aliases) and the first shift+tab into auto require explicit confirmation; declining or dismissing the dialog stays in manual mode, opening on cancel keeps a stray return from granting it, and the risk word is red like the `auto` badge. | Keep the consent flag + gate flow, the `toggle()` removal, the `defaultChoice` prop, and the accessor-aware `message` on both dialog and toast; take upstream's surrounding changes. If upstream adds new writers of the permission mode, route their enable path through `enableAutoApprove()`. | ## Known rebrand gaps diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index de7c3431a8..f7bf0214f6 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -146,8 +146,6 @@ const appBindingCommands = [ ] as const const AUTO_APPROVE_TITLE = "Auto-approve Permissions" -const AUTO_APPROVE_MESSAGE = - "Permission requests that are not explicitly denied will be approved automatically. This is dangerous: CoDev Code can run commands and edit files without asking." export type TuiInput = { url: string @@ -496,15 +494,34 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi } }) + // The risk word is red like the `auto` badge so the warning reads as a danger + // signal. Passed unevaluated: renderables can only be created while a + // renderer is active, and the gate runs outside render. + const autoApproveMessage = () => ( + <> + Permission requests that are not explicitly denied will be approved automatically. This is{" "} + dangerous: CoDev Code can run commands and edit files without asking. + + ) + + // The toast only has a few seconds on screen, so it restates the mode in one + // line instead of repeating the dialog. + const autoApproveToast = () => ( + <> + Auto-approve is on — a dangerous mode where commands run and files change + without asking. + + ) + // Auto-approve is dangerous, so both entry points (--auto and the shift+tab // permission-mode cycle) funnel through this one consent gate: a confirm // dialog plus a warning toast, shown once per run — later toggles are silent. async function enableAutoApprove() { if (!local.permission.consented) { - const choice = await DialogConfirm.show(dialog, AUTO_APPROVE_TITLE, AUTO_APPROVE_MESSAGE, undefined, "cancel") + const choice = await DialogConfirm.show(dialog, AUTO_APPROVE_TITLE, autoApproveMessage, undefined, "cancel") if (choice !== true) return local.permission.consent() - toast.show({ variant: "warning", title: AUTO_APPROVE_TITLE, message: AUTO_APPROVE_MESSAGE }) + toast.show({ variant: "warning", title: AUTO_APPROVE_TITLE, message: autoApproveToast }) } local.permission.set("auto") } diff --git a/packages/tui/src/ui/dialog-confirm.tsx b/packages/tui/src/ui/dialog-confirm.tsx index 34041a2026..d7c4287a9c 100644 --- a/packages/tui/src/ui/dialog-confirm.tsx +++ b/packages/tui/src/ui/dialog-confirm.tsx @@ -2,13 +2,16 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { For } from "solid-js" +import { For, type JSX } from "solid-js" import { Locale } from "../util/locale" import { useBindings } from "../keymap" export type DialogConfirmProps = { title: string - message: string + // Fork: widened from string so a message can style part of itself with + // (the auto-approve warning reddens the word "dangerous"); an accessor + // defers creating those renderables until this dialog renders. + message: JSX.Element | (() => JSX.Element) onConfirm?: () => void onCancel?: () => void label?: string @@ -67,7 +70,7 @@ export function DialogConfirm(props: DialogConfirmProps) { - {props.message} + {typeof props.message === "function" ? props.message() : props.message} @@ -96,7 +99,7 @@ export function DialogConfirm(props: DialogConfirmProps) { DialogConfirm.show = ( dialog: DialogContext, title: string, - message: string, + message: JSX.Element | (() => JSX.Element), label?: string, defaultChoice?: "confirm" | "cancel", ) => { diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index 2e46c767d5..66b2d64eeb 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, type ParentProps, Show } from "solid-js" +import { createContext, useContext, type JSX, type ParentProps, Show } from "solid-js" import { createStore } from "solid-js/store" import { useTheme } from "../context/theme" import { useTerminalDimensions } from "@opentui/solid" @@ -6,7 +6,10 @@ import { SplitBorder } from "./border" import { TextAttributes } from "@opentui/core" export type ToastOptions = { title?: string - message: string + // Fork: widened from string so a message can style part of itself with + // (the auto-approve warning reddens the word "dangerous"); an accessor + // defers creating those renderables until the toast renders. + message: JSX.Element | (() => JSX.Element) variant: "secondary" | "info" | "success" | "warning" | "error" duration: number } @@ -16,6 +19,12 @@ export function Toast() { const toast = useToast() const { theme } = useTheme() const dimensions = useTerminalDimensions() + // Fork: an accessor message builds its renderables here, inside the render + // pass, instead of wherever the toast was raised. + const message = () => { + const value = toast.currentToast?.message + return typeof value === "function" ? value() : value + } return ( @@ -42,7 +51,7 @@ export function Toast() { - {current().message} + {message()} )} diff --git a/packages/tui/test/auto-approve-consent.view.test.tsx b/packages/tui/test/auto-approve-consent.view.test.tsx index 54804ad23c..30df629bad 100644 --- a/packages/tui/test/auto-approve-consent.view.test.tsx +++ b/packages/tui/test/auto-approve-consent.view.test.tsx @@ -36,6 +36,26 @@ async function waitFrame(setup: Awaited>, } } +// The rendered color of the first span containing `needle`; a styled word is +// its own span, so this is how "dangerous is red" is checked. +function colorOf(setup: Awaited>, needle: string) { + const span = setup + .captureSpans() + .lines.flatMap((line) => line.spans) + .find((span) => span.text.includes(needle)) + if (!span) throw new Error(`no span containing ${JSON.stringify(needle)}`) + return span.fg +} + +// `plain` is unstyled text from the same message, so the check is "this word is +// red" rather than "the whole message happens to be". +function expectDangerIsRed(setup: Awaited>, plain: string) { + const danger = colorOf(setup, "dangerous") + expect(danger.r).toBeGreaterThan(danger.g) + expect(danger.r).toBeGreaterThan(danger.b) + expect(danger).not.toEqual(colorOf(setup, plain)) +} + // Boots the real app with `--auto` against a fetch fixture; the sidebar // builtins are registered so the chat view (and its footer badge) paints. async function boot() { @@ -117,9 +137,10 @@ test("--auto asks for consent and stays manual until it is given", async () => { const boot_ = await boot() try { const frame = await waitFrame(boot_.setup, (f) => f.includes(DIALOG) && f.includes("Confirm")) - // The warning has to name the risk, and the mode must not flip before the - // user answers. + // The warning has to name the risk in red, and the mode must not flip + // before the user answers. expect(frame).toContain("dangerous") + expectDangerIsRed(boot_.setup, "Permission requests") expect(frame).toMatch(MANUAL) expect(frame).not.toMatch(AUTO) } finally { @@ -152,8 +173,11 @@ test("confirming enables auto-approve, warns, and is remembered for the run", as boot_.setup.mockInput.pressArrow("right") boot_.setup.mockInput.pressEnter() const enabled = await waitFrame(boot_.setup, (f) => AUTO.test(f) && !f.includes("Confirm")) - // The dialog is gone, so the surviving warning is the toast. - expect(enabled).toContain("dangerous") + // The dialog is gone, so the surviving warning is the toast: the short + // one-liner, not a replay of the dialog's message. + expect(enabled).toContain("Auto-approve is on") + expect(enabled).not.toContain("Permission requests") + expectDangerIsRed(boot_.setup, "Auto-approve is on") // Disabling goes straight through, and re-enabling no longer asks: consent // is remembered for the rest of the run. The toast reuses the dialog title,