feat(opentui-v2): Phase 3 — blocking prompts (clarify/approval/sudo/secret), no deadlock

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 <select> (once/session/always/deny)
  → approval.respond {choice, session_id}.
- view/prompts/clarifyPrompt.tsx: native <select> over choices + an "✎ Other…"
  option that swaps to a native <input> for free-text → clarify.respond
  {answer, request_id}.
- view/prompts/maskedPrompt.tsx: sudo (🔐) / secret (🔑) — native <input> 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.
This commit is contained in:
alt-glitch 2026-06-08 15:06:58 +00:00
parent a572a1eae4
commit d01b573796
10 changed files with 367 additions and 19 deletions

View file

@ -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

View file

@ -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<string, unknown>) => {
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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} onSubmit={submit} />
<App store={store} onSubmit={submit} onRespond={respond} sessionId={() => gateway.sessionId()} />
</ThemeProvider>
),
renderer

View file

@ -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<StoreState>({
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<typeof createSessionStore>

View file

@ -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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ 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 <select> option
expect(frame).toContain('Deny')
expect(frame).not.toContain('Type your message') // composer is hidden while blocked
})
})

View file

@ -119,3 +119,37 @@ describe('session store — ordered parts (Phase 2b)', () => {
expect(parts[0]).toMatchObject({ type: 'reasoning', text: 'thinking hard' })
})
})
describe('session store — blocking prompts (Phase 3)', () => {
test('approval.request sets an approval prompt; clearPrompt clears it', () => {
const store = createSessionStore()
expect(store.state.prompt).toBeUndefined()
store.apply({ type: 'approval.request', payload: { command: 'rm -rf /tmp/x', description: 'delete temp' } })
expect(store.state.prompt).toMatchObject({ kind: 'approval', command: 'rm -rf /tmp/x', description: 'delete temp' })
store.clearPrompt()
expect(store.state.prompt).toBeUndefined()
})
test('clarify.request carries question + choices + request_id', () => {
const store = createSessionStore()
store.apply({ type: 'clarify.request', payload: { question: 'Which?', choices: ['a', 'b'], request_id: 'r1' } })
const p = store.state.prompt
expect(p).toMatchObject({ kind: 'clarify', question: 'Which?', requestId: 'r1' })
if (p?.kind === 'clarify') expect(p.choices).toEqual(['a', 'b'])
})
test('clarify.request with null choices → free-text only', () => {
const store = createSessionStore()
store.apply({ type: 'clarify.request', payload: { question: 'Name?', choices: null, request_id: 'r2' } })
const p = store.state.prompt
if (p?.kind === 'clarify') expect(p.choices).toBeNull()
})
test('sudo.request + secret.request set masked prompts', () => {
const store = createSessionStore()
store.apply({ type: 'sudo.request', payload: { request_id: 's1' } })
expect(store.state.prompt).toMatchObject({ kind: 'sudo', requestId: 's1' })
store.apply({ type: 'secret.request', payload: { env_var: 'API_KEY', prompt: 'Enter key', request_id: 's2' } })
expect(store.state.prompt).toMatchObject({ kind: 'secret', envVar: 'API_KEY', requestId: 's2' })
})
})

View file

