diff --git a/CHANGELOG.md b/CHANGELOG.md index 1811cfed0e..90d4baf2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ file is the human-readable summary of what changed and why. ## Unreleased +### Sessions + +- **`Ctrl+C` interrupts a running turn** — while the agent is working, `Ctrl+C` + aborts it on a single press, and the prompt footer now leads with + `ctrl+c interrupt` instead of `esc interrupt`. `escape` keeps its existing + double-press interrupt. With text in the prompt `Ctrl+C` still clears the + draft first, and while a turn is running it can no longer arm the + double-press quit from [#51](https://github.com/quickbeard/codev-code/pull/51) + — `Ctrl+D`, `q`, and `/exit` still quit immediately. Configurable as + the new `session_interrupt_now` keybind. + ### Sidebar - **Hidden-sidebar hint shows while busy** — the `ctrl+b N todos` hint from diff --git a/DIVERGENCES.md b/DIVERGENCES.md index ed255f2a5e..82d3eb6b2a 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -104,6 +104,7 @@ from the squash message first, or use a plain merge. | `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 (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/tui/src/config/keybind.ts`, `packages/web/src/content/docs/keybinds.mdx` (+ `es/`), `packages/opencode/test/cli/run/footer.view.test.tsx` | `sidebar_toggle` default `"b"` → `"ctrl+b,b"` (ctrl+b first so single-binding displays — the prompt-footer sidebar hint, the sidebar tip — resolve to it); `ctrl+b`'s former jobs move to `ctrl+o`: `input_move_left` `"left,ctrl+b"` → `"left,ctrl+o"`, `session_background` `"ctrl+b"` → `"ctrl+o"`; docs defaults updated to match (docs don't list `session_background`); footer.view.test.tsx asserts the run-CLI footer background hint as `"ctrl+o background"` (was `"ctrl+b background"`) since it resolves the real `session_background` default | Single-chord sidebar toggle on `ctrl+b` (tmux/VS Code sidebar muscle memory); its displaced bindings land on `ctrl+o`, which was unbound in every scope and free of terminal-encoding collisions (unlike `ctrl+h`/`ctrl+i`/`ctrl+m`) and flow control (`ctrl+s`/`ctrl+q`). | Keep our defaults on all three keybinds, the doc lines, and the footer hint assertion; take upstream's surrounding changes. If upstream binds `ctrl+o` to something else, re-audit and pick a new free key. | +| `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx`, `packages/web/src/content/docs/keybinds.mdx` (+ `es/`) | Single-press Ctrl+C interrupt. keybind.ts: added `session_interrupt_now: "ctrl+c"` + its `session.interrupt.now` CommandMap entry (`session_interrupt` keeps `escape`; its description gains "(press twice)"). prompt/index.tsx: added the `session.interrupt.now` command — same guards as upstream's `session.interrupt` but it aborts on the first press, and its `enabled` requires a busy session AND an empty prompt so ctrl+c falls through to `prompt.clear` (draft present) or `app.exit.confirm` (idle) instead of racing them on layer order; gathered the command in the `prompt.palette` binding list; the busy footer hint reads `interruptShortcut()` (resolves `session.interrupt.now`, falling back to the escape label when unbound) instead of the hardcoded `esc`, and still shows the escape label while its double-press is armed. app.tsx: split `app_exit_confirm` out of the shared exit `useBindings` into its own layer whose `enabled` also requires `sync.data.session_status[route.data.sessionID]` to be idle, so ctrl+c can't arm the quit confirm mid-turn (`app_exit` keeps the shared predicate). Docs (en + es): added the `session_interrupt_now` default. Covered by the fork-only `packages/tui/test/interrupt.view.test.tsx`. See also the double-Ctrl+C row above, whose scope this narrows. | `esc` alone was a two-press, low-discoverability interrupt; Ctrl+C is what users reach for to stop a running agent, and it now leads the busy footer. Gating on `enabled` rather than binding order keeps the ctrl+c precedence (clear draft → interrupt → exit confirm) deterministic, since same-priority layers resolve by registration order and `useBindings` re-registers on every reactive change. | Keep the `session_interrupt_now` definition + mapping, the `session.interrupt.now` command and its `enabled` predicate, the footer shortcut memos, the split exit layer, the doc lines, and the test; take upstream's surrounding changes. If upstream binds `ctrl+c` to something else in the prompt, re-audit the precedence chain end to end. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code. `method()` drops upstream's `~/.local/bin` → `curl` check, keeping only `~/.opencode/bin`. The `curl` upgrade branch refuses instead of running upstream's install script: `upgradeCurl` + `upgradeScriptShell` + the `Stream` import are removed, and `case "curl"` returns `UpgradeFailedError` pointing at `npm i -g 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). A `curl`-detected binary (in `~/.opencode/bin`) is a stock opencode install; piping upstream's `opencode.ai/install` script would silently replace the fork with stock opencode. `~/.local/bin` is dropped because it's also a common npm/bun global-bin prefix, so a legitimate package-manager install there was misdetected as curl (now falls through to the manager probes). | Keep our package names, the `~/.local/bin` removal, and the curl refusal; keep `upgradeCurl`/`upgradeScriptShell`/`Stream` deleted (take upstream's changes elsewhere). | | `packages/opencode/src/cli/cmd/uninstall.ts` | npm-family uninstall commands (display + exec maps) `opencode-ai` → `codev-code` | `codev uninstall` must remove the fork's actual npm package; the old name was a silent no-op for every npm-installed fork user. NOT renamed: brew/choco/scoop package names (`opencode` — the fork publishes no such formula/package, and `method()` never resolves to those for a fork install) and the curl `.opencode/bin` + `# opencode` shell markers (they match upstream install-script artifacts, not the fork's). | Keep our `codev-code` names; take upstream's surrounding changes. | | `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. | diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 94c73dd92e..4e2fff5bce 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1063,17 +1063,29 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands), })) + const exitBindingsEnabled = () => { + const current = promptRef.current + if (!current?.focused) return true + return current.current.input === "" + } + + useBindings(() => ({ + mode: CODEV_BASE_MODE, + enabled: exitBindingsEnabled, + bindings: tuiConfig.keybinds.gather("app_exit", ["app.exit"]), + })) + + // Ctrl+C interrupts a running session instead of arming the exit confirm, so + // this layer goes inert while the session is busy. Ctrl+D and q above + // still quit immediately. useBindings(() => ({ mode: CODEV_BASE_MODE, enabled: () => { - const current = promptRef.current - if (!current?.focused) return true - return current.current.input === "" + if (!exitBindingsEnabled()) return false + if (route.data.type !== "session") return true + return (sync.data.session_status?.[route.data.sessionID] ?? { type: "idle" }).type === "idle" }, - bindings: [ - ...tuiConfig.keybinds.gather("app_exit", ["app.exit"]), - ...tuiConfig.keybinds.gather("app_exit_confirm", ["app.exit.confirm"]), - ], + bindings: tuiConfig.keybinds.gather("app_exit_confirm", ["app.exit.confirm"]), })) event.on("tui.command.execute", (evt, { workspace }) => { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index a377a03ee1..7c7d285653 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -172,6 +172,12 @@ export function Prompt(props: PromptProps) { const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") + const interruptSequence = useCommandShortcut("session.interrupt") + const interruptNowSequence = useCommandShortcut("session.interrupt.now") + const escapeInterruptShortcut = createMemo(() => (interruptSequence() === "escape" ? "esc" : interruptSequence())) + // Fork: the footer leads with the single-press ctrl+c interrupt and only falls + // back to the double-press escape one when ctrl+c is unbound. + const interruptShortcut = createMemo(() => interruptNowSequence() || escapeInterruptShortcut()) const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() @@ -440,6 +446,32 @@ export function Prompt(props: PromptProps) { dialog.clear() }, }, + { + // Fork: the ctrl+c interrupt aborts on the first press, unlike the + // escape one above. It stays disabled while the prompt holds a draft so + // ctrl+c falls through to prompt.clear instead of racing it on layer + // order, and while idle so it falls through to app.exit.confirm. + title: "Interrupt session", + name: "session.interrupt.now", + category: "Session", + hidden: true, + enabled: status().type !== "idle" && store.prompt.input === "", + run: () => { + if (auto()?.visible) return + if (!input.focused) return + if (store.mode === "shell") { + setStore("mode", "normal") + return + } + if (!props.sessionID) return + + void sdk.client.session.abort({ + sessionID: props.sessionID, + }) + setStore("interrupt", 0) + dialog.clear() + }, + }, { title: "Open editor", category: "Session", @@ -594,6 +626,7 @@ export function Prompt(props: PromptProps) { "prompt.stash.list", "prompt.skills", "session.interrupt", + "session.interrupt.now", "workspace.set", "session.move", ]), @@ -1603,7 +1636,7 @@ export function Prompt(props: PromptProps) { 0 ? theme.primary : theme.text}> - esc{" "} + {store.interrupt > 0 ? escapeInterruptShortcut() : interruptShortcut()}{" "} 0 ? theme.primary : theme.textMuted }}> {store.interrupt > 0 ? "again to interrupt" : "interrupt"} diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 75de96bb17..e7e886b855 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -95,7 +95,8 @@ export const Definitions = { session_delete: keybind("ctrl+d", "Delete session"), session_share: keybind("none", "Share current session"), session_unshare: keybind("none", "Unshare current session"), - session_interrupt: keybind("escape", "Interrupt current session"), + session_interrupt: keybind("escape", "Interrupt current session (press twice)"), + session_interrupt_now: keybind("ctrl+c", "Interrupt current session"), session_background: keybind("ctrl+o", "Background synchronous subagents"), session_compact: keybind("c", "Compact the session"), session_toggle_timestamps: keybind("none", "Toggle message timestamps"), @@ -304,6 +305,7 @@ export const CommandMap = { session_share: "session.share", session_unshare: "session.unshare", session_interrupt: "session.interrupt", + session_interrupt_now: "session.interrupt.now", session_background: "session.background", session_compact: "session.compact", session_toggle_timestamps: "session.toggle.timestamps", diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index e284e13bfa..de9f142185 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -121,7 +121,7 @@ export function Tips(props: { api: TuiPluginApi; connected?: boolean }) { modelCycleRecent: useCommandShortcut("model.cycle_recent"), modelList: useCommandShortcut("model.list"), sessionExport: configShortcut(props.api, "session.export"), - sessionInterrupt: configShortcut(props.api, "session.interrupt"), + sessionInterrupt: configShortcut(props.api, "session.interrupt.now"), sessionList: useCommandShortcut("session.list"), sessionNew: useCommandShortcut("session.new"), sessionParent: configShortcut(props.api, "session.parent"), diff --git a/packages/tui/test/interrupt.view.test.tsx b/packages/tui/test/interrupt.view.test.tsx new file mode 100644 index 0000000000..93b8c6d0bd --- /dev/null +++ b/packages/tui/test/interrupt.view.test.tsx @@ -0,0 +1,113 @@ +import { expect, mock, test } from "bun:test" +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 } from "./fixture/tui-sdk" + +async function waitForFrame(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) + } +} + +// The app is booted on a session the server already reports as busy, so ctrl+c +// hits the running-session branch without driving a real provider turn. +test("ctrl+c clears a prompt draft, then interrupts a busy session on the first press", async () => { + // Wide enough that the footer keeps "ctrl+c interrupt" on one line. + const setup = await createTestRenderer({ width: 120, height: 30, useThread: false }) + const core = await import("@opentui/core") + mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) + const events = createEventSource() + const session = { + id: "ses_test", + title: "Demo session", + slug: "test", + projectID: "proj_test", + directory, + version: "0.0.0-test", + time: { created: 1, updated: 1 }, + } + const aborts: string[] = [] + const calls = createFetch((url, method) => { + if (url.pathname === "/session") return json([session]) + if (url.pathname === "/session/status") return json({ ses_test: { type: "busy" } }) + if (url.pathname === "/session/ses_test/abort" && method === "POST") { + aborts.push(url.pathname) + return json(true) + } + if (url.pathname.startsWith("/session/ses_test/message")) return json([]) + if (url.pathname === "/session/ses_test/todo") return json([]) + if (url.pathname === "/session/ses_test/diff") return json([]) + if (url.pathname === "/session/ses_test") return json(session) + if (url.pathname === "/project/proj_test/directories") return json([]) + // Register a paid model so the fork's first-run configure dialog stays + // closed and focus remains on the prompt. + 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 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: { continue: true }, + pluginHost: { + // The session prompt is a slot default; the slot view renders + // nothing until the slot registry is set up. + async start(input) { + input.runtime.setupSlots(input.api) + started() + }, + async dispose() {}, + }, + }).pipe(Effect.provide(AppNodeBuilder.build(Global.node))), + ) + await ready + + // The busy footer leads with the ctrl+c interrupt, not the escape one. + await waitForFrame(setup, (frame) => frame.includes("ctrl+c interrupt")) + + // A draft takes precedence: the first ctrl+c only clears the prompt. + setup.mockInput.typeText("draft") + await waitForFrame(setup, (frame) => frame.includes("draft")) + setup.mockInput.pressKey("c", { ctrl: true }) + await waitForFrame(setup, (frame) => !frame.includes("draft")) + expect(aborts).toEqual([]) + + // With the prompt empty, ctrl+c aborts on a single press. + setup.mockInput.pressKey("c", { ctrl: true }) + await waitForFrame(setup, () => aborts.length > 0) + expect(aborts).toEqual(["/session/ses_test/abort"]) + + // The exit confirm stays disarmed: ctrl+c must not start quitting the app + // while the session is running. + expect(setup.captureCharFrame()).not.toContain("again to exit") + expect(setup.renderer.isDestroyed).toBe(false) + + process.emit("SIGHUP") + await task + } finally { + if (!setup.renderer.isDestroyed) setup.renderer.destroy() + mock.restore() + } +}, 30_000) diff --git a/packages/web/src/content/docs/es/keybinds.mdx b/packages/web/src/content/docs/es/keybinds.mdx index 213f25ec8b..4298e42811 100644 --- a/packages/web/src/content/docs/es/keybinds.mdx +++ b/packages/web/src/content/docs/es/keybinds.mdx @@ -28,6 +28,7 @@ CoDev Code tiene una lista de combinaciones de teclas que puede personalizar a t "session_share": "none", "session_unshare": "none", "session_interrupt": "escape", + "session_interrupt_now": "ctrl+c", "session_compact": "c", "session_child_first": "down", "session_child_cycle": "right", diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 69cc263057..38e48f6318 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -45,6 +45,7 @@ CoDev Code has a list of keybinds that you can customize through `tui.json`. "session_share": "none", "session_unshare": "none", "session_interrupt": "escape", + "session_interrupt_now": "ctrl+c", "session_compact": "c", "session_toggle_timestamps": "none", "session_toggle_generic_tool_output": "none",