mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
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:
parent
a572a1eae4
commit
d01b573796
10 changed files with 367 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
55
ui-tui-opentui-v2/src/view/prompts/approvalPrompt.tsx
Normal file
55
ui-tui-opentui-v2/src/view/prompts/approvalPrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
BIN
ui-tui-opentui-v2/src/view/prompts/clarifyPrompt.tsx
Normal file
BIN
ui-tui-opentui-v2/src/view/prompts/clarifyPrompt.tsx
Normal file
Binary file not shown.
63
ui-tui-opentui-v2/src/view/prompts/maskedPrompt.tsx
Normal file
63
ui-tui-opentui-v2/src/view/prompts/maskedPrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
ui-tui-opentui-v2/src/view/prompts/promptOverlay.tsx
Normal file
94
ui-tui-opentui-v2/src/view/prompts/promptOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue