From d01b57379626cf775db2b5bfb1d8c505c3045002 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 15:06:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=203=20=E2=80=94=20blo?= =?UTF-8?q?cking=20prompts=20(clarify/approval/sudo/secret),=20no=20deadlo?= =?UTF-8?q?ck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 4 gateway *.request events now drive a blocking-prompt overlay instead of deadlocking the agent (spec §8 #6). Native OpenTUI paradigm (per glitch's steer): - view/prompts/approvalPrompt.tsx: native over choices + an "✎ Other…" option that swaps to a native for free-text → clarify.respond {answer, request_id}. - view/prompts/maskedPrompt.tsx: sudo (🔐) / secret (🔑) — native has no mask, so we own a buffer via useKeyboard and render '*' per char → sudo/secret.respond {password|value, request_id}. - view/prompts/promptOverlay.tsx: dispatches by prompt kind, binds each answer/cancel to the matching *.respond; Esc/Ctrl+C → deny/empty so the agent always unblocks. Wiring: store gains ActivePrompt state + the 4 reducer cases + clearPrompt; App swaps Composer↔PromptOverlay on store.state.prompt (so the composer textarea stops capturing keys while blocked); renderer.ts gates the global Ctrl+C-quit on isBlocked() so a prompt owns Ctrl+C (→ cancel); entry adds a generic `respond` runFork callback + passes sessionId. Verified: bun run check green (28 tests / 5 files) — reducer set/clear for all 4, + a frame test (approval overlay renders the command + all options as a bordered modal, composer hidden while blocked). LIVE tmux: a real `rm -rf` approval fired; Approve-once → command ran → unblocked; Esc → deny → "BLOCKED by user" → unblocked; Ctrl+C-while-blocked cancelled WITHOUT quitting; Ctrl+C-unblocked quit clean, no orphan. Smoke P3 + parity matrix updated. confirm (local) → Phase 4. --- ui-tui-opentui-v2/src/boundary/renderer.ts | 16 +-- ui-tui-opentui-v2/src/entry/main.tsx | 24 ++++- ui-tui-opentui-v2/src/logic/store.ts | 48 ++++++++- ui-tui-opentui-v2/src/test/render.test.tsx | 21 ++++ ui-tui-opentui-v2/src/test/store.test.ts | 34 +++++++ ui-tui-opentui-v2/src/view/App.tsx | 31 ++++-- .../src/view/prompts/approvalPrompt.tsx | 55 ++++++++++ .../src/view/prompts/clarifyPrompt.tsx | Bin 0 -> 2916 bytes .../src/view/prompts/maskedPrompt.tsx | 63 ++++++++++++ .../src/view/prompts/promptOverlay.tsx | 94 ++++++++++++++++++ 10 files changed, 367 insertions(+), 19 deletions(-) create mode 100644 ui-tui-opentui-v2/src/view/prompts/approvalPrompt.tsx create mode 100644 ui-tui-opentui-v2/src/view/prompts/clarifyPrompt.tsx create mode 100644 ui-tui-opentui-v2/src/view/prompts/maskedPrompt.tsx create mode 100644 ui-tui-opentui-v2/src/view/prompts/promptOverlay.tsx diff --git a/ui-tui-opentui-v2/src/boundary/renderer.ts b/ui-tui-opentui-v2/src/boundary/renderer.ts index 13afb6e6896..87da5d82252 100644 --- a/ui-tui-opentui-v2/src/boundary/renderer.ts +++ b/ui-tui-opentui-v2/src/boundary/renderer.ts @@ -17,6 +17,8 @@ import { RendererError } from './errors.ts' export interface RendererOptions { /** Mouse tracking on/off (from decoded display config). */ readonly mouse: boolean + /** When true, a blocking prompt owns Ctrl+C (cancel) — the global quit is suppressed (gotcha §8 #6). */ + readonly isBlocked?: () => boolean } /** @@ -47,13 +49,15 @@ export const acquireRenderer = Effect.fn('Renderer.acquire')(function* (options: Deferred.doneUnsafe(shutdown, Effect.void) }) - // Minimal global quit (Phase 1). `exitOnCtrlC:false` hands Ctrl+C to us as a key - // event (not SIGINT), so destroying here fires 'destroy' → resolves `shutdown` → - // the entry scope closes → finalizers run: renderer teardown + the gateway layer's - // `client.stop()` EOFs the Python child's stdin so it exits (no orphan). Prompts - // gate this on `!blocked` once they own Ctrl+C (Phase 3, gotcha §8 #6). + // Global quit on Ctrl+C. `exitOnCtrlC:false` hands Ctrl+C to us as a key event + // (not SIGINT), so destroying here fires 'destroy' → resolves `shutdown` → the + // entry scope closes → finalizers run: renderer teardown + the gateway layer's + // `client.stop()` EOFs the Python child's stdin so it exits (no orphan). When a + // blocking prompt is up, it owns Ctrl+C (→ deny/cancel) so we suppress the quit + // (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' && !renderer.isDestroyed) renderer.destroy() + if (key.ctrl && key.name === 'c' && !isBlocked() && !renderer.isDestroyed) 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 98bda9de7d1..2eeaa1d5d6d 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -83,11 +83,15 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { yield* Effect.scoped( Effect.gen(function* () { - const { renderer, shutdown } = yield* acquireRenderer({ mouse: input.mouse }) - // 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)) @@ -111,6 +115,20 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { ) } + // Blocking-prompt replies (clarify/approval/sudo/secret `*.respond`). Same + // detached-runFork pattern; failures logged, never thrown into the view. + const respond = (method: string, params: Record) => { + Effect.runFork( + gateway + .request(method, params) + .pipe( + Effect.catchCause(cause => + Effect.sync(() => getLog().warn('respond', 'failed', { cause: String(cause), method })) + ) + ) + ) + } + // Live backend: drive a session (create + optional initial prompt) concurrently. if (!input.fake) yield* Effect.forkScoped(bootstrapSession(gateway, store, input)) @@ -120,7 +138,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { render( () => ( store.state.theme}> - + gateway.sessionId()} /> ), renderer diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 5a83b6684e3..f11ee414659 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -46,10 +46,22 @@ export interface Message { streaming?: boolean } +/** + * A BLOCKING interactive request from the agent (spec §8 #6 — unhandled = deadlock). + * Each is answered via the matching `*.respond` RPC; Esc/Ctrl+C sends deny/empty. + */ +export type ActivePrompt = + | { kind: 'clarify'; question: string; choices: string[] | null; requestId: string } + | { kind: 'approval'; command: string; description: string } + | { kind: 'sudo'; requestId: string } + | { kind: 'secret'; envVar: string; prompt: string; requestId: string } + export interface StoreState { ready: boolean messages: Message[] theme: Theme + /** The active blocking prompt (composer is hidden while set); undefined when none. */ + prompt: ActivePrompt | undefined } const LRU_LIMIT = 1000 @@ -64,7 +76,8 @@ export function createSessionStore() { const [state, setState] = createStore({ ready: false, messages: [], - theme: DEFAULT_THEME + theme: DEFAULT_THEME, + prompt: undefined }) // Monotonic part id (stable `key` per part so a new tool part below a streaming @@ -238,11 +251,40 @@ export function createSessionStore() { ) break } - // Other event types (prompts, chrome, subagents) are reduced in later phases; + // ── blocking prompts (spec §8 #6 — unhandled = the agent deadlocks) ── + case 'clarify.request': + setState('prompt', { + kind: 'clarify', + question: event.payload.question ?? '', + // decoded choices are readonly — copy to the store's mutable string[] + choices: event.payload.choices ? [...event.payload.choices] : null, + requestId: event.payload.request_id + }) + break + case 'approval.request': + setState('prompt', { kind: 'approval', command: event.payload.command, description: event.payload.description }) + break + case 'sudo.request': + setState('prompt', { kind: 'sudo', requestId: event.payload.request_id }) + break + case 'secret.request': + setState('prompt', { + kind: 'secret', + envVar: event.payload.env_var, + prompt: event.payload.prompt, + requestId: event.payload.request_id + }) + break + // Other event types (chrome, subagents) are reduced in later phases; // unhandled members are intentionally ignored here. } } + /** Clear the active blocking prompt (after it's answered/cancelled). */ + function clearPrompt(): void { + setState('prompt', undefined) + } + /** * Begin a resume hydrate: buffer live events, replace history with the * snapshot, then replay buffered events. `loadSnapshot` maps the gateway's @@ -257,7 +299,7 @@ export function createSessionStore() { for (const event of pending) applyNow(event) } - return { state, apply, pushUser, hydrate, duplicate } as const + return { state, apply, pushUser, hydrate, duplicate, clearPrompt } as const } export type SessionStore = ReturnType diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index a1f8b24ac68..517e174f298 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -82,4 +82,25 @@ describe('App render (Phase 1, themed)', () => { expect(frame).toContain('alpha.txt') // envelope-stripped output, block-rendered expect(frame).not.toContain('exit_code') // the {output,exit_code} envelope is stripped }) + + test('an approval prompt replaces the composer (blocked) and renders the options', async () => { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + store.apply({ type: 'approval.request', payload: { command: 'rm -rf /tmp/x', description: 'Delete temp dir' } }) + + const frame = await captureFrame( + () => ( + store.state.theme}> + + + ), + { until: 'Approval required', width: 72, height: 18 } + ) + + expect(frame).toContain('Approval required') + expect(frame).toContain('rm -rf /tmp/x') // the command under review + expect(frame).toContain('Approve once') // native