fix(tui): approval flow + input ergonomics + selection perf

- tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/
  HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of
  silently falling through the CLI input() path and auto-denying
- approval UX: dedicated PromptZone between transcript and composer, safer
  defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line,
  outcome footer under the cost row
- text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed
  (fixes Ctrl+Backspace at cursor 0 inserting literal w)
- hermes-ink selection: swap synchronous onRender() for throttled
  scheduleRender() on drag, and only notify React subscribers on presence
  change — no more per-cell paint/subscribe spam
- useConfigSync: silence config.get polling failures instead of surfacing
  'error: timeout: config.get' in the transcript
This commit is contained in:
Brooklyn Nicholson 2026-04-17 10:37:48 -05:00
parent 0219da9626
commit 5b386ced71
15 changed files with 319 additions and 129 deletions

View file

@ -12,31 +12,77 @@ import { ModelPicker } from './modelPicker.js'
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
export function AppOverlays({
export function PromptZone({
cols,
onApprovalChoice,
onClarifyAnswer,
onSecretSubmit,
onSudoSubmit
}: Pick<AppOverlaysProps, 'cols' | 'onApprovalChoice' | 'onClarifyAnswer' | 'onSecretSubmit' | 'onSudoSubmit'>) {
const overlay = useStore($overlayState)
const ui = useStore($uiState)
if (overlay.approval) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={ui.theme} />
</Box>
)
}
if (overlay.clarify) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ClarifyPrompt
cols={cols}
onAnswer={onClarifyAnswer}
onCancel={() => onClarifyAnswer('')}
req={overlay.clarify}
t={ui.theme}
/>
</Box>
)
}
if (overlay.sudo) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={ui.theme} />
</Box>
)
}
if (overlay.secret) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<MaskedPrompt
cols={cols}
icon="🔑"
label={overlay.secret.prompt}
onSubmit={onSecretSubmit}
sub={`for ${overlay.secret.envVar}`}
t={ui.theme}
/>
</Box>
)
}
return null
}
export function FloatingOverlays({
cols,
compIdx,
completions,
onApprovalChoice,
onClarifyAnswer,
onModelSelect,
onPickerSelect,
onSecretSubmit,
onSudoSubmit,
pagerPageSize
}: AppOverlaysProps) {
}: Pick<AppOverlaysProps, 'cols' | 'compIdx' | 'completions' | 'onModelSelect' | 'onPickerSelect' | 'pagerPageSize'>) {
const { gw } = useGateway()
const overlay = useStore($overlayState)
const ui = useStore($uiState)
const hasAny =
overlay.approval ||
overlay.clarify ||
overlay.modelPicker ||
overlay.pager ||
overlay.picker ||
overlay.secret ||
overlay.sudo ||
completions.length
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length
if (!hasAny) {
return null
@ -46,43 +92,6 @@ export function AppOverlays({
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.clarify && (
<FloatBox color={ui.theme.color.bronze}>
<ClarifyPrompt
cols={cols}
onAnswer={onClarifyAnswer}
onCancel={() => onClarifyAnswer('')}
req={overlay.clarify}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.approval && (
<FloatBox color={ui.theme.color.bronze}>
<ApprovalPrompt onChoice={onApprovalChoice} req={overlay.approval} t={ui.theme} />
</FloatBox>
)}
{overlay.sudo && (
<FloatBox color={ui.theme.color.bronze}>
<MaskedPrompt cols={cols} icon="🔐" label="sudo password required" onSubmit={onSudoSubmit} t={ui.theme} />
</FloatBox>
)}
{overlay.secret && (
<FloatBox color={ui.theme.color.bronze}>
<MaskedPrompt
cols={cols}
icon="🔑"
label={overlay.secret.prompt}
onSubmit={onSecretSubmit}
sub={`for ${overlay.secret.envVar}`}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.picker && (
<FloatBox color={ui.theme.color.bronze}>
<SessionPicker