- 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
171 lines
5.2 KiB
TypeScript
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>
|
|
)
|
|
}
|