opentui(v2): Ctrl-C stops the agent; second press (debounced) quits
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.
This commit is contained in:
parent
93793b6af5
commit
1be5bd92fa
@ -19,6 +19,12 @@ export interface RendererOptions {
|
|||||||
readonly mouse: boolean
|
readonly mouse: boolean
|
||||||
/** When true, a blocking prompt owns Ctrl+C (cancel) — the global quit is suppressed (gotcha §8 #6). */
|
/** When true, a blocking prompt owns Ctrl+C (cancel) — the global quit is suppressed (gotcha §8 #6). */
|
||||||
readonly isBlocked?: () => boolean
|
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.
|
// (gotcha §8 #6) — the prompt's own handler sends the cancel reply.
|
||||||
const isBlocked = options.isBlocked ?? (() => false)
|
const isBlocked = options.isBlocked ?? (() => false)
|
||||||
renderer.keyInput.on('keypress', (key: KeyEvent) => {
|
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
|
return { renderer, shutdown } as const
|
||||||
|
|||||||
@ -49,6 +49,8 @@ export interface TuiInput {
|
|||||||
|
|
||||||
const READY_POLL = Duration.millis(100)
|
const READY_POLL = Duration.millis(100)
|
||||||
const READY_TIMEOUT_MS = 20_000
|
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`
|
* 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.
|
// Solid side: the store + reducer. Created here, lives in Solid-land.
|
||||||
const store = createSessionStore()
|
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.
|
// Contact point #2: boundary pushes decoded events into the Solid store.
|
||||||
const gateway = yield* GatewayService
|
const gateway = yield* GatewayService
|
||||||
yield* gateway.subscribe(event => store.apply(event))
|
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<typeof setTimeout> | 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(...)`
|
// Submit a user turn: the service value is in hand, so `gateway.request(...)`
|
||||||
// is Effect<…, never> — fire it detached with runFork; failures are logged.
|
// is Effect<…, never> — fire it detached with runFork; failures are logged.
|
||||||
const submitPrompt = (text: string) => {
|
const submitPrompt = (text: string) => {
|
||||||
|
|||||||
@ -148,6 +148,9 @@ export interface StoreState {
|
|||||||
status: string | undefined
|
status: string | undefined
|
||||||
/** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */
|
/** Live session chrome for the status bar (model/effort/cwd/branch/context/running). */
|
||||||
info: SessionInfo
|
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
|
const LRU_LIMIT = 1000
|
||||||
@ -231,7 +234,8 @@ export function createSessionStore() {
|
|||||||
subagents: [],
|
subagents: [],
|
||||||
dashboard: false,
|
dashboard: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
info: {}
|
info: {},
|
||||||
|
hint: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
// 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)
|
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). */
|
/** Merge a session-info patch into the chrome state (status bar — item 14). */
|
||||||
function applyInfo(raw: { readonly [k: string]: unknown }): void {
|
function applyInfo(raw: { readonly [k: string]: unknown }): void {
|
||||||
const patch = readInfoPatch(raw)
|
const patch = readInfoPatch(raw)
|
||||||
@ -598,6 +607,7 @@ export function createSessionStore() {
|
|||||||
setCompletions,
|
setCompletions,
|
||||||
clearCompletions,
|
clearCompletions,
|
||||||
applyInfo,
|
applyInfo,
|
||||||
|
setHint,
|
||||||
openDashboard,
|
openDashboard,
|
||||||
closeDashboard,
|
closeDashboard,
|
||||||
hydrate,
|
hydrate,
|
||||||
|
|||||||
@ -240,6 +240,15 @@ describe('session store — session chrome / status bar (item 14)', () => {
|
|||||||
store.applyInfo({ branch: 'dev' }) // partial patch — model/cwd must survive
|
store.applyInfo({ branch: 'dev' }) // partial patch — model/cwd must survive
|
||||||
expect(store.state.info).toMatchObject({ model: 'gpt-5.4', cwd: '/tmp', branch: 'dev' })
|
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)', () => {
|
describe('session store — resume hydrate (Phase 4b)', () => {
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* StatusLine — the transient busy indicator (spec §3 chrome; Ink's FaceTicker).
|
* StatusLine — the transient line just below the transcript (spec §3 chrome).
|
||||||
* Shows the kaomoji face/verb from `thinking.delta`/`status.update` WHILE a turn
|
* Shows EITHER:
|
||||||
* runs, above the composer; cleared on `message.complete`. This keeps those
|
* - a `hint` (e.g. "Ctrl+C again to quit" — item 11), in the warn colour and
|
||||||
* transient indicators OUT of the transcript (they used to render as reasoning
|
* taking priority; or
|
||||||
* rows and linger). Themed, dim. Renders nothing when idle.
|
* - 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'
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
@ -12,12 +15,14 @@ import { useTheme } from './theme.tsx'
|
|||||||
|
|
||||||
export function StatusLine(props: { store: SessionStore }) {
|
export function StatusLine(props: { store: SessionStore }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const line = () => props.store.state.hint ?? props.store.state.status
|
||||||
|
const isHint = () => props.store.state.hint !== undefined
|
||||||
return (
|
return (
|
||||||
<Show when={props.store.state.status}>
|
<Show when={line()}>
|
||||||
{status => (
|
{text => (
|
||||||
<box style={{ flexShrink: 0 }}>
|
<box style={{ flexShrink: 0 }}>
|
||||||
<text>
|
<text>
|
||||||
<span style={{ fg: theme().color.muted }}>{status()}</span>
|
<span style={{ fg: isHint() ? theme().color.warn : theme().color.muted }}>{text()}</span>
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user