From 7233bc9fc025e105c37ad5d4d497e5c785b938f3 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 10:37:15 +0700 Subject: [PATCH 1/6] feat(tui): require double Ctrl+C to quit The first Ctrl+C on an empty/unfocused prompt now shows a "Press Ctrl+C again to exit" toast instead of quitting immediately; a second press within 2s exits. Split Ctrl+C off the shared app_exit binding into a new app_exit_confirm binding / app.exit.confirm command so Ctrl+D, q, and the /exit slash commands still quit immediately. Modal routes (question, permission, persistent dialog) that bind the exit keys to a scoped reject/cancel keep single-press Ctrl+C behavior via a local app.exit.confirm command. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/tui/src/app.tsx | 29 ++++++++++- packages/tui/src/config/keybind.ts | 4 +- .../tui/src/routes/session/permission.tsx | 19 +++++++ packages/tui/src/routes/session/question.tsx | 10 ++++ packages/tui/src/ui/dialog.tsx | 7 +-- packages/tui/test/app-lifecycle.test.tsx | 52 +++++++++++++++++++ packages/web/src/content/docs/es/keybinds.mdx | 3 +- packages/web/src/content/docs/keybinds.mdx | 3 +- 8 files changed, 120 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 02a1c39a14..4ddafe6657 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -90,6 +90,9 @@ import { cliErrorMessage, errorFormat } from "./util/error" registerOpencodeSpinner() +// How long the first Ctrl+C stays "armed" before a second press is needed again. +const EXIT_CONFIRM_WINDOW = 2000 + const appGlobalBindingCommands = [ "session.list", "session.new", @@ -389,6 +392,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi const sync = useSync() const project = useProject() const exit = useExit() + let exitConfirmTimer: ReturnType | undefined const promptRef = usePromptRef() const pluginRuntime = usePluginRuntime() const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) @@ -903,6 +907,26 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi run: () => exit(), category: "System", }, + { + // Ctrl+C requires a second press within the confirm window to quit, so an + // accidental Ctrl+C on an empty prompt does not exit the app. + name: "app.exit.confirm", + title: "Exit the app", + hidden: true, + run: () => { + if (exitConfirmTimer !== undefined) { + clearTimeout(exitConfirmTimer) + exitConfirmTimer = undefined + exit() + return + } + toast.show({ variant: "info", message: "Press Ctrl+C again to exit", duration: EXIT_CONFIRM_WINDOW }) + exitConfirmTimer = setTimeout(() => { + exitConfirmTimer = undefined + }, EXIT_CONFIRM_WINDOW) + }, + category: "System", + }, { name: "app.debug", title: "Toggle debug panel", @@ -1050,7 +1074,10 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi if (!current?.focused) return true return current.current.input === "" }, - bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), + bindings: [ + ...tuiConfig.keybinds.gather("app_exit", ["app.exit"]), + ...tuiConfig.keybinds.gather("app_exit_confirm", ["app.exit.confirm"]), + ], })) event.on("tui.command.execute", (evt, { workspace }) => { diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aa..eae873dba2 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -45,7 +45,8 @@ const keybind = (value: Definition["default"], description: string): Definition export const Definitions = { leader: keybind(LeaderDefault, "Leader key for keybind combinations"), - app_exit: keybind("ctrl+c,ctrl+d,q", "Exit the application"), + app_exit: keybind("ctrl+d,q", "Exit the application"), + app_exit_confirm: keybind("ctrl+c", "Exit the application (press twice)"), app_debug: keybind("none", "Toggle debug panel"), app_console: keybind("none", "Toggle console"), app_heap_snapshot: keybind("none", "Write heap snapshot"), @@ -255,6 +256,7 @@ export const Descriptions = Object.fromEntries( ) as Record export const CommandMap = { app_exit: "app.exit", + app_exit_confirm: "app.exit.confirm", app_debug: "app.debug", app_console: "app.console", app_heap_snapshot: "app.heap_snapshot", diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 08e0bc7ea1..926dcdb2d6 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -457,10 +457,19 @@ function RejectPrompt(props: { onConfirm: (message: string) => void; onCancel: ( props.onCancel() }, }, + { + name: "app.exit.confirm", + title: "Cancel permission rejection", + category: "Permission", + run() { + props.onCancel() + }, + }, ], bindings: [ { key: "escape", desc: "Cancel permission rejection", group: "Permission", cmd: () => props.onCancel() }, ...tuiConfig.keybinds.get("app.exit"), + ...tuiConfig.keybinds.get("app.exit.confirm"), { key: "return", desc: "Confirm permission rejection", @@ -553,6 +562,15 @@ function Prompt>(props: { props.onSelect(props.escapeKey) }, }, + { + name: "app.exit.confirm", + title: "Reject permission", + category: "Permission", + run() { + if (!props.escapeKey) return + props.onSelect(props.escapeKey) + }, + }, { name: "permission.prompt.fullscreen", title: "Toggle permission fullscreen", @@ -621,6 +639,7 @@ function Prompt>(props: { ] : []), ...(props.escapeKey ? tuiConfig.keybinds.get("app.exit") : []), + ...(props.escapeKey ? tuiConfig.keybinds.get("app.exit.confirm") : []), ...(props.fullscreen ? tuiConfig.keybinds.get("permission.prompt.fullscreen") : []), ], })) diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 191d0a936a..6eeb63786e 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -223,6 +223,14 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st reject() }, }, + { + name: "app.exit.confirm", + title: "Reject question", + category: "Question", + run() { + reject() + }, + }, ], bindings: [ { @@ -252,6 +260,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st { key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, ...tuiConfig.keybinds.get("app.exit"), + ...tuiConfig.keybinds.get("app.exit.confirm"), ] : [ ...Array.from({ length: max }, (_, index) => ({ @@ -280,6 +289,7 @@ export function QuestionPrompt(props: { request: QuestionRequest; directory?: st { key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() }, { key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() }, ...tuiConfig.keybinds.get("app.exit"), + ...tuiConfig.keybinds.get("app.exit.confirm"), ]), ], } diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index cd6906e2ba..0a99790907 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -133,10 +133,11 @@ function init() { cmd: () => { // A persistent dialog is a forced gate (the CoDev configure flow) — // it can't be dismissed, so ctrl+c is the escape hatch and quits the - // app instead. The global app_exit binding is base-mode only and the - // open dialog is in modal mode, so dispatch app.exit explicitly. + // app instead. The global app_exit_confirm binding is base-mode only and + // the open dialog is in modal mode, so dispatch the confirm command + // explicitly; it still requires a second ctrl+c to actually exit. if (store.persistent) { - keymap.dispatchCommand("app.exit") + keymap.dispatchCommand("app.exit.confirm") return } if (renderer.getSelection()) { diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 6fce5bedda..b5207d4ca1 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -126,3 +126,55 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { mock.restore() } }) + +test("app.exit.confirm requires two presses to exit", async () => { + const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const calls = createFetch() + let api: TuiPluginApi | undefined + let started!: () => void + const ready = new Promise((resolve) => { + started = resolve + }) + + try { + 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: {}, + pluginHost: { + async start(input) { + api = input.api + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + + await ready + await setup.renderOnce() + + // First press arms the confirm; the app must stay running. + api?.keymap.dispatchCommand("app.exit.confirm") + const settled = Symbol("settled") + const first = await Promise.race([task.then(() => settled), Promise.resolve("pending")]) + expect(first).toBe("pending") + expect(setup.renderer.isDestroyed).toBe(false) + + // Second press within the confirm window exits. + api?.keymap.dispatchCommand("app.exit.confirm") + await task + expect(setup.renderer.isDestroyed).toBe(true) + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}) diff --git a/packages/web/src/content/docs/es/keybinds.mdx b/packages/web/src/content/docs/es/keybinds.mdx index 4529b97a42..0274ef6260 100644 --- a/packages/web/src/content/docs/es/keybinds.mdx +++ b/packages/web/src/content/docs/es/keybinds.mdx @@ -10,7 +10,8 @@ OpenCode tiene una lista de combinaciones de teclas que puede personalizar a tra "$schema": "https://opencode.ai/tui.json", "keybinds": { "leader": "ctrl+x", - "app_exit": "ctrl+c,ctrl+d,q", + "app_exit": "ctrl+d,q", + "app_exit_confirm": "ctrl+c", "editor_open": "e", "theme_list": "t", "sidebar_toggle": "b", diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 23849edd02..550113917e 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -11,7 +11,8 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "leader_timeout": 2000, "keybinds": { "leader": "ctrl+x", - "app_exit": "ctrl+c,ctrl+d,q", + "app_exit": "ctrl+d,q", + "app_exit_confirm": "ctrl+c", "app_debug": "none", "app_console": "none", "app_heap_snapshot": "none", From 4917b4dd391ad3356e9dc0f84f0e8761acbec15b Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 10:43:09 +0700 Subject: [PATCH 2/6] feat(tui): use theme primary color for exit-confirm toast Add a "primary" toast variant that maps to theme.primary and use it for the "Press Ctrl+C again to exit" toast, matching the Esc-to-interrupt "press again" hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/tui/src/app.tsx | 2 +- packages/tui/src/ui/toast.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 4ddafe6657..8923234670 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -920,7 +920,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi exit() return } - toast.show({ variant: "info", message: "Press Ctrl+C again to exit", duration: EXIT_CONFIRM_WINDOW }) + toast.show({ variant: "primary", message: "Press Ctrl+C again to exit", duration: EXIT_CONFIRM_WINDOW }) exitConfirmTimer = setTimeout(() => { exitConfirmTimer = undefined }, EXIT_CONFIRM_WINDOW) diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index fdacec41de..e1c319b5d1 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -7,7 +7,7 @@ import { TextAttributes } from "@opentui/core" export type ToastOptions = { title?: string message: string - variant: "info" | "success" | "warning" | "error" + variant: "primary" | "info" | "success" | "warning" | "error" duration: number } type ToastInput = Omit & { duration?: number } From a3ac7df6ae11f4ea8cd1e5d28b2ba856d8be4f92 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 10:45:46 +0700 Subject: [PATCH 3/6] feat(tui): match exit-confirm toast color to the banner Use theme.secondary (the banner/logo brand color) for the "Press Ctrl+C again to exit" toast instead of theme.primary. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/tui/src/app.tsx | 2 +- packages/tui/src/ui/toast.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 8923234670..8c105b7fe7 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -920,7 +920,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi exit() return } - toast.show({ variant: "primary", message: "Press Ctrl+C again to exit", duration: EXIT_CONFIRM_WINDOW }) + toast.show({ variant: "secondary", message: "Press Ctrl+C again to exit", duration: EXIT_CONFIRM_WINDOW }) exitConfirmTimer = setTimeout(() => { exitConfirmTimer = undefined }, EXIT_CONFIRM_WINDOW) diff --git a/packages/tui/src/ui/toast.tsx b/packages/tui/src/ui/toast.tsx index e1c319b5d1..2e46c767d5 100644 --- a/packages/tui/src/ui/toast.tsx +++ b/packages/tui/src/ui/toast.tsx @@ -7,7 +7,7 @@ import { TextAttributes } from "@opentui/core" export type ToastOptions = { title?: string message: string - variant: "primary" | "info" | "success" | "warning" | "error" + variant: "secondary" | "info" | "success" | "warning" | "error" duration: number } type ToastInput = Omit & { duration?: number } From 5143cb408d2f744d2451e7df73cd5fdd5b08ca23 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 10:59:38 +0700 Subject: [PATCH 4/6] docs: record double-Ctrl+C divergences Every deliberate edit to an upstream file must be listed in DIVERGENCES.md. Add a dedicated row for keybind.ts / question.tsx / toast.tsx / the lifecycle test / keybinds docs, and extend the existing app.tsx, dialog.tsx, and permission.tsx rows with their app.exit.confirm handlers. Co-Authored-By: Claude Opus 4.8 (1M context) --- DIVERGENCES.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 55fa583f7d..feb5c113aa 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -22,7 +22,7 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `bun.lock` | Mirrors the bin key rename above and the fork-only workspace packages/deps (`@codev/gateway`) | Lockfile records workspace bin mappings and workspace deps. | Regenerate with `bun install`; never hand-merge. | | `packages/tui/package.json` | Added dependency `"@codev/gateway": "workspace:*"` | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`. | Keep our dependency line; take upstream's other fields. | | `packages/tui/src/feature-plugins/home/tips-view.tsx` | `NO_MODELS_TIP` points at `/configure` (CoDev gateway) instead of `/connect` | The unconfigured-state tip should lead to the fork's configure flow. | Keep our tip text. | -| `packages/tui/src/ui/dialog.tsx` | Added a `persistent` store flag (with `setPersistent`/`persistent` accessors): escape and the click-outside backdrop no-op while set, and ctrl+c dispatches `app.exit` (base-mode-only, so suppressed under modal) instead of closing; every `replace`/`clear` resets it to false | The CoDev gateway configure flow is a non-dismissable first-run gate; ctrl+c stays the quit escape hatch. Default-false keeps every other dialog dismissable. | Keep our persistent flag, the escape/backdrop guards, the ctrl+c app.exit dispatch, and the reset lines; take upstream's surrounding changes. | +| `packages/tui/src/ui/dialog.tsx` | Added a `persistent` store flag (with `setPersistent`/`persistent` accessors): escape and the click-outside backdrop no-op while set, and ctrl+c dispatches `app.exit.confirm` (base-mode-only, so suppressed under modal) instead of closing; every `replace`/`clear` resets it to false | The CoDev gateway configure flow is a non-dismissable first-run gate; ctrl+c stays the quit escape hatch (double-press — see the double-Ctrl+C row). Default-false keeps every other dialog dismissable. | Keep our persistent flag, the escape/backdrop guards, the ctrl+c `app.exit.confirm` dispatch, and the reset lines; take upstream's surrounding changes. | | `packages/tui/src/ui/dialog-select.tsx` | Added a `persistent?` prop that hides the top-right `esc` hint | A non-dismissable select shouldn't advertise `esc` (the dialog store's persistent flag blocks the actual close). | Keep our prop + the `Show` wrapper around the esc label; take upstream's surrounding changes. | | `packages/tui/src/ui/dialog-prompt.tsx` | Added `persistent?` (hides the `esc` hint + `DialogPrompt.show` re-arms the store flag), `onInput?` (fires on textarea content change), and `ref?` props | The configure flow's prompts (SSO login, manual credentials) are non-dismissable, and the SSO view watches `onInput` for the lone-`c` copy shortcut on the paste-back link (needs the `ref` to clear the field). | Keep our three props, the `Show` around the esc label, the textarea `onContentChange`, and the `.show` re-arm; take upstream's surrounding changes. | | `packages/opencode/script/build.ts` | Platform packages' `package.json` name `codev-code-` (dist dir names unchanged — the bun compile target derives from them) | npm publishing under the fork's names. | Keep our `name:` line; take upstream's surrounding changes. | @@ -36,10 +36,11 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `packages/tui/src/util/presentation.ts` | Local logo copy replaced by the shared `ansiWordmark` (brand teal + drop shadow) | Presentation-mode wordmark was still upstream-branded. | Keep our wordmark lines; take upstream's surrounding changes. | | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Sidebar footer wordmark: upstream's two-tone "Open"+"Code" spans → a single "CoDev" span (text color, bold) | Upstream splits the wordmark across two spans, so it never matched `opencode` greps and survived earlier rebrand passes. | Keep our span; take upstream's surrounding changes. | | `packages/opencode/src/cli/cmd/*.ts` (tui, run, serve, attach, pr, upgrade, uninstall, web) | Help `describe` strings "opencode" → "codev" | `--help` is the first thing users read. | Keep our strings. | -| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; startup dialog effect gates on `!useConnected()` (not `provider.length === 0` — hosted Zen free models always register) and opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch); `codev` starts directly in the session layout instead of the home screen. | Keep our strings, gate condition, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, and the `-c` default-title filter; take upstream's surrounding changes. | +| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; startup dialog effect gates on `!useConnected()` (not `provider.length === 0` — hosted Zen free models always register) and opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 2 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, gate condition, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Empty-session banner: when the session has no messages, the transcript area shows the vertically centered `Logo` wordmark with the `home_bottom` tips slot beneath it (flexGrow spacer boxes), the session `Prompt` gets home's `placeholder` pool so the input shows an `Ask anything... ""` placeholder like home, and the scrollbox is clamped to `maxHeight` 1 until the first message (it holds its flex-derived height even when empty, so `flexGrow` 0 alone does not collapse it) | Startup lands in an empty session instead of the home screen; without this the transcript is a large blank void above the prompt. | Keep our banner block, the `placeholders` prop, and the conditional `maxHeight`; take upstream's surrounding changes. | | `packages/tui/src/routes/home.tsx` | `placeholder` suggestion pool exported (was module-private) | The empty-session view in routes/session/index.tsx feeds the same pool to its prompt, so upstream edits to the suggestions flow through. | Keep the `export` keyword; take upstream's changes to the list. | -| `packages/tui/src/routes/session/permission.tsx`, `packages/opencode/src/cli/cmd/run/permission.shared.ts`, `packages/opencode/test/cli/run/permission.shared.test.ts`, `packages/tui/src/component/error-component.tsx`, `packages/opencode/src/cli/cmd/run/splash.ts` | Visible sentences/badges "OpenCode"/"opencode" → "CoDev Code"/"codev-code" (incl. the permission always-allow copy and its test expectations) | User-facing copy. | Keep our strings. | +| `packages/tui/src/routes/session/permission.tsx`, `packages/opencode/src/cli/cmd/run/permission.shared.ts`, `packages/opencode/test/cli/run/permission.shared.test.ts`, `packages/tui/src/component/error-component.tsx`, `packages/opencode/src/cli/cmd/run/splash.ts` | Visible sentences/badges "OpenCode"/"opencode" → "CoDev Code"/"codev-code" (incl. the permission always-allow copy and its test expectations); permission.tsx also registers a local `app.exit.confirm` command mirroring its `app.exit` reject and gathers the confirm binding (double-press Ctrl+C — see the double-Ctrl+C row) | User-facing copy; keep Ctrl+C rejecting the permission prompt as before. | Keep our strings; keep permission.tsx's `app.exit.confirm` command + binding. | +| `packages/tui/src/config/keybind.ts`, `packages/tui/src/routes/session/question.tsx`, `packages/tui/src/ui/toast.tsx`, `packages/tui/test/app-lifecycle.test.tsx`, `packages/web/src/content/docs/keybinds.mdx` (+ `es/`) | Double-press Ctrl+C to quit. keybind.ts: `app_exit` `ctrl+c,ctrl+d,q` → `ctrl+d,q`, added `app_exit_confirm: "ctrl+c"` definition + its `app.exit.confirm` CommandMap entry. question.tsx: a local `app.exit.confirm` command mirroring its `app.exit` reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: `ToastOptions.variant` union gains `"secondary"` (resolves to `theme.secondary`, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. Docs (en + es `keybinds.mdx`): split the `app_exit` default and added `app_exit_confirm: "ctrl+c"`. See also the `app.tsx`, `dialog.tsx`, and `permission.tsx` rows for their `app.exit.confirm` handlers. | A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (2 s window), the second within that window quits. Ctrl+D, `q`, and the `/exit` slash commands stay immediate. | Keep our binding split, the `app_exit_confirm`/`app.exit.confirm` definition + mapping, the local confirm commands + gathered bindings, the toast `"secondary"` variant, the test, and the doc defaults; take upstream's surrounding changes. If upstream re-adds `ctrl+c` to `app_exit`, keep it removed. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). | Keep our package names. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | | Config-file rename: `packages/opencode/src/config/{config,managed,tui-migrate}.ts`, `packages/core/src/config.ts`, `packages/opencode/src/plugin/install.ts`, `packages/opencode/src/cli/cmd/{mcp,plug,providers,error}.ts`, user-facing strings (`packages/{tui,app}` dialogs/tips/i18n, skill descriptions in `packages/core/src/plugin/skill.ts` + `packages/opencode/src/skill/index.ts`), bundled prompts (`customize-opencode.md`, `initialize.txt`), tests seeding config fixtures, docs (`packages/web/src/content/docs` en + es) | Config file `opencode.json(c)` → `codev.json(c)` everywhere (global, project walk-up, config dirs, managed); global `config.json` support and the legacy TOML `config` migration removed; managed config dir `/Library/Application Support/opencode`→`…/codev`, `/etc/opencode`→`/etc/codev`, `%ProgramData%\opencode`→`…\codev` | The fork's config surface uses the fork's name; hard rename, no legacy fallback. NOT renamed: the `$schema` URL `https://opencode.ai/config.json`, `@opencode-ai/*` packages, theme asset files named `opencode.json`, brew/scoop manifest URLs. | Keep our file names; take upstream's surrounding logic. Rename any new upstream `opencode.json`/global-`config.json` reference to `codev.json` (watch the not-renamed list). | From a3a6510301a56cf6d3eb1b71a402f571d2d8cd8e Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 11:11:26 +0700 Subject: [PATCH 5/6] feat(tui): shorten exit-confirm window to 1500ms Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/tui/src/app.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 8c105b7fe7..971c9e2ccb 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -90,8 +90,8 @@ import { cliErrorMessage, errorFormat } from "./util/error" registerOpencodeSpinner() -// How long the first Ctrl+C stays "armed" before a second press is needed again. -const EXIT_CONFIRM_WINDOW = 2000 +// How long (ms) the first Ctrl+C stays "armed" before a second press is needed again. +const EXIT_CONFIRM_WINDOW = 1500 const appGlobalBindingCommands = [ "session.list", From 0da29cdfac438449130492297f16283c29f919ef Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 11:12:09 +0700 Subject: [PATCH 6/6] docs: update exit-confirm window to 1.5 s in DIVERGENCES Co-Authored-By: Claude Opus 4.8 (1M context) --- DIVERGENCES.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index feb5c113aa..37c80d32a2 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -36,11 +36,11 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `packages/tui/src/util/presentation.ts` | Local logo copy replaced by the shared `ansiWordmark` (brand teal + drop shadow) | Presentation-mode wordmark was still upstream-branded. | Keep our wordmark lines; take upstream's surrounding changes. | | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Sidebar footer wordmark: upstream's two-tone "Open"+"Code" spans → a single "CoDev" span (text color, bold) | Upstream splits the wordmark across two spans, so it never matched `opencode` greps and survived earlier rebrand passes. | Keep our span; take upstream's surrounding changes. | | `packages/opencode/src/cli/cmd/*.ts` (tui, run, serve, attach, pr, upgrade, uninstall, web) | Help `describe` strings "opencode" → "codev" | `--help` is the first thing users read. | Keep our strings. | -| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; startup dialog effect gates on `!useConnected()` (not `provider.length === 0` — hosted Zen free models always register) and opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 2 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, gate condition, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | +| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; startup dialog effect gates on `!useConnected()` (not `provider.length === 0` — hosted Zen free models always register) and opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, gate condition, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Empty-session banner: when the session has no messages, the transcript area shows the vertically centered `Logo` wordmark with the `home_bottom` tips slot beneath it (flexGrow spacer boxes), the session `Prompt` gets home's `placeholder` pool so the input shows an `Ask anything... ""` placeholder like home, and the scrollbox is clamped to `maxHeight` 1 until the first message (it holds its flex-derived height even when empty, so `flexGrow` 0 alone does not collapse it) | Startup lands in an empty session instead of the home screen; without this the transcript is a large blank void above the prompt. | Keep our banner block, the `placeholders` prop, and the conditional `maxHeight`; take upstream's surrounding changes. | | `packages/tui/src/routes/home.tsx` | `placeholder` suggestion pool exported (was module-private) | The empty-session view in routes/session/index.tsx feeds the same pool to its prompt, so upstream edits to the suggestions flow through. | Keep the `export` keyword; take upstream's changes to the list. | | `packages/tui/src/routes/session/permission.tsx`, `packages/opencode/src/cli/cmd/run/permission.shared.ts`, `packages/opencode/test/cli/run/permission.shared.test.ts`, `packages/tui/src/component/error-component.tsx`, `packages/opencode/src/cli/cmd/run/splash.ts` | Visible sentences/badges "OpenCode"/"opencode" → "CoDev Code"/"codev-code" (incl. the permission always-allow copy and its test expectations); permission.tsx also registers a local `app.exit.confirm` command mirroring its `app.exit` reject and gathers the confirm binding (double-press Ctrl+C — see the double-Ctrl+C row) | User-facing copy; keep Ctrl+C rejecting the permission prompt as before. | Keep our strings; keep permission.tsx's `app.exit.confirm` command + binding. | -| `packages/tui/src/config/keybind.ts`, `packages/tui/src/routes/session/question.tsx`, `packages/tui/src/ui/toast.tsx`, `packages/tui/test/app-lifecycle.test.tsx`, `packages/web/src/content/docs/keybinds.mdx` (+ `es/`) | Double-press Ctrl+C to quit. keybind.ts: `app_exit` `ctrl+c,ctrl+d,q` → `ctrl+d,q`, added `app_exit_confirm: "ctrl+c"` definition + its `app.exit.confirm` CommandMap entry. question.tsx: a local `app.exit.confirm` command mirroring its `app.exit` reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: `ToastOptions.variant` union gains `"secondary"` (resolves to `theme.secondary`, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. Docs (en + es `keybinds.mdx`): split the `app_exit` default and added `app_exit_confirm: "ctrl+c"`. See also the `app.tsx`, `dialog.tsx`, and `permission.tsx` rows for their `app.exit.confirm` handlers. | A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (2 s window), the second within that window quits. Ctrl+D, `q`, and the `/exit` slash commands stay immediate. | Keep our binding split, the `app_exit_confirm`/`app.exit.confirm` definition + mapping, the local confirm commands + gathered bindings, the toast `"secondary"` variant, the test, and the doc defaults; take upstream's surrounding changes. If upstream re-adds `ctrl+c` to `app_exit`, keep it removed. | +| `packages/tui/src/config/keybind.ts`, `packages/tui/src/routes/session/question.tsx`, `packages/tui/src/ui/toast.tsx`, `packages/tui/test/app-lifecycle.test.tsx`, `packages/web/src/content/docs/keybinds.mdx` (+ `es/`) | Double-press Ctrl+C to quit. keybind.ts: `app_exit` `ctrl+c,ctrl+d,q` → `ctrl+d,q`, added `app_exit_confirm: "ctrl+c"` definition + its `app.exit.confirm` CommandMap entry. question.tsx: a local `app.exit.confirm` command mirroring its `app.exit` reject, with the confirm binding gathered in both option branches (Ctrl+C still rejects the question — single press). toast.tsx: `ToastOptions.variant` union gains `"secondary"` (resolves to `theme.secondary`, the banner brand color, for the confirm toast). app-lifecycle.test.tsx: added the "app.exit.confirm requires two presses to exit" test. Docs (en + es `keybinds.mdx`): split the `app_exit` default and added `app_exit_confirm: "ctrl+c"`. See also the `app.tsx`, `dialog.tsx`, and `permission.tsx` rows for their `app.exit.confirm` handlers. | A stray Ctrl+C on an empty/unfocused prompt no longer quits instantly: the first press shows a "Press Ctrl+C again to exit" toast (1.5 s window), the second within that window quits. Ctrl+D, `q`, and the `/exit` slash commands stay immediate. | Keep our binding split, the `app_exit_confirm`/`app.exit.confirm` definition + mapping, the local confirm commands + gathered bindings, the toast `"secondary"` variant, the test, and the doc defaults; take upstream's surrounding changes. If upstream re-adds `ctrl+c` to `app_exit`, keep it removed. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). | Keep our package names. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | | Config-file rename: `packages/opencode/src/config/{config,managed,tui-migrate}.ts`, `packages/core/src/config.ts`, `packages/opencode/src/plugin/install.ts`, `packages/opencode/src/cli/cmd/{mcp,plug,providers,error}.ts`, user-facing strings (`packages/{tui,app}` dialogs/tips/i18n, skill descriptions in `packages/core/src/plugin/skill.ts` + `packages/opencode/src/skill/index.ts`), bundled prompts (`customize-opencode.md`, `initialize.txt`), tests seeding config fixtures, docs (`packages/web/src/content/docs` en + es) | Config file `opencode.json(c)` → `codev.json(c)` everywhere (global, project walk-up, config dirs, managed); global `config.json` support and the legacy TOML `config` migration removed; managed config dir `/Library/Application Support/opencode`→`…/codev`, `/etc/opencode`→`/etc/codev`, `%ProgramData%\opencode`→`…\codev` | The fork's config surface uses the fork's name; hard rename, no legacy fallback. NOT renamed: the `$schema` URL `https://opencode.ai/config.json`, `@opencode-ai/*` packages, theme asset files named `opencode.json`, brew/scoop manifest URLs. | Keep our file names; take upstream's surrounding logic. Rename any new upstream `opencode.json`/global-`config.json` reference to `codev.json` (watch the not-renamed list). |