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)
})
})