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

@ -10,7 +10,7 @@ import type { Theme } from '../theme.js'
import type { DetailsMode } from '../types.js'
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { AppOverlays } from './appOverlays.js'
import { FloatingOverlays, PromptZone } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
@ -37,6 +37,7 @@ const StreamingAssistant = memo(function StreamingAssistant({
activity={progress.activity}
busy={busy}
detailsMode={detailsMode}
outcome={progress.outcome}
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
@ -179,16 +180,12 @@ const ComposerPane = memo(function ComposerPane({
/>
)}
<AppOverlays
<FloatingOverlays
cols={composer.cols}
compIdx={composer.compIdx}
completions={composer.completions}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onModelSelect={actions.onModelSelect}
onPickerSelect={actions.resumeById}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
pagerPageSize={composer.pagerPageSize}
/>
</Box>
@ -254,6 +251,14 @@ export const AppLayout = memo(function AppLayout({
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
</Box>
<PromptZone
cols={composer.cols}
onApprovalChoice={actions.answerApproval}
onClarifyAnswer={actions.answerClarify}
onSecretSubmit={actions.answerSecret}
onSudoSubmit={actions.answerSudo}
/>
<ComposerPane actions={actions} composer={composer} status={status} />
</Box>
</AlternateScreen>

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

View file

@ -10,57 +10,49 @@ const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(3)
const [sel, setSel] = useState(0)
useInput((ch, key) => {
if (key.upArrow && sel > 0) {
setSel(s => s - 1)
}
if (key.downArrow && sel < 3) {
if (key.downArrow && sel < OPTS.length - 1) {
setSel(s => s + 1)
}
const n = parseInt(ch, 10)
if (n >= 1 && n <= OPTS.length) {
onChoice(OPTS[n - 1]!)
return
}
if (key.return) {
onChoice(OPTS[sel]!)
}
if (ch === 'o') {
onChoice('once')
}
if (ch === 's') {
onChoice('session')
}
if (ch === 'a') {
onChoice('always')
}
if (ch === 'd' || key.escape) {
onChoice('deny')
}
})
return (
<Box flexDirection="column">
<Box borderColor={t.color.warn} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={t.color.warn}>
! DANGEROUS COMMAND: {req.description}
approval required · {req.description}
</Text>
<Text color={t.color.dim}> {req.command}</Text>
<Text color={t.color.cornsilk}> {req.command}</Text>
<Text />
{OPTS.map((o, i) => (
<Text key={o}>
<Text color={sel === i ? t.color.warn : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? t.color.cornsilk : t.color.dim}>
[{o[0]}] {LABELS[o]}
{i + 1}. {LABELS[o]}
</Text>
</Text>
))}
<Text color={t.color.dim}>/ select · Enter confirm · o/s/a/d quick pick</Text>
<Text color={t.color.dim}>/ select · Enter confirm · 1-4 quick pick · Ctrl+C deny</Text>
</Box>
)
}

View file

@ -1,5 +1,5 @@
import * as Ink from '@hermes/ink'
import type { InputEvent, Key } from '@hermes/ink'
import * as Ink from '@hermes/ink'
import { useEffect, useMemo, useRef, useState } from 'react'
type InkExt = typeof Ink & {
@ -240,6 +240,14 @@ function renderWithCursor(value: string, cursor: number) {
return done ? out : out + invert(' ')
}
function renderWithSelection(value: string, start: number, end: number) {
if (start >= end) {
return value
}
return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end)
}
function useFwdDelete(active: boolean) {
const ref = useRef(false)
const { inputEmitter: ee } = useStdin()
@ -274,13 +282,16 @@ export function TextInput({
focus = true
}: TextInputProps) {
const [cur, setCur] = useState(value.length)
const [sel, setSel] = useState<null | { end: number; start: number }>(null)
const fwdDel = useFwdDelete(focus)
const termFocus = useTerminalFocus()
const curRef = useRef(cur)
const selRef = useRef<null | { end: number; start: number }>(null)
const vRef = useRef(value)
const self = useRef(false)
const pasteBuf = useRef('')
const pasteEnd = useRef<null | number>(null)
const pasteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const pastePos = useRef(0)
const undo = useRef<{ cursor: number; value: string }[]>([])
@ -296,12 +307,15 @@ export function TextInput({
const raw = self.current ? vRef.current : value
const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw
const selected =
sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null
const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display])
const boxRef = useDeclaredCursor({
line: layout.line,
column: layout.column,
active: focus && termFocus
active: focus && termFocus && !selected
})
const rendered = useMemo(() => {
@ -313,15 +327,21 @@ export function TextInput({
return invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1))
}
if (selected) {
return renderWithSelection(display, selected.start, selected.end)
}
return renderWithCursor(display, cur)
}, [cur, display, focus, placeholder])
}, [cur, display, focus, placeholder, selected])
useEffect(() => {
if (self.current) {
self.current = false
} else {
setCur(value.length)
setSel(null)
curRef.current = value.length
selRef.current = null
vRef.current = value
undo.current = []
redo.current = []
@ -341,6 +361,11 @@ export function TextInput({
const prev = vRef.current
const c = snapPos(next, nextCur)
if (selRef.current) {
selRef.current = null
setSel(null)
}
if (track && next !== prev) {
undo.current.push({ cursor: curRef.current, value: prev })
@ -385,7 +410,9 @@ export function TextInput({
const flushPaste = () => {
const text = pasteBuf.current
const at = pastePos.current
const end = pasteEnd.current ?? at
pasteBuf.current = ''
pasteEnd.current = null
pasteTimer.current = null
if (!text) {
@ -393,10 +420,41 @@ export function TextInput({
}
if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) {
commit(vRef.current.slice(0, at) + text + vRef.current.slice(at), at + text.length)
commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length)
}
}
const clearSel = () => {
if (!selRef.current) {
return
}
selRef.current = null
setSel(null)
}
const selectAll = () => {
const end = vRef.current.length
if (!end) {
return
}
const next = { end, start: 0 }
selRef.current = next
setSel(next)
setCur(end)
curRef.current = end
}
const selRange = () => {
const range = selRef.current
return range && range.start !== range.end
? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) }
: null
}
const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c)
useInput(
@ -431,6 +489,8 @@ export function TextInput({
let c = curRef.current
let v = vRef.current
const mod = k.ctrl || k.meta
const range = selRange()
const delFwd = k.delete || fwdDel.current
if (k.ctrl && inp === 'z') {
return swap(undo, redo)
@ -440,19 +500,42 @@ export function TextInput({
return swap(redo, undo)
}
if (k.home || (k.ctrl && inp === 'a')) {
if (k.ctrl && inp === 'a') {
return selectAll()
}
if (k.home) {
clearSel()
c = 0
} else if (k.end || (k.ctrl && inp === 'e')) {
clearSel()
c = v.length
} else if (k.leftArrow) {
c = mod ? wordLeft(v, c) : prevPos(v, c)
if (range && !mod) {
clearSel()
c = range.start
} else {
clearSel()
c = mod ? wordLeft(v, c) : prevPos(v, c)
}
} else if (k.rightArrow) {
c = mod ? wordRight(v, c) : nextPos(v, c)
if (range && !mod) {
clearSel()
c = range.end
} else {
clearSel()
c = mod ? wordRight(v, c) : nextPos(v, c)
}
} else if (k.meta && inp === 'b') {
clearSel()
c = wordLeft(v, c)
} else if (k.meta && inp === 'f') {
clearSel()
c = wordRight(v, c)
} else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) {
} else if (range && (k.backspace || delFwd)) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else if (k.backspace && c > 0) {
if (mod) {
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
@ -462,22 +545,40 @@ export function TextInput({
v = v.slice(0, t) + v.slice(c)
c = t
}
} else if (k.delete && fwdDel.current && c < v.length) {
} else if (delFwd && c < v.length) {
if (mod) {
const t = wordRight(v, c)
v = v.slice(0, c) + v.slice(t)
} else {
v = v.slice(0, c) + v.slice(nextPos(v, c))
}
} else if (k.ctrl && inp === 'w' && c > 0) {
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
} else if (k.ctrl && inp === 'w') {
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else if (c > 0) {
clearSel()
const t = wordLeft(v, c)
v = v.slice(0, t) + v.slice(c)
c = t
} else {
return
}
} else if (k.ctrl && inp === 'u') {
v = v.slice(c)
c = 0
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else {
v = v.slice(c)
c = 0
}
} else if (k.ctrl && inp === 'k') {
v = v.slice(0, c)
if (range) {
v = v.slice(0, range.start) + v.slice(range.end)
c = range.start
} else {
v = v.slice(0, c)
}
} else if (inp.length > 0) {
const bracketed = inp.includes('[200~')
const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
@ -496,7 +597,8 @@ export function TextInput({
if (text.length > 1 || text.includes('\n')) {
if (!pasteBuf.current) {
pastePos.current = c
pastePos.current = range ? range.start : c
pasteEnd.current = range ? range.end : pastePos.current
}
pasteBuf.current += text
@ -511,8 +613,13 @@ export function TextInput({
}
if (PRINTABLE.test(text)) {
v = v.slice(0, c) + text + v.slice(c)
c += text.length
if (range) {
v = v.slice(0, range.start) + text + v.slice(range.end)
c = range.start + text.length
} else {
v = v.slice(0, c) + text + v.slice(c)
c += text.length
}
} else {
return
}
@ -532,6 +639,7 @@ export function TextInput({
return
}
clearSel()
const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns)
setCur(next)
curRef.current = next

View file

@ -537,6 +537,7 @@ interface Group {
export const ToolTrail = memo(function ToolTrail({
busy = false,
detailsMode = 'collapsed',
outcome = '',
reasoningActive = false,
reasoning = '',
reasoningTokens,
@ -550,6 +551,7 @@ export const ToolTrail = memo(function ToolTrail({
}: {
busy?: boolean
detailsMode?: DetailsMode
outcome?: string
reasoningActive?: boolean
reasoning?: string
reasoningTokens?: number
@ -596,7 +598,16 @@ export const ToolTrail = memo(function ToolTrail({
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) {
if (
!busy &&
!trail.length &&
!tools.length &&
!subagents.length &&
!activity.length &&
!cot &&
!reasoningActive &&
!outcome
) {
return null
}
@ -961,6 +972,13 @@ export const ToolTrail = memo(function ToolTrail({
t={t}
/>
) : null}
{outcome ? (
<Box marginTop={1}>
<Text color={t.color.dim} dim>
· {outcome}
</Text>
</Box>
) : null}
</Box>
)
})