feat(tui): replace /clear double-press gate with a proper confirm overlay

The time-window gate felt wrong — users would hit /clear, read the
prompt, retype, and consistently blow past the window. Swapping to a
real yes/no overlay that blocks input like the existing Approval and
Clarify prompts.

- add ConfirmReq type + OverlayState.confirm + $isBlocked coverage
- ConfirmPrompt component (prompts.tsx): cancel row on top as the
  default, danger-coloured confirm row on the bottom, Y/N hotkeys,
  Enter on default = cancel, Esc/Ctrl+C cancel
- wire into PromptZone (appOverlays.tsx)
- /clear + /new now push onto the overlay instead of arming a timer
- HERMES_TUI_NO_CONFIRM=1 still skips the prompt for scripting
- drop the destructiveGate + createSlashHandler reset wiring
  (destructive.ts and its tests removed)

Refs #4069.
This commit is contained in:
Brooklyn Nicholson 2026-04-18 18:04:08 -05:00
parent 75377feb07
commit df5ca5065f
9 changed files with 132 additions and 115 deletions

View file

@ -9,7 +9,7 @@ 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 { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
import { SkillsHub } from './skillsHub.js'
@ -31,6 +31,23 @@ export function PromptZone({
)
}
if (overlay.confirm) {
const req = overlay.confirm
const onConfirm = () => {
patchOverlayState({ confirm: null })
req.onConfirm()
}
const onCancel = () => patchOverlayState({ confirm: null })
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>
<ConfirmPrompt onCancel={onCancel} onConfirm={onConfirm} req={req} t={ui.theme} />
</Box>
)
}
if (overlay.clarify) {
return (
<Box flexDirection="column" flexShrink={0} paddingX={1} paddingY={1}>

View file

@ -2,7 +2,7 @@ import { Box, Text, useInput } from '@hermes/ink'
import { useState } from 'react'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq } from '../types.js'
import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js'
import { TextInput } from './textInput.js'
@ -151,6 +151,80 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify
)
}
export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProps) {
const [sel, setSel] = useState(0)
useInput((ch, key) => {
if (key.escape || (key.ctrl && ch.toLowerCase() === 'c')) {
onCancel()
return
}
const lower = ch.toLowerCase()
if (lower === 'y') {
onConfirm()
return
}
if (lower === 'n') {
onCancel()
return
}
if (key.upArrow && sel > 0) {
setSel(0)
}
if (key.downArrow && sel < 1) {
setSel(1)
}
if (key.return) {
sel === 0 ? onCancel() : onConfirm()
}
})
const accent = req.danger ? t.color.error : t.color.warn
const confirmLabel = req.confirmLabel ?? 'Yes'
const cancelLabel = req.cancelLabel ?? 'No'
const rows = [
{ color: t.color.cornsilk, label: cancelLabel },
{ color: req.danger ? t.color.error : t.color.cornsilk, label: confirmLabel }
]
return (
<Box borderColor={accent} borderStyle="double" flexDirection="column" paddingX={1}>
<Text bold color={accent}>
{req.danger ? '⚠' : '?'} {req.title}
</Text>
{req.detail ? (
<Box paddingLeft={1}>
<Text color={t.color.cornsilk} wrap="truncate-end">
{req.detail}
</Text>
</Box>
) : null}
<Text />
{rows.map((row, i) => (
<Text key={row.label}>
<Text color={sel === i ? accent : t.color.dim}>{sel === i ? '▸ ' : ' '}</Text>
<Text color={sel === i ? row.color : t.color.dim}>{row.label}</Text>
</Text>
))}
<Text color={t.color.dim}>/ select · Enter confirm · Y/N quick · Esc cancel</Text>
</Box>
)
}
interface ApprovalPromptProps {
onChoice: (s: string) => void
req: ApprovalReq
@ -164,3 +238,10 @@ interface ClarifyPromptProps {
req: ClarifyReq
t: Theme
}
interface ConfirmPromptProps {
onCancel: () => void
onConfirm: () => void
req: ConfirmReq
t: Theme
}