From 76cf809066a7ef77d2607849ca97f21fad4a0961 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 17:42:21 +0000 Subject: [PATCH] opentui(v2): Ctrl-C stops the agent; second press (debounced) quits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 11 — "stopping the agent doesn't work". Ctrl+C used to immediately destroy the renderer. Now a turn-aware state machine (opencode's double-press model, the user's preferred behaviour): - While a turn runs (store.info.running): first Ctrl+C → session.interrupt {session_id} (STOP the agent), and arms a 3s quit window with a warn hint "⏹ stopped — Ctrl+C again to quit". - Idle: first Ctrl+C arms the window ("Ctrl+C again to quit"); a stray single press never nukes the session. - A second Ctrl+C within the window KILLS the TUI (renderer.destroy → clean scope teardown → gateway child EOF). - A blocking prompt still owns Ctrl+C (deny/cancel) — unchanged. Wiring: renderer.ts gains an `onCtrlC` hook (owns Ctrl+C when not blocked); entry builds the machine (gateway yielded before the renderer so it can read `running` + send interrupt). store gains a transient `hint` slice; StatusLine shows hint (warn, priority) or the busy face (dim). Live-smoked: long turn → Ctrl+C shows "stopped" + idle dot; second press exits cleanly with no orphaned gateway child (the user's installed-venv sessions untouched). 60 pass. --- ui-tui-opentui-v2/src/boundary/renderer.ts | 11 +++- ui-tui-opentui-v2/src/entry/main.tsx | 63 +++++++++++++++++++--- ui-tui-opentui-v2/src/logic/store.ts | 12 ++++- ui-tui-opentui-v2/src/test/store.test.ts | 9 ++++ ui-tui-opentui-v2/src/view/statusLine.tsx | 21 +++++--- 5 files changed, 100 insertions(+), 16 deletions(-) diff --git a/ui-tui-opentui-v2/src/boundary/renderer.ts b/ui-tui-opentui-v2/src/boundary/renderer.ts index 87da5d82252..c187e8be200 100644 --- a/ui-tui-opentui-v2/src/boundary/renderer.ts +++ b/ui-tui-opentui-v2/src/boundary/renderer.ts @@ -19,6 +19,12 @@ export interface RendererOptions { readonly mouse: boolean /** When true, a blocking prompt owns Ctrl+C (cancel) — the global quit is suppressed (gotcha §8 #6). */ readonly isBlocked?: () => boolean + /** + * Ctrl+C handler (item 11). When set, it OWNS Ctrl+C while not blocked — the + * entry's state machine decides interrupt-the-turn vs quit. When omitted, the + * default is an immediate `renderer.destroy()` (quit). + */ + readonly onCtrlC?: () => void } /** @@ -57,7 +63,10 @@ export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: // (gotcha §8 #6) — the prompt's own handler sends the cancel reply. const isBlocked = options.isBlocked ?? (() => false) renderer.keyInput.on('keypress', (key: KeyEvent) => { - if (key.ctrl && key.name === 'c' && !isBlocked() && !renderer.isDestroyed) renderer.destroy() + if (!(key.ctrl && key.name === 'c') || renderer.isDestroyed) return + if (isBlocked()) return // a blocking prompt owns Ctrl+C (→ deny/cancel) + if (options.onCtrlC) options.onCtrlC() + else renderer.destroy() }) return { renderer, shutdown } as const diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 9d92217d2ec..a50367362a6 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -49,6 +49,8 @@ export interface TuiInput { const READY_POLL = Duration.millis(100) const READY_TIMEOUT_MS = 20_000 +/** Window after a Ctrl+C in which a second Ctrl+C quits the TUI (item 11). */ +const QUIT_WINDOW_MS = 3_000 /** * Resume a session INTO the store: buffer live events across the `session.resume` @@ -137,16 +139,65 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { // Solid side: the store + reducer. Created here, lives in Solid-land. const store = createSessionStore() - // A blocking prompt owns Ctrl+C (→ cancel) — suppress the global quit while one is up. - const { renderer, shutdown } = yield* acquireRenderer({ - mouse: input.mouse, - isBlocked: () => store.state.prompt !== undefined - }) - // Contact point #2: boundary pushes decoded events into the Solid store. const gateway = yield* GatewayService yield* gateway.subscribe(event => store.apply(event)) + // ── Ctrl+C state machine (item 11) ────────────────────────────────── + // While a turn runs, the first Ctrl+C STOPS the agent (session.interrupt); + // a second Ctrl+C within QUIT_WINDOW_MS (or when idle) KILLS the TUI. The + // debounce stops a stray Ctrl+C from nuking the session (opencode's + // double-press model; the user's preferred behaviour). + let quitArmed = false + let quitTimer: ReturnType | undefined + let doQuit = () => {} // assigned once the renderer exists + const disarmQuit = () => { + quitArmed = false + if (quitTimer) clearTimeout(quitTimer) + quitTimer = undefined + store.setHint(undefined) + } + const armQuit = (message: string) => { + quitArmed = true + store.setHint(message) + if (quitTimer) clearTimeout(quitTimer) + quitTimer = setTimeout(disarmQuit, QUIT_WINDOW_MS) + } + const interruptTurn = () => { + const sid = gateway.sessionId() + if (!sid) return + Effect.runFork( + gateway + .request('session.interrupt', { session_id: sid }) + .pipe( + Effect.catchCause(cause => Effect.sync(() => getLog().warn('interrupt', 'failed', { cause: String(cause) }))) + ) + ) + } + const onCtrlC = () => { + if (quitArmed) { + disarmQuit() + doQuit() + return + } + if (store.state.info.running) { + interruptTurn() + armQuit('⏹ stopped — Ctrl+C again to quit') + } else { + armQuit('Ctrl+C again to quit') + } + } + + // A blocking prompt owns Ctrl+C (→ cancel); otherwise the state machine above runs. + const { renderer, shutdown } = yield* acquireRenderer({ + mouse: input.mouse, + isBlocked: () => store.state.prompt !== undefined, + onCtrlC + }) + doQuit = () => { + if (!renderer.isDestroyed) renderer.destroy() + } + // Submit a user turn: the service value is in hand, so `gateway.request(...)` // is Effect<…, never> — fire it detached with runFork; failures are logged. const submitPrompt = (text: string) => { diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 2f52982e5b0..d0c3a85485e 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -148,6 +148,9 @@ export interface StoreState { status: string | undefined /** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */ info: SessionInfo + /** Transient hint shown above the composer (e.g. "Ctrl+C again to quit" — item 11); + * takes visual priority over the busy `status` face. Undefined when none. */ + hint: string | undefined } const LRU_LIMIT = 1000 @@ -231,7 +234,8 @@ export function createSessionStore() { subagents: [], dashboard: false, status: undefined, - info: {} + info: {}, + hint: undefined }) // Monotonic part id (stable `key` per part so a new tool part below a streaming @@ -364,6 +368,11 @@ export function createSessionStore() { setState('picker', undefined) } + /** Set / clear the transient composer hint ("Ctrl+C again to quit" — item 11). */ + function setHint(text: string | undefined): void { + setState('hint', text) + } + /** Merge a session-info patch into the chrome state (status bar — item 14). */ function applyInfo(raw: { readonly [k: string]: unknown }): void { const patch = readInfoPatch(raw) @@ -598,6 +607,7 @@ export function createSessionStore() { setCompletions, clearCompletions, applyInfo, + setHint, openDashboard, closeDashboard, hydrate, diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 583ff099eb3..d7bf348cafc 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -240,6 +240,15 @@ describe('session store — session chrome / status bar (item 14)', () => { store.applyInfo({ branch: 'dev' }) // partial patch — model/cwd must survive expect(store.state.info).toMatchObject({ model: 'gpt-5.4', cwd: '/tmp', branch: 'dev' }) }) + + test('setHint sets/clears the transient composer hint (Ctrl+C again to quit — item 11)', () => { + const store = createSessionStore() + expect(store.state.hint).toBeUndefined() + store.setHint('Ctrl+C again to quit') + expect(store.state.hint).toBe('Ctrl+C again to quit') + store.setHint(undefined) + expect(store.state.hint).toBeUndefined() + }) }) describe('session store — resume hydrate (Phase 4b)', () => { diff --git a/ui-tui-opentui-v2/src/view/statusLine.tsx b/ui-tui-opentui-v2/src/view/statusLine.tsx index 54e02e42a2c..629f6de997b 100644 --- a/ui-tui-opentui-v2/src/view/statusLine.tsx +++ b/ui-tui-opentui-v2/src/view/statusLine.tsx @@ -1,9 +1,12 @@ /** - * StatusLine — the transient busy indicator (spec §3 chrome; Ink's FaceTicker). - * Shows the kaomoji face/verb from `thinking.delta`/`status.update` WHILE a turn - * runs, above the composer; cleared on `message.complete`. This keeps those - * transient indicators OUT of the transcript (they used to render as reasoning - * rows and linger). Themed, dim. Renders nothing when idle. + * StatusLine — the transient line just below the transcript (spec §3 chrome). + * Shows EITHER: + * - a `hint` (e.g. "Ctrl+C again to quit" — item 11), in the warn colour and + * taking priority; or + * - the kaomoji busy face/verb from `thinking.delta`/`status.update` WHILE a + * turn runs (Ink's FaceTicker), dim, cleared on `message.complete`. + * This keeps those transient indicators OUT of the transcript. Renders nothing + * when both are idle. */ import { Show } from 'solid-js' @@ -12,12 +15,14 @@ import { useTheme } from './theme.tsx' export function StatusLine(props: { store: SessionStore }) { const theme = useTheme() + const line = () => props.store.state.hint ?? props.store.state.status + const isHint = () => props.store.state.hint !== undefined return ( - - {status => ( + + {text => ( - {status()} + {text()} )}