hermes-agent-features/ui-tui/src/components/appOverlays.tsx
Brooklyn Nicholson 5b386ced71 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
2026-04-17 10:37:48 -05:00

171 lines
5.2 KiB
TypeScript

import { Box, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import { FloatBox } from './appChrome.js'
import { MaskedPrompt } from './maskedPrompt.js'
import { ModelPicker } from './modelPicker.js'
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
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,
onModelSelect,
onPickerSelect,
pagerPageSize
}: Pick<AppOverlaysProps, 'cols' | 'compIdx' | 'completions' | 'onModelSelect' | 'onPickerSelect' | 'pagerPageSize'>) {
const { gw } = useGateway()
const overlay = useStore($overlayState)
const ui = useStore($uiState)
const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length
if (!hasAny) {
return null
}
const start = Math.max(0, compIdx - 8)
return (
<Box alignItems="flex-start" bottom="100%" flexDirection="column" left={0} position="absolute" right={0}>
{overlay.picker && (
<FloatBox color={ui.theme.color.bronze}>
<SessionPicker
gw={gw}
onCancel={() => patchOverlayState({ picker: false })}
onSelect={onPickerSelect}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.modelPicker && (
<FloatBox color={ui.theme.color.bronze}>
<ModelPicker
gw={gw}
onCancel={() => patchOverlayState({ modelPicker: false })}
onSelect={onModelSelect}
sessionId={ui.sid}
t={ui.theme}
/>
</FloatBox>
)}
{overlay.pager && (
<FloatBox color={ui.theme.color.bronze}>
<Box flexDirection="column" paddingX={1} paddingY={1}>
{overlay.pager.title && (
<Box justifyContent="center" marginBottom={1}>
<Text bold color={ui.theme.color.gold}>
{overlay.pager.title}
</Text>
</Box>
)}
{overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => (
<Text key={i}>{line}</Text>
))}
<Box marginTop={1}>
<Text color={ui.theme.color.dim}>
{overlay.pager.offset + pagerPageSize < overlay.pager.lines.length
? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})`
: `end · q to close (${overlay.pager.lines.length} lines)`}
</Text>
</Box>
</Box>
</FloatBox>
)}
{!!completions.length && (
<FloatBox color={ui.theme.color.gold}>
<Box flexDirection="column" width={Math.max(28, cols - 6)}>
{completions.slice(start, compIdx + 8).map((item, i) => {
const active = start + i === compIdx
return (
<Box
backgroundColor={active ? ui.theme.color.completionCurrentBg : undefined}
flexDirection="row"
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Text bold color={ui.theme.color.label}>
{' '}
{item.display}
</Text>
{item.meta ? <Text color={ui.theme.color.dim}> {item.meta}</Text> : null}
</Box>
)
})}
</Box>
</FloatBox>
)}
</Box>
)
}