@ -1,33 +1,50 @@
/**
* App the Solid view shell (spec v4 §2 `view/App.tsx`). Phase 2: header +
* scrolling transcript + composer, composed in a flex column. Fully themed via
* the ThemeProvider NO hardcoded styles (§7.5).
* App the Solid view shell (spec v4 §2 `view/App.tsx`). Header + scrolling
* transcript + an input zone that swaps the composer for a blocking-prompt
* overlay when one is active. Fully themed via the ThemeProvider (§7.5).
*
* header flexShrink:0 (top chrome line)
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
* composer flexShrink:0 (the <textarea>; clears on submit, §8 #3)
* input zone flexShrink:0 (Composer, OR PromptOverlay when blocked)
*
* `onSubmit` is wired by the entry (Effect boundary) to fire `prompt.submit`;
* it's optional so headless frame tests can mount the shell without a gateway.
* When `store.state.prompt` is set the composer is REPLACED by the prompt overlay
* (so the composer's textarea no longer captures keys, and the prompt's
* select/input/masked-buffer owns input) the §8 #6 deadlock fix. `onSubmit`/
* `onRespond`/`sessionId` are wired by the entry (Effect boundary); all optional
* so headless frame tests can mount the shell without a gateway.
*/
import { Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { Composer } from './composer.tsx'
import { Header } from './header.tsx'
import { PromptOverlay } from './prompts/promptOverlay.tsx'
import { Transcript } from './transcript.tsx'
export interface AppProps {
readonly store: SessionStore
readonly onSubmit?: (text: string) => void
readonly onRespond?: (method: string, params: Record<string, unknown>) => void
readonly sessionId?: () => string | undefined
}
const NOOP = () => {}
const NOOP_RESPOND = () => {}
const NO_SESSION = () => undefined
export function App(props: AppProps) {
const blocked = () => props.store.state.prompt !== undefined
return (
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
<Header store={props.store} />
<Transcript store={props.store} />
<Composer onSubmit={props.onSubmit ?? NOOP} />
<Show when={blocked()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
<PromptOverlay
store={props.store}
onRespond={props.onRespond ?? NOOP_RESPOND}
sessionId={props.sessionId ?? NO_SESSION}
/>
</Show>
</box>
)
}

View file

@ -0,0 +1,55 @@
/**
* ApprovalPrompt dangerous-command approval (spec §8 #6). Native `<select>`
* (built-in /j/k/Enter nav) over once/session/always/deny; a small `useKeyboard`
* adds the Esc/Ctrl+C deny cancel path the select doesn't cover. Answered via
* `approval.respond {choice, session_id}`.
*/
import { useKeyboard } from '@opentui/solid'
import { useTheme } from '../theme.tsx'
const OPTIONS = [
{ description: 'Run this command this one time', name: 'Approve once', value: 'once' },
{ description: 'Allow for the rest of this session', name: 'Approve for session', value: 'session' },
{ description: 'Always allow this command', name: 'Always approve', value: 'always' },
{ description: 'Reject this command', name: 'Deny', value: 'deny' }
]
export function ApprovalPrompt(props: {
command: string
description: string
onChoose: (choice: string) => void
onCancel: () => void
}) {
const theme = useTheme()
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) props.onCancel()
})
return (
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.warn}>
<b> Approval required</b>
</text>
<text fg={theme().color.text}>{props.command}</text>
{props.description ? <text fg={theme().color.muted}>{props.description}</text> : null}
<select
focused
options={OPTIONS}
onSelect={(_index, option) => {
if (option) props.onChoose(String(option.value))
}}
backgroundColor={theme().color.statusBg}
selectedBackgroundColor={theme().color.selectionBg}
textColor={theme().color.text}
selectedTextColor={theme().color.text}
descriptionColor={theme().color.muted}
style={{ height: 8, marginTop: 1 }}
/>
<text fg={theme().color.muted}> select · Enter confirm · Esc/Ctrl+C deny</text>
</box>
)
}

Binary file not shown.

View file

@ -0,0 +1,63 @@
/**
* MaskedPrompt sudo (🔐) / secret (🔑) masked entry (spec §8 #6). OpenTUI's
* `<input>` has NO native mask (only value/placeholder/maxLength), and feeding it
* stars via `value` is a feedback loop (onInput reports the masked value), so we
* own a hidden buffer and capture raw keystrokes via `useKeyboard`, rendering '*'
* per char the robust path for masked input (verified in the React build).
*
* Enter submits the real buffer; Esc/Ctrl+C submits empty so the agent unblocks.
*/
import { useKeyboard } from '@opentui/solid'
import { createSignal, Show } from 'solid-js'
import { useTheme } from '../theme.tsx'
export function MaskedPrompt(props: {
icon: string
label: string
sub?: string
onSubmit: (value: string) => void
onCancel: () => void
}) {
const theme = useTheme()
const [value, setValue] = createSignal('')
useKeyboard(key => {
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
props.onCancel()
return
}
if (key.name === 'return') {
props.onSubmit(value())
return
}
if (key.name === 'backspace') {
setValue(v => v.slice(0, -1))
return
}
const ch = key.sequence ?? ''
if (ch.length === 1 && !key.ctrl && !key.meta && ch >= ' ') setValue(v => v + ch)
})
return (
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.label}>
<b>
{props.icon} {props.label}
</b>
</text>
<Show when={props.sub}>
<text fg={theme().color.muted}>{props.sub}</text>
</Show>
<box style={{ flexDirection: 'row' }}>
<text fg={theme().color.label}>{'> '}</text>
<text fg={theme().color.text}>{'*'.repeat(value().length)}</text>
<text fg={theme().color.accent}></text>
</box>
<text fg={theme().color.muted}>Enter send · Esc/Ctrl+C cancel · masked</text>
</box>
)
}

