diff --git a/ui-tui/src/__tests__/destructive.test.ts b/ui-tui/src/__tests__/destructive.test.ts new file mode 100644 index 0000000000..3e19066c6e --- /dev/null +++ b/ui-tui/src/__tests__/destructive.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { CONFIRM_WINDOW_MS, createDestructiveGate } from '../domain/destructive.js' + +describe('createDestructiveGate', () => { + 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', 2_500)).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('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) + }) +}) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index dd5a9f58c8..690d6972d5 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,5 +1,7 @@ +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, @@ -13,6 +15,8 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' +const destructiveGate = createDestructiveGate() + const flagFromArg = (arg: string, current: boolean): boolean | null => { if (!arg) { return !current @@ -82,6 +86,12 @@ export const coreCommands: SlashCommand[] = [ return } + const label = cmd.startsWith('/new') ? '/new' : '/clear' + + if (!NO_CONFIRM_DESTRUCTIVE && !destructiveGate.request('clear')) { + return ctx.transcript.sys(`press ${label} again within 3s to confirm (starts a new session)`) + } + patchUiState({ status: 'forging session…' }) ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined) } diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 3a476d6bc5..999607dacf 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -1,2 +1,5 @@ export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim()) +export const NO_CONFIRM_DESTRUCTIVE = /^(?:1|true|yes|on)$/i.test( + (process.env.HERMES_TUI_NO_CONFIRM ?? '').trim() +) diff --git a/ui-tui/src/domain/destructive.ts b/ui-tui/src/domain/destructive.ts new file mode 100644 index 0000000000..3570de74b1 --- /dev/null +++ b/ui-tui/src/domain/destructive.ts @@ -0,0 +1,23 @@ +export const CONFIRM_WINDOW_MS = 3_000 + +export interface DestructiveGate { + request: (key: string, now?: number) => boolean + reset: () => void +} + +export const createDestructiveGate = (windowMs = CONFIRM_WINDOW_MS): DestructiveGate => { + let pending: { at: number; key: string } | null = null + + return { + request: (key, now = Date.now()) => { + const confirmed = pending?.key === key && now - pending.at < windowMs + + pending = confirmed ? null : { at: now, key } + + return confirmed + }, + reset: () => { + pending = null + } + } +}