diff --git a/ui-tui/src/__tests__/destructive.test.ts b/ui-tui/src/__tests__/destructive.test.ts index 3e19066c6e..4ed7dc1b35 100644 --- a/ui-tui/src/__tests__/destructive.test.ts +++ b/ui-tui/src/__tests__/destructive.test.ts @@ -3,6 +3,10 @@ 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) @@ -11,7 +15,7 @@ describe('createDestructiveGate', () => { 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) + expect(g.request('clear', CONFIRM_WINDOW_MS - 1)).toBe(true) }) it('second request outside the window re-arms and is not confirmed', () => { @@ -20,6 +24,15 @@ describe('createDestructiveGate', () => { 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) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 425e778ef3..0bd2398d40 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -3,6 +3,7 @@ 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' @@ -40,11 +41,17 @@ 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() diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 690d6972d5..bbb5e2ec11 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -15,7 +15,11 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' -const destructiveGate = createDestructiveGate() +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) { @@ -89,7 +93,7 @@ export const coreCommands: SlashCommand[] = [ 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)`) + return ctx.transcript.sys(`press ${label} again to confirm — starts a new session`) } patchUiState({ status: 'forging session…' }) diff --git a/ui-tui/src/domain/destructive.ts b/ui-tui/src/domain/destructive.ts index 3570de74b1..f808b2a30f 100644 --- a/ui-tui/src/domain/destructive.ts +++ b/ui-tui/src/domain/destructive.ts @@ -1,6 +1,7 @@ -export const CONFIRM_WINDOW_MS = 3_000 +export const CONFIRM_WINDOW_MS = 30_000 export interface DestructiveGate { + armed: () => null | string request: (key: string, now?: number) => boolean reset: () => void } @@ -8,9 +9,12 @@ export interface DestructiveGate { 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 && now - pending.at < windowMs + const confirmed = pending?.key === key && isFresh(now) pending = confirmed ? null : { at: now, key }