View file

@ -0,0 +1,94 @@
/**
* PromptOverlay renders the active blocking prompt and binds each answer/cancel
* to the matching `*.respond` RPC (spec §4 reply contract; §8 #6 deadlock fix):
* clarify.respond {answer, request_id} · approval.respond {choice, session_id} ·
* sudo.respond {password, request_id} · secret.respond {value, request_id}.
* Every cancel path (Esc/Ctrl+C) sends the deny/empty reply so the agent unblocks.
*
* `onRespond` is the entry-wired boundary callback (fires `gateway.request`); the
* overlay also clears the store prompt so the composer returns. Narrowing is done
* with reactive `as*()` accessors so each sub-prompt gets its typed payload.
*/
import { Match, Switch } from 'solid-js'
import type { SessionStore } from '../../logic/store.ts'
import { ApprovalPrompt } from './approvalPrompt.tsx'
import { ClarifyPrompt } from './clarifyPrompt.tsx'
import { MaskedPrompt } from './maskedPrompt.tsx'
export interface PromptOverlayProps {
readonly store: SessionStore
readonly onRespond: (method: string, params: Record<string, unknown>) => void
readonly sessionId: () => string | undefined
}
export function PromptOverlay(props: PromptOverlayProps) {
const prompt = () => props.store.state.prompt
const respond = (method: string, params: Record<string, unknown>) => {
props.onRespond(method, params)
props.store.clearPrompt()
}
const asApproval = () => {
const p = prompt()
return p && p.kind === 'approval' ? p : undefined
}
const asClarify = () => {
const p = prompt()
return p && p.kind === 'clarify' ? p : undefined
}
const asSudo = () => {
const p = prompt()
return p && p.kind === 'sudo' ? p : undefined
}
const asSecret = () => {
const p = prompt()
return p && p.kind === 'secret' ? p : undefined
}
return (
<Switch>
<Match when={asApproval()}>
{p => (
<ApprovalPrompt
command={p().command}
description={p().description}
onChoose={choice => respond('approval.respond', { choice, session_id: props.sessionId() })}
onCancel={() => respond('approval.respond', { choice: 'deny', session_id: props.sessionId() })}
/>
)}
</Match>
<Match when={asClarify()}>
{p => (
<ClarifyPrompt
question={p().question}
choices={p().choices}
onAnswer={answer => respond('clarify.respond', { answer, request_id: p().requestId })}
onCancel={() => respond('clarify.respond', { answer: '', request_id: p().requestId })}
/>
)}
</Match>
<Match when={asSudo()}>
{p => (
<MaskedPrompt
icon="🔐"
label="sudo password"
onSubmit={value => respond('sudo.respond', { password: value, request_id: p().requestId })}
onCancel={() => respond('sudo.respond', { password: '', request_id: p().requestId })}
/>
)}
</Match>
<Match when={asSecret()}>
{p => (
<MaskedPrompt
icon="🔑"
label={`Secret: ${p().envVar}`}
sub={p().prompt}
onSubmit={value => respond('secret.respond', { request_id: p().requestId, value })}
onCancel={() => respond('secret.respond', { request_id: p().requestId, value: '' })}
/>
)}
</Match>
</Switch>
)
}