Skip to content

Commit 6475e70

Browse files
quickbeardclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 4a29627 commit 6475e70

6 files changed

Lines changed: 72 additions & 18 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ file is the human-readable summary of what changed and why.
1212
- **Auto-approve now asks for consent** — launching with `--auto` (or its
1313
hidden aliases) no longer enables auto-approve silently: a confirm dialog
1414
warns that permission requests not explicitly denied will be approved
15-
automatically and that this is dangerous, and the mode turns on only after
16-
you confirm, followed by a warning toast with the same message. The dialog
15+
automatically and that this is dangerous — the word in red, like the `auto`
16+
badge — and the mode turns on only after you confirm, followed by a warning
17+
toast restating it in one line. The dialog
1718
opens on Cancel, so a stray return declines instead of granting it.
1819
Switching into auto mode with `shift+tab` goes through the same dialog and
1920
toast the first time; consent is remembered for the rest of the run, so later

‎DIVERGENCES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ from the squash message first, or use a plain merge.
162162
| `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. |
163163
| `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. |
164164
| `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. |
165-
| `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()`. |
165+
| `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()`. |
166166

167167
## Known rebrand gaps
168168

‎packages/tui/src/app.tsx‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,6 @@ const appBindingCommands = [
146146
] as const
147147

148148
const AUTO_APPROVE_TITLE = "Auto-approve Permissions"
149-
const AUTO_APPROVE_MESSAGE =
150-
"Permission requests that are not explicitly denied will be approved automatically. This is dangerous: CoDev Code can run commands and edit files without asking."
151149

152150
export type TuiInput = {
153151
url: string
@@ -496,15 +494,34 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
496494
}
497495
})
498496

497+
// The risk word is red like the `auto` badge so the warning reads as a danger
498+
// signal. Passed unevaluated: renderables can only be created while a
499+
// renderer is active, and the gate runs outside render.
500+
const autoApproveMessage = () => (
501+
<>
502+
Permission requests that are not explicitly denied will be approved automatically. This is{" "}
503+
<span style={{ fg: theme.error }}>dangerous</span>: CoDev Code can run commands and edit files without asking.
504+
</>
505+
)
506+
507+
// The toast only has a few seconds on screen, so it restates the mode in one
508+
// line instead of repeating the dialog.
509+
const autoApproveToast = () => (
510+
<>
511+
Auto-approve is on — a <span style={{ fg: theme.error }}>dangerous</span> mode where commands run and files change
512+
without asking.
513+
</>
514+
)
515+
499516
// Auto-approve is dangerous, so both entry points (--auto and the shift+tab
500517
// permission-mode cycle) funnel through this one consent gate: a confirm
501518
// dialog plus a warning toast, shown once per run — later toggles are silent.
502519
async function enableAutoApprove() {
503520
if (!local.permission.consented) {
504-
const choice = await DialogConfirm.show(dialog, AUTO_APPROVE_TITLE, AUTO_APPROVE_MESSAGE, undefined, "cancel")
521+
const choice = await DialogConfirm.show(dialog, AUTO_APPROVE_TITLE, autoApproveMessage, undefined, "cancel")
505522
if (choice !== true) return
506523
local.permission.consent()
507-
toast.show({ variant: "warning", title: AUTO_APPROVE_TITLE, message: AUTO_APPROVE_MESSAGE })
524+
toast.show({ variant: "warning", title: AUTO_APPROVE_TITLE, message: autoApproveToast })
508525
}
509526
local.permission.set("auto")
510527
}

‎packages/tui/src/ui/dialog-confirm.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { TextAttributes } from "@opentui/core"
22
import { useTheme } from "../context/theme"
33
import { useDialog, type DialogContext } from "./dialog"
44
import { createStore } from "solid-js/store"
5-
import { For } from "solid-js"
5+
import { For, type JSX } from "solid-js"
66
import { Locale } from "../util/locale"
77
import { useBindings } from "../keymap"
88

99
export type DialogConfirmProps = {
1010
title: string
11-
message: string
11+
// Fork: widened from string so a message can style part of itself with
12+
// <span> (the auto-approve warning reddens the word "dangerous"); an accessor
13+
// defers creating those renderables until this dialog renders.
14+
message: JSX.Element | (() => JSX.Element)
1215
onConfirm?: () => void
1316
onCancel?: () => void
1417
label?: string
@@ -67,7 +70,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
6770
</text>
6871
</box>
6972
<box paddingBottom={1}>
70-
<text fg={theme.textMuted}>{props.message}</text>
73+
<text fg={theme.textMuted}>{typeof props.message === "function" ? props.message() : props.message}</text>
7174
</box>
7275
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
7376
<For each={["cancel", "confirm"] as const}>
@@ -96,7 +99,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
9699
DialogConfirm.show = (
97100
dialog: DialogContext,
98101
title: string,
99-
message: string,
102+
message: JSX.Element | (() => JSX.Element),
100103
label?: string,
101104
defaultChoice?: "confirm" | "cancel",
102105
) => {

‎packages/tui/src/ui/toast.tsx‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { createContext, useContext, type ParentProps, Show } from "solid-js"
1+
import { createContext, useContext, type JSX, type ParentProps, Show } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import { useTheme } from "../context/theme"
44
import { useTerminalDimensions } from "@opentui/solid"
55
import { SplitBorder } from "./border"
66
import { TextAttributes } from "@opentui/core"
77
export type ToastOptions = {
88
title?: string
9-
message: string
9+
// Fork: widened from string so a message can style part of itself with
10+
// <span> (the auto-approve warning reddens the word "dangerous"); an accessor
11+
// defers creating those renderables until the toast renders.
12+
message: JSX.Element | (() => JSX.Element)
1013
variant: "secondary" | "info" | "success" | "warning" | "error"
1114
duration: number
1215
}
@@ -16,6 +19,12 @@ export function Toast() {
1619
const toast = useToast()
1720
const { theme } = useTheme()
1821
const dimensions = useTerminalDimensions()
22+
// Fork: an accessor message builds its renderables here, inside the render
23+
// pass, instead of wherever the toast was raised.
24+
const message = () => {
25+
const value = toast.currentToast?.message
26+
return typeof value === "function" ? value() : value
27+
}
1928

2029
return (
2130
<Show when={toast.currentToast}>
@@ -42,7 +51,7 @@ export function Toast() {
4251
</text>
4352
</Show>
4453
<text fg={theme.text} wrapMode="word" width="100%">
45-
{current().message}
54+
{message()}
4655
</text>
4756
</box>
4857
)}

‎packages/tui/test/auto-approve-consent.view.test.tsx‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,26 @@ async function waitFrame(setup: Awaited<ReturnType<typeof createTestRenderer>>,
3636
}
3737
}
3838

39+
// The rendered color of the first span containing `needle`; a styled word is
40+
// its own span, so this is how "dangerous is red" is checked.
41+
function colorOf(setup: Awaited<ReturnType<typeof createTestRenderer>>, needle: string) {
42+
const span = setup
43+
.captureSpans()
44+
.lines.flatMap((line) => line.spans)
45+
.find((span) => span.text.includes(needle))
46+
if (!span) throw new Error(`no span containing ${JSON.stringify(needle)}`)
47+
return span.fg
48+
}
49+
50+
// `plain` is unstyled text from the same message, so the check is "this word is
51+
// red" rather than "the whole message happens to be".
52+
function expectDangerIsRed(setup: Awaited<ReturnType<typeof createTestRenderer>>, plain: string) {
53+
const danger = colorOf(setup, "dangerous")
54+
expect(danger.r).toBeGreaterThan(danger.g)
55+
expect(danger.r).toBeGreaterThan(danger.b)
56+
expect(danger).not.toEqual(colorOf(setup, plain))
57+
}
58+
3959
// Boots the real app with `--auto` against a fetch fixture; the sidebar
4060
// builtins are registered so the chat view (and its footer badge) paints.
4161
async function boot() {
@@ -117,9 +137,10 @@ test("--auto asks for consent and stays manual until it is given", async () => {
117137
const boot_ = await boot()
118138
try {
119139
const frame = await waitFrame(boot_.setup, (f) => f.includes(DIALOG) && f.includes("Confirm"))
120-
// The warning has to name the risk, and the mode must not flip before the
121-
// user answers.
140+
// The warning has to name the risk in red, and the mode must not flip
141+
// before the user answers.
122142
expect(frame).toContain("dangerous")
143+
expectDangerIsRed(boot_.setup, "Permission requests")
123144
expect(frame).toMatch(MANUAL)
124145
expect(frame).not.toMatch(AUTO)
125146
} finally {
@@ -152,8 +173,11 @@ test("confirming enables auto-approve, warns, and is remembered for the run", as
152173
boot_.setup.mockInput.pressArrow("right")
153174
boot_.setup.mockInput.pressEnter()
154175
const enabled = await waitFrame(boot_.setup, (f) => AUTO.test(f) && !f.includes("Confirm"))
155-
// The dialog is gone, so the surviving warning is the toast.
156-
expect(enabled).toContain("dangerous")
176+
// The dialog is gone, so the surviving warning is the toast: the short
177+
// one-liner, not a replay of the dialog's message.
178+
expect(enabled).toContain("Auto-approve is on")
179+
expect(enabled).not.toContain("Permission requests")
180+
expectDangerIsRed(boot_.setup, "Auto-approve is on")
157181

158182
// Disabling goes straight through, and re-enabling no longer asks: consent
159183
// is remembered for the rest of the run. The toast reuses the dialog title,

0 commit comments

Comments
 (0)