Skip to content
Merged
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ 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
warns that permission requests not explicitly denied will be approved
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
toggles are instant. Declining or dismissing the dialog stays in manual mode.

## 1.18.4-2

### TUI
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `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

Expand Down
45 changes: 44 additions & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ const appBindingCommands = [
"permission.mode",
] as const

const AUTO_APPROVE_TITLE = "Auto-approve Permissions"

export type TuiInput = {
url: string
args: Args
Expand Down Expand Up @@ -492,7 +494,44 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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{" "}
<span style={{ fg: theme.error }}>dangerous</span>: 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 <span style={{ fg: theme.error }}>dangerous</span> 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, autoApproveMessage, undefined, "cancel")
if (choice !== true) return
local.permission.consent()
toast.show({ variant: "warning", title: AUTO_APPROVE_TITLE, message: autoApproveToast })
}
local.permission.set("auto")
}

const args = useArgs()
onMount(() => {
if (!args.auto) return
void enableAutoApprove()
})

onMount(() => {
batch(() => {
if (args.agent) local.agent.set(args.agent)
Expand Down Expand Up @@ -1044,8 +1083,12 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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) => ({
Expand Down
19 changes: 12 additions & 7 deletions packages/tui/src/context/permission.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,31 @@
import { createStore } from "solid-js/store"
import { useArgs } from "./args"
import { createSimpleContext } from "./helper"

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"))
},
}
},
})
23 changes: 18 additions & 5 deletions packages/tui/src/ui/dialog-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@ 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
// <span> (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
// 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
Expand All @@ -20,7 +26,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(() => ({
Expand Down Expand Up @@ -64,7 +70,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
</text>
</box>
<box paddingBottom={1}>
<text fg={theme.textMuted}>{props.message}</text>
<text fg={theme.textMuted}>{typeof props.message === "function" ? props.message() : props.message}</text>
</box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"] as const}>
Expand All @@ -90,7 +96,13 @@ export function DialogConfirm(props: DialogConfirmProps) {
)
}

DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
DialogConfirm.show = (
dialog: DialogContext,
title: string,
message: JSX.Element | (() => JSX.Element),
label?: string,
defaultChoice?: "confirm" | "cancel",
) => {
return new Promise<DialogConfirmResult>((resolve) => {
dialog.replace(
() => (
Expand All @@ -100,6 +112,7 @@ DialogConfirm.show = (dialog: DialogContext, title: string, message: string, lab
onConfirm={() => resolve(true)}
onCancel={() => resolve(false)}
label={label}
defaultChoice={defaultChoice}
/>
),
() => resolve(undefined),
Expand Down
15 changes: 12 additions & 3 deletions packages/tui/src/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
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"
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
// <span> (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
}
Expand All @@ -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 (
<Show when={toast.currentToast}>
Expand All @@ -42,7 +51,7 @@ export function Toast() {
</text>
</Show>
<text fg={theme.text} wrapMode="word" width="100%">
{current().message}
{message()}
</text>
</box>
)}
Expand Down
Loading
Loading