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

@@ -1,65 +0,0 @@
import { describe, expect, it } from 'vitest'
import { CONFIRM_WINDOW_MS, createDestructiveGate } from '../domain/destructive.js'
describe('createDestructiveGate', () => {
it('uses a generous default window so real humans can retype (#4069)', () => {
expect(CONFIRM_WINDOW_MS).toBeGreaterThanOrEqual(15_000)
})
it('first request is not confirmed — it arms the gate', () => {
const g = createDestructiveGate()
expect(g.request('clear', 0)).toBe(false)
})
it('second request within window with same key is confirmed', () => {
const g = createDestructiveGate()
g.request('clear', 0)
expect(g.request('clear', CONFIRM_WINDOW_MS - 1)).toBe(true)
})
it('second request outside the window re-arms and is not confirmed', () => {
const g = createDestructiveGate()
g.request('clear', 0)
expect(g.request('clear', CONFIRM_WINDOW_MS + 1)).toBe(false)
})
it('armed() reports the pending key while fresh, null otherwise', () => {
const g = createDestructiveGate(100)
expect(g.armed()).toBe(null)
g.request('clear')
expect(g.armed()).toBe('clear')
g.reset()
expect(g.armed()).toBe(null)
})
it('different key re-arms the gate, does not confirm', () => {
const g = createDestructiveGate()
g.request('clear', 0)
expect(g.request('undo', 500)).toBe(false)
expect(g.request('undo', 900)).toBe(true)
})
it('confirmation consumes the pending state so a third press re-arms', () => {
const g = createDestructiveGate()
g.request('clear', 0)
g.request('clear', 500)
expect(g.request('clear', 600)).toBe(false)
})
it('reset clears pending state', () => {
const g = createDestructiveGate()
g.request('clear', 0)
g.reset()
expect(g.request('clear', 500)).toBe(false)
})
it('respects a custom window', () => {
const g = createDestructiveGate(100)
g.request('clear', 0)
expect(g.request('clear', 50)).toBe(true)
g.request('clear', 0)
expect(g.request('clear', 150)).toBe(false)
})
})

View File

@@ -3,7 +3,6 @@ import type { SlashExecResponse } from '../gatewayTypes.js'
import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js'
import type { SlashHandlerContext } from './interfaces.js'
import { destructiveGate, isDestructiveCommand } from './slash/commands/core.js'
import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.js'
@@ -41,17 +40,11 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
const found = findSlashCommand(parsed.name)
if (found) {
if (!isDestructiveCommand(found.name)) {
destructiveGate.reset()
}
found.run(parsed.arg, runCtx, cmd)
return true
}
destructiveGate.reset()
if (catalog?.canon) {
const needle = `/${parsed.name}`.toLowerCase()

View File

@@ -10,6 +10,7 @@ import type {
ActivityItem,
ApprovalReq,
ClarifyReq,
ConfirmReq,
DetailsMode,
Msg,
PanelSection,
@@ -53,6 +54,7 @@ export interface GatewayProviderProps {
export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
confirm: ConfirmReq | null
modelPicker: boolean
pager: null | PagerState
picker: boolean

View File

@@ -5,6 +5,7 @@ import type { OverlayState } from './interfaces.js'
const buildOverlayState = (): OverlayState => ({
approval: null,
clarify: null,
confirm: null,
modelPicker: false,
pager: null,
picker: false,
@@ -17,8 +18,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
export const $isBlocked = computed(
$overlayState,
({ approval, clarify, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(approval || clarify || modelPicker || pager || picker || secret || skillsHub || sudo)
({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
)
export const getOverlayState = () => $overlayState.get()

View File

@@ -1,7 +1,6 @@
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { createDestructiveGate } from '../../../domain/destructive.js'
import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
@@ -15,12 +14,6 @@ import { patchOverlayState } from '../../overlayStore.js'
import { patchUiState } from '../../uiStore.js'
import type { SlashCommand } from '../types.js'
export const destructiveGate = createDestructiveGate()
const DESTRUCTIVE_COMMANDS = new Set(['clear', 'new'])
export const isDestructiveCommand = (name: string) => DESTRUCTIVE_COMMANDS.has(name)
const flagFromArg = (arg: string, current: boolean): boolean | null => {
if (!arg) {
return !current
@@ -90,14 +83,27 @@ export const coreCommands: SlashCommand[] = [
return
}
const label = cmd.startsWith('/new') ? '/new' : '/clear'
const isNew = cmd.startsWith('/new')
if (!NO_CONFIRM_DESTRUCTIVE && !destructiveGate.request('clear')) {
return ctx.transcript.sys(`press ${label} again to confirm — starts a new session`)
const commit = () => {
patchUiState({ status: 'forging session…' })
ctx.session.newSession(isNew ? 'new session started' : undefined)
}
patchUiState({ status: 'forging session…' })
ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined)
if (NO_CONFIRM_DESTRUCTIVE) {
return commit()
}
patchOverlayState({
confirm: {
cancelLabel: 'No, keep going',
confirmLabel: isNew ? 'Yes, start a new session' : 'Yes, clear the session',
danger: true,
detail: 'This ends the current conversation and clears the transcript.',
onConfirm: commit,
title: isNew ? 'Start a new session?' : 'Clear the current session?'
}
})
}
},

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
}

View File

@@ -1,27 +0,0 @@
export const CONFIRM_WINDOW_MS = 30_000
export interface DestructiveGate {
armed: () => null | string
request: (key: string, now?: number) => boolean
reset: () => void
}
export const createDestructiveGate = (windowMs = CONFIRM_WINDOW_MS): DestructiveGate => {
let pending: { at: number; key: string } | null = null
const isFresh = (now: number) => pending != null && now - pending.at < windowMs
return {
armed: () => (pending != null && isFresh(Date.now()) ? pending.key : null),
request: (key, now = Date.now()) => {
const confirmed = pending?.key === key && isFresh(now)
pending = confirmed ? null : { at: now, key }
return confirmed
},
reset: () => {
pending = null
}
}
}

View File

@@ -29,6 +29,15 @@ export interface ApprovalReq {
description: string
}
export interface ConfirmReq {
cancelLabel?: string
confirmLabel?: string
danger?: boolean
detail?: string
onConfirm: () => void
title: string
}
export interface ClarifyReq {
choices: string[] | null
question: string