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.
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
/**
|
|
* 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>
|
|
)
|
|
}
|