fix(tui): make /clear confirm window humane (3s → 30s, reset on other slash)
The 3s gate was too tight — users reading the prompt and retyping
consistently blow past it and get stuck in a loop ("press /clear
again within 3s" forever). Fixes:
- bump CONFIRM_WINDOW_MS 3_000 → 30_000
- drop the time number from the confirmation message to remove the
pressure vibe: "press /clear again to confirm — starts a new session"
- reset the gate from createSlashHandler whenever any non-destructive
slash command runs, so stale arming from 20s ago can't silently
turn the next /clear into an unintended confirm
- export the gate + isDestructiveCommand helper for that wiring
- add armed() introspection method
Follow-up to #4069 / 3366714b.
This commit is contained in:
parent
20eab355e7
commit
75377feb07
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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…' })
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user