/** * public/voice-confirm.ts — Approve confirm window controller (VC / V1b, decision B). * * PLAN_VOICE_COMMANDS.md §4: a spoken "approve" is irreversible (grants shell * command execution via the held permission gate), so instead of committing * immediately it arms a short cancellable window. `arm()` fires `onHeard` * synchronously (so the caller can show a "听到:批准" style affordance) and * starts a `windowMs` timer; if `cancel()` is not called first, `onCommit` * fires when the timer elapses. * * Pure timing logic — NO DOM here. The caller (tabs.ts, lane V2) owns the * overlay UI and calls `session.approve()` inside `onCommit`. Kept dependency- * free (setTimeout/clearTimeout only) so tests can use vi.useFakeTimers * without any DOM/WS coupling. */ /** Default confirm-window duration in milliseconds (PLAN_VOICE_COMMANDS §4). */ const DEFAULT_WINDOW_MS = 1500 /** A cancellable approve confirm window. Obtain via createApproveConfirm(). */ export interface ApproveConfirm { /** * Arm the confirm window: calls `onHeard` immediately, then starts a * `windowMs` timer that calls `onCommit` unless `cancel()` runs first. * Re-arming (calling `arm` again while already armed) clears the prior * timer, so only the latest arm's `onCommit` can ever fire. */ arm(onHeard: () => void, onCommit: () => void): void /** Cancel the pending window, if any. No-op (does not throw) if not armed. */ cancel(): void /** True while a confirm window is pending (armed and not yet committed/cancelled). */ isArmed(): boolean } /** * Create an approve confirm window controller. * * @param windowMs Duration of the cancellable window in ms. Default: 1500 (B). */ export function createApproveConfirm(windowMs: number = DEFAULT_WINDOW_MS): ApproveConfirm { let timer: ReturnType | undefined return { arm(onHeard: () => void, onCommit: () => void): void { if (timer !== undefined) { clearTimeout(timer) } onHeard() timer = setTimeout(() => { timer = undefined onCommit() }, windowMs) }, cancel(): void { if (timer === undefined) return clearTimeout(timer) timer = undefined }, isArmed(): boolean { return timer !== undefined }, } }