import { Box, Text, useInput, wrapAnsi } from '@hermes/ink' import { useEffect, useState } from 'react' import { isMac } from '../lib/platform.js' import { clarifyBatchRevisitState } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js' import { chipRowProps } from './overlayPrimitives.js' import { TextInput } from './textInput.js' const APPROVAL_OPTS = ['once', 'session', 'always', 'deny'] as const // tirith warning present → backend downgrades "always" to session scope, so drop it. const APPROVAL_OPTS_NO_ALWAYS = APPROVAL_OPTS.filter(o => o !== 'always') const APPROVAL_OPTS_SMART_DENY = ['once', 'deny'] as const const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const const CMD_PREVIEW_LINES = 10 type ApprovalChoice = 'always' | 'deny' | 'once' | 'session' export function approvalOptions(req: ApprovalReq): readonly ApprovalChoice[] { if (req.choices) { return req.choices.filter((choice): choice is ApprovalChoice => APPROVAL_OPTS.includes(choice as ApprovalChoice)) } if (req.smartDenied) { return APPROVAL_OPTS_SMART_DENY } return req.allowPermanent === false ? APPROVAL_OPTS_NO_ALWAYS : APPROVAL_OPTS } type ApprovalKey = { downArrow?: boolean escape?: boolean return?: boolean upArrow?: boolean } type ApprovalAction = { kind: 'choose'; choice: ApprovalChoice } | { kind: 'move'; delta: -1 | 1 } | { kind: 'noop' } /** * Pure key-dispatch for the approval prompt — exported so the regression * matrix (Esc, Ctrl+C-equivalent, number keys, Enter, ↑↓) is testable * without mounting React + Ink + a fake stdin. The component just maps the * action onto its own state setters. * * Esc and number keys both terminate the prompt; Esc maps to deny (parity * with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC * for approvals). Numbers 1..opts.length pick the labelled choice. Enter * confirms the current selection. ↑/↓ moves the selection within bounds. */ export function approvalAction( ch: string, key: ApprovalKey, sel: number, opts: readonly ApprovalChoice[] = APPROVAL_OPTS ): ApprovalAction { if (key.escape) { return { kind: 'choose', choice: 'deny' } } const n = parseInt(ch, 10) if (n >= 1 && n <= opts.length) { return { kind: 'choose', choice: opts[n - 1]! } } if (key.return) { return { kind: 'choose', choice: opts[sel]! } } if (key.upArrow && sel > 0) { return { kind: 'move', delta: -1 } } if (key.downArrow && sel < opts.length - 1) { return { kind: 'move', delta: 1 } } return { kind: 'noop' } } export function ApprovalPrompt({ cols = 80, onChoice, req, t }: ApprovalPromptProps) { const [sel, setSel] = useState(0) const opts = approvalOptions(req) useInput((ch, key) => { const action = approvalAction(ch, key, sel, opts) if (action.kind === 'choose') { onChoice(action.choice) } else if (action.kind === 'move') { setSel(s => s + action.delta) } }) // Wrap long single-line commands to the panel width instead of clipping the // tail (mirrors the CLI approval panel fix — the full command must be // reviewable before approving). Border + paddingX + inner padding ≈ 8 cols. const innerWidth = Math.max(20, cols - 8) const rawLines = req.command .split('\n') .flatMap(line => wrapAnsi(line, innerWidth, { hard: true, trim: false }).split('\n')) const shown = rawLines.slice(0, CMD_PREVIEW_LINES) const overflow = rawLines.length - shown.length return ( ⚠ approval required · {req.description} {shown.map((line, i) => ( {line || ' '} ))} {overflow > 0 ? ( … +{overflow} more line{overflow === 1 ? '' : 's'} (full text above) ) : null} {opts.map((o, i) => ( {sel === i ? '▸ ' : ' '} {i + 1}. {LABELS[o]} ))} ↑/↓ select · Enter confirm · 1-{opts.length} quick pick · Esc/Ctrl+C deny ) } export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, onQuestionAnswer, req, t }: ClarifyPromptProps) { const [sel, setSel] = useState(0) const [custom, setCustom] = useState('') const [typing, setTyping] = useState(false) const choices = req.choices ?? [] const batch = req.questions ?? [] const isBatch = batch.length > 0 // ── Batch (A-compact) state: status list + one expanded active question. // `active` walks the QUESTION list (Tab/Shift-Tab cycle it, any order); // `sel` is reused as the cursor within the active question's choice rows. const answers = req.answers ?? {} const firstUnanswered = batch.findIndex(q => answers[q.qid] === undefined) const [active, setActive] = useState(Math.max(0, firstUnanswered)) const moveActive = (delta: number) => { const next = (active + delta + batch.length) % batch.length const question = batch[next] // Re-visit restore, same model as the CLI panel: a choice answer puts // the cursor back on its row; a typed answer lands on Other with the // text staged so Enter edits it instead of retyping. const restored = clarifyBatchRevisitState(question?.choices ?? [], question ? answers[question.qid] : undefined) setActive(next) setSel(restored.sel) setCustom(restored.custom) setTyping(false) } // After a lock the overlay is re-patched with the new answers map — jump // the cursor to the next unanswered question (stay put when editing). useEffect(() => { if (!isBatch) { return } const current = batch[active] if (current && answers[current.qid] === undefined) { return } const next = batch.findIndex(q => answers[q.qid] === undefined) if (next >= 0) { setActive(next) setSel(0) setCustom('') setTyping(false) } // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by the answers map only }, [req.answers]) const heading = ( ask {isBatch ? `${batch.length} questions` : req.question} ) const activeQuestion = isBatch ? batch[active] : undefined const activeChoices = activeQuestion ? (activeQuestion.choices ?? []) : choices const answeredCount = isBatch ? batch.filter(q => answers[q.qid] !== undefined).length : 0 const remainingCount = isBatch ? batch.length - answeredCount : 0 const lockActive = (value: string) => { if (activeQuestion) { onQuestionAnswer?.(activeQuestion.qid, value) setSel(0) setCustom('') setTyping(false) } } useInput((ch, key) => { if (key.escape) { if (typing) { setTyping(false) return } onCancel() return } if (typing) { return } if (isBatch) { // Tab / Shift-Tab cycle the active question (with wrap) — the // selected question is always the expanded one, like the CLI panel. if (key.tab) { moveActive(key.shift ? -1 : 1) return } if (!activeQuestion) { return } if (activeChoices.length === 0) { // Open-ended question: any keypress starts typing (TextInput below). setTyping(true) return } if (key.upArrow && sel > 0) { setSel(s => s - 1) } if (key.downArrow && sel < activeChoices.length) { setSel(s => s + 1) } if (key.return) { if (sel === activeChoices.length) { setTyping(true) } else if (activeChoices[sel]) { lockActive(activeChoices[sel]!) } return } const n = parseInt(ch) if (n >= 1 && n <= activeChoices.length) { lockActive(activeChoices[n - 1]!) } return } if (!choices.length) { return } if (key.upArrow && sel > 0) { setSel(s => s - 1) } if (key.downArrow && sel < choices.length) { setSel(s => s + 1) } if (key.return) { sel === choices.length ? setTyping(true) : choices[sel] && onAnswer(choices[sel]!) } const n = parseInt(ch) if (n >= 1 && n <= choices.length) { onAnswer(choices[n - 1]!) } }) if (isBatch) { const hint = typing ? `Enter ${remainingCount === 1 ? 'confirm and continue' : 'lock answer'} · Esc back` : `↑/↓ select · Enter ${remainingCount === 1 ? 'confirm and continue' : 'lock answer'} · Tab/Shift+Tab switch question · Esc/Ctrl+C cancel` return ( {heading} {batch.map((q, i) => { const answer = answers[q.qid] const isActive = i === active const marker = answer !== undefined ? '✓' : isActive ? '▸' : '·' return ( {marker} {q.question} {answer !== undefined ? ( // The locked answer on its own line, in the ok color, so the // current answers stay readable while Tab walks the list. {answer || '(skipped)'} ) : null} {isActive ? ( typing || activeChoices.length === 0 ? ( {'> '} ) : ( {[...activeChoices, 'Other (type your answer)'].map((c, ci) => ( {sel === ci ? '▸ ' : ' '} {ci + 1}. {c} ))} ) ) : null} ) })} {answeredCount}/{batch.length} answered · {hint} ) } if (typing || !choices.length) { return ( {heading} {'> '} Enter send · Esc {choices.length ? 'back' : 'cancel'} ·{' '} {isMac ? 'Cmd+C copy · Cmd+V paste · Ctrl+C cancel' : 'Ctrl+C cancel'} ) } return ( {heading} {[...choices, 'Other (type your answer)'].map((c, i) => ( {sel === i ? '▸ ' : ' '} {i + 1}. {c} ))} ↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel ) } export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProps) { const [sel, setSel] = useState(0) useInput((ch, key) => { const lower = ch.toLowerCase() if (key.escape || (key.ctrl && lower === 'c') || lower === 'n') { return onCancel() } if (lower === 'y') { return onConfirm() } if (key.upArrow) { setSel(0) } if (key.downArrow) { setSel(1) } if (key.return) { sel === 0 ? onCancel() : onConfirm() } }) const accent = req.danger ? t.color.error : t.color.warn const rows = [ { color: t.color.text, label: req.cancelLabel ?? 'No' }, { color: req.danger ? t.color.error : t.color.text, label: req.confirmLabel ?? 'Yes' } ] return ( {req.danger ? '⚠' : '?'} {req.title} {req.detail ? ( {req.detail} ) : null} {rows.map((row, i) => ( {sel === i ? '▸ ' : ' '} {row.label} ))} ↑/↓ select · Enter confirm · Y/N quick · Esc cancel ) } interface ApprovalPromptProps { cols?: number onChoice: (s: string) => void req: ApprovalReq t: Theme } interface ClarifyPromptProps { cols?: number onAnswer: (s: string) => void onCancel: () => void /** Batch mode: lock one question's answer (clarify.respond + question_id). */ onQuestionAnswer?: (qid: string, s: string) => void req: ClarifyReq t: Theme } interface ConfirmPromptProps { onCancel: () => void onConfirm: () => void req: ConfirmReq t: Theme }