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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<leader>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
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,<leader>q` → `ctrl+d,<leader>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, `<leader>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 `"<leader>b"` → `"ctrl+b,<leader>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. |
Expand Down
26 changes: 19 additions & 7 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1063,17 +1063,29 @@ function App(props: { onSnapshot?: () => Promise<string[]>; 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 <leader>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 }) => {
Expand Down
35 changes: 34 additions & 1 deletion packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -594,6 +626,7 @@ export function Prompt(props: PromptProps) {
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.interrupt.now",
"workspace.set",
"session.move",
]),
Expand Down Expand Up @@ -1603,7 +1636,7 @@ export function Prompt(props: PromptProps) {
</box>
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
{store.interrupt > 0 ? escapeInterruptShortcut() : interruptShortcut()}{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
Expand Down
4 changes: 3 additions & 1 deletion packages/tui/src/config/keybind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<leader>c", "Compact the session"),
session_toggle_timestamps: keybind("none", "Toggle message timestamps"),
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/feature-plugins/home/tips-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
113 changes: 113 additions & 0 deletions packages/tui/test/interrupt.view.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof createTestRenderer>>, 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<void>((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)
1 change: 1 addition & 0 deletions packages/web/src/content/docs/es/keybinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<leader>c",
"session_child_first": "<leader>down",
"session_child_cycle": "<leader>right",
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/content/docs/keybinds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<leader>c",
"session_toggle_timestamps": "none",
"session_toggle_generic_tool_output": "none",
Expand Down
Loading