alt-glitch 1bc376921f 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.
2026-06-08 15:06:58 +00:00

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>
)
}