From 52124384de5367585d9644826ccf2da6b3b7c63d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 17:47:26 -0500 Subject: [PATCH 1/5] fix(tui): stable React keys in /model picker rows Use provider.slug (and a composite key for model rows) instead of the rendered string, so dupes in the backend response can't collapse two rows into one or trigger key-collision warnings. --- ui-tui/src/components/modelPicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 1bc95481d..406047bc1 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -181,7 +181,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const idx = off + i return ( - + {providerIdx === idx ? '▸ ' : ' '} {i + 1}. {row} @@ -212,7 +212,7 @@ export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPicke const idx = off + i return ( - + {modelIdx === idx ? '▸ ' : ' '} {i + 1}. {row} From 3366714ba4fb34a2fb933a96180236df488ab01f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 17:48:34 -0500 Subject: [PATCH 2/5] feat(tui): double-press confirm on /clear and /new Prevents accidental session loss: the first press prints "press /clear again within 3s to confirm"; a second press inside the window actually starts a new session. Outside the window the gate re-arms. Opt out with HERMES_TUI_NO_CONFIRM=1 for scripted / muscle-memory workflows. Refs #4069. --- ui-tui/src/__tests__/destructive.test.ts | 52 ++++++++++++++++++++++++ ui-tui/src/app/slash/commands/core.ts | 10 +++++ ui-tui/src/config/env.ts | 3 ++ ui-tui/src/domain/destructive.ts | 23 +++++++++++ 4 files changed, 88 insertions(+) create mode 100644 ui-tui/src/__tests__/destructive.test.ts create mode 100644 ui-tui/src/domain/destructive.ts diff --git a/ui-tui/src/__tests__/destructive.test.ts b/ui-tui/src/__tests__/destructive.test.ts new file mode 100644 index 000000000..3e19066c6 --- /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 dd5a9f58c..690d6972d 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 3a476d6bc..999607dac 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 000000000..3570de74b --- /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 + } + } +} From 20eab355e753a61c3e7e0f648a50be0cd3d22431 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 17:49:40 -0500 Subject: [PATCH 3/5] feat(tui): add LIGHT_THEME preset for white/light terminal backgrounds Splits the existing palette into DARK_THEME (current yellow-heavy default) and LIGHT_THEME (darker browns + proper contrast on white). DEFAULT_THEME aliases DARK_THEME, and flips to LIGHT_THEME when HERMES_TUI_LIGHT=1 is set at launch. Skin system (fromSkin) still layers on top of whichever preset is active, so users can keep customizing on top of either palette. Refs #11300. --- ui-tui/src/__tests__/theme.test.ts | 22 +++++++++- ui-tui/src/theme.ts | 69 +++++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index 86a9768b0..4fe165c8d 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DEFAULT_THEME, fromSkin } from '../theme.js' +import { DARK_THEME, DEFAULT_THEME, fromSkin, LIGHT_THEME } from '../theme.js' describe('DEFAULT_THEME', () => { it('has brand defaults', () => { @@ -15,6 +15,26 @@ describe('DEFAULT_THEME', () => { }) }) +describe('LIGHT_THEME', () => { + it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', () => { + expect(LIGHT_THEME.color.gold).not.toBe('#FFD700') + expect(LIGHT_THEME.color.amber).not.toBe('#FFBF00') + expect(LIGHT_THEME.color.dim).not.toBe('#B8860B') + expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700') + }) + + it('keeps the same shape as DARK_THEME', () => { + expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort()) + expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand) + }) +}) + +describe('DEFAULT_THEME aliasing', () => { + it('defaults to DARK_THEME when HERMES_TUI_LIGHT is unset', () => { + expect(DEFAULT_THEME).toBe(DARK_THEME) + }) +}) + describe('fromSkin', () => { it('overrides banner colors', () => { expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000') diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 88bc3c390..386e436f5 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -78,7 +78,17 @@ function mix(a: string, b: string, t: number) { // ── Defaults ───────────────────────────────────────────────────────── -export const DEFAULT_THEME: Theme = { +const BRAND: ThemeBrand = { + name: 'Hermes Agent', + icon: '⚕', + prompt: '❯', + welcome: 'Type your message or /help for commands.', + goodbye: 'Goodbye! ⚕', + tool: '┊', + helpHeader: '(^_^)? Commands' +} + +export const DARK_THEME: Theme = { color: { gold: '#FFD700', amber: '#FFBF00', @@ -112,20 +122,59 @@ export const DEFAULT_THEME: Theme = { shellDollar: '#4dabf7' }, - brand: { - name: 'Hermes Agent', - icon: '⚕', - prompt: '❯', - welcome: 'Type your message or /help for commands.', - goodbye: 'Goodbye! ⚕', - tool: '┊', - helpHeader: '(^_^)? Commands' - }, + brand: BRAND, bannerLogo: '', bannerHero: '' } +// Light-terminal palette: darker golds/ambers that stay legible on white +// backgrounds. Same shape as DARK_THEME so `fromSkin` still layers on top +// cleanly (#11300). +export const LIGHT_THEME: Theme = { + color: { + gold: '#8B6914', + amber: '#A0651C', + bronze: '#7A4F1F', + cornsilk: '#3D2F13', + dim: '#7A5A0F', + completionBg: '#F5F5F5', + completionCurrentBg: mix('#F5F5F5', '#A0651C', 0.25), + + label: '#7A5A0F', + ok: '#2E7D32', + error: '#C62828', + warn: '#E65100', + + prompt: '#2B2014', + sessionLabel: '#7A5A0F', + sessionBorder: '#7A5A0F', + + statusBg: '#F5F5F5', + statusFg: '#333333', + statusGood: '#2E7D32', + statusWarn: '#8B6914', + statusBad: '#D84315', + statusCritical: '#B71C1C', + selectionBg: '#D4E4F7', + + diffAdded: 'rgb(200,240,200)', + diffRemoved: 'rgb(240,200,200)', + diffAddedWord: 'rgb(27,94,32)', + diffRemovedWord: 'rgb(183,28,28)', + shellDollar: '#1565C0' + }, + + brand: BRAND, + + bannerLogo: '', + bannerHero: '' +} + +const LIGHT_MODE = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_LIGHT ?? '').trim()) + +export const DEFAULT_THEME: Theme = LIGHT_MODE ? LIGHT_THEME : DARK_THEME + // ── Skin → Theme ───────────────────────────────────────────────────── export function fromSkin( From 75377feb0729c8996a25448ddc3ddc0ecfb22cb0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 17:55:53 -0500 Subject: [PATCH 4/5] =?UTF-8?q?fix(tui):=20make=20/clear=20confirm=20windo?= =?UTF-8?q?w=20humane=20(3s=20=E2=86=92=2030s,=20reset=20on=20other=20slas?= =?UTF-8?q?h)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/__tests__/destructive.test.ts | 15 ++++++++++++++- ui-tui/src/app/createSlashHandler.ts | 7 +++++++ ui-tui/src/app/slash/commands/core.ts | 8 ++++++-- ui-tui/src/domain/destructive.ts | 8 ++++++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/__tests__/destructive.test.ts b/ui-tui/src/__tests__/destructive.test.ts index 3e19066c6..4ed7dc1b3 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 425e778ef..0bd2398d4 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 690d6972d..bbb5e2ec1 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 3570de74b..f808b2a30 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 } From df5ca5065f9204e4fb8d67b8103980d849e5fcd9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 18 Apr 2026 18:04:08 -0500 Subject: [PATCH 5/5] feat(tui): replace /clear double-press gate with a proper confirm overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui-tui/src/__tests__/destructive.test.ts | 65 ------------------- ui-tui/src/app/createSlashHandler.ts | 7 -- ui-tui/src/app/interfaces.ts | 2 + ui-tui/src/app/overlayStore.ts | 5 +- ui-tui/src/app/slash/commands/core.ts | 30 +++++---- ui-tui/src/components/appOverlays.tsx | 19 +++++- ui-tui/src/components/prompts.tsx | 83 +++++++++++++++++++++++- ui-tui/src/domain/destructive.ts | 27 -------- ui-tui/src/types.ts | 9 +++ 9 files changed, 132 insertions(+), 115 deletions(-) delete mode 100644 ui-tui/src/__tests__/destructive.test.ts delete mode 100644 ui-tui/src/domain/destructive.ts diff --git a/ui-tui/src/__tests__/destructive.test.ts b/ui-tui/src/__tests__/destructive.test.ts deleted file mode 100644 index 4ed7dc1b3..000000000 --- a/ui-tui/src/__tests__/destructive.test.ts +++ /dev/null @@ -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) - }) -}) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 0bd2398d4..425e778ef 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -3,7 +3,6 @@ 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' @@ -41,17 +40,11 @@ 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/interfaces.ts b/ui-tui/src/app/interfaces.ts index a23b20688..353c56535 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -10,6 +10,7 @@ import type { ActivityItem, ApprovalReq, ClarifyReq, + ConfirmReq, DetailsMode, Msg, PanelSection, @@ -53,6 +54,7 @@ export interface GatewayProviderProps { export interface OverlayState { approval: ApprovalReq | null clarify: ClarifyReq | null + confirm: ConfirmReq | null modelPicker: boolean pager: null | PagerState picker: boolean diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index a2ea40023..06dbd27a7 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -5,6 +5,7 @@ import type { OverlayState } from './interfaces.js' const buildOverlayState = (): OverlayState => ({ approval: null, clarify: null, + confirm: null, modelPicker: false, pager: null, picker: false, @@ -17,8 +18,8 @@ export const $overlayState = atom(buildOverlayState()) export const $isBlocked = computed( $overlayState, - ({ approval, clarify, modelPicker, pager, picker, secret, skillsHub, sudo }) => - Boolean(approval || clarify || modelPicker || pager || picker || secret || skillsHub || sudo) + ({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) => + Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo) ) export const getOverlayState = () => $overlayState.get() diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index bbb5e2ec1..0f8916c5c 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -1,7 +1,6 @@ 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, @@ -15,12 +14,6 @@ import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' -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) { return !current @@ -90,14 +83,27 @@ export const coreCommands: SlashCommand[] = [ return } - const label = cmd.startsWith('/new') ? '/new' : '/clear' + const isNew = cmd.startsWith('/new') - if (!NO_CONFIRM_DESTRUCTIVE && !destructiveGate.request('clear')) { - return ctx.transcript.sys(`press ${label} again to confirm — starts a new session`) + const commit = () => { + patchUiState({ status: 'forging session…' }) + ctx.session.newSession(isNew ? 'new session started' : undefined) } - patchUiState({ status: 'forging session…' }) - ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined) + if (NO_CONFIRM_DESTRUCTIVE) { + return commit() + } + + patchOverlayState({ + confirm: { + cancelLabel: 'No, keep going', + confirmLabel: isNew ? 'Yes, start a new session' : 'Yes, clear the session', + danger: true, + detail: 'This ends the current conversation and clears the transcript.', + onConfirm: commit, + title: isNew ? 'Start a new session?' : 'Clear the current session?' + } + }) } }, diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 27db09024..844996af3 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -9,7 +9,7 @@ import { $uiState } from '../app/uiStore.js' import { FloatBox } from './appChrome.js' import { MaskedPrompt } from './maskedPrompt.js' import { ModelPicker } from './modelPicker.js' -import { ApprovalPrompt, ClarifyPrompt } from './prompts.js' +import { ApprovalPrompt, ClarifyPrompt, ConfirmPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' import { SkillsHub } from './skillsHub.js' @@ -31,6 +31,23 @@ export function PromptZone({ ) } + if (overlay.confirm) { + const req = overlay.confirm + + const onConfirm = () => { + patchOverlayState({ confirm: null }) + req.onConfirm() + } + + const onCancel = () => patchOverlayState({ confirm: null }) + + return ( + + + + ) + } + if (overlay.clarify) { return ( diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index c7ced5b31..cd9c3a2d1 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -2,7 +2,7 @@ import { Box, Text, useInput } from '@hermes/ink' import { useState } from 'react' import type { Theme } from '../theme.js' -import type { ApprovalReq, ClarifyReq } from '../types.js' +import type { ApprovalReq, ClarifyReq, ConfirmReq } from '../types.js' import { TextInput } from './textInput.js' @@ -151,6 +151,80 @@ export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: Clarify ) } +export function ConfirmPrompt({ onCancel, onConfirm, req, t }: ConfirmPromptProps) { + const [sel, setSel] = useState(0) + + useInput((ch, key) => { + if (key.escape || (key.ctrl && ch.toLowerCase() === 'c')) { + onCancel() + + return + } + + const lower = ch.toLowerCase() + + if (lower === 'y') { + onConfirm() + + return + } + + if (lower === 'n') { + onCancel() + + return + } + + if (key.upArrow && sel > 0) { + setSel(0) + } + + if (key.downArrow && sel < 1) { + setSel(1) + } + + if (key.return) { + sel === 0 ? onCancel() : onConfirm() + } + }) + + const accent = req.danger ? t.color.error : t.color.warn + const confirmLabel = req.confirmLabel ?? 'Yes' + const cancelLabel = req.cancelLabel ?? 'No' + + const rows = [ + { color: t.color.cornsilk, label: cancelLabel }, + { color: req.danger ? t.color.error : t.color.cornsilk, label: confirmLabel } + ] + + return ( + + + {req.danger ? '⚠' : '?'} {req.title} + + + {req.detail ? ( + + + {req.detail} + + + ) : null} + + + + {rows.map((row, i) => ( + + {sel === i ? '▸ ' : ' '} + {row.label} + + ))} + + ↑/↓ select · Enter confirm · Y/N quick · Esc cancel + + ) +} + interface ApprovalPromptProps { onChoice: (s: string) => void req: ApprovalReq @@ -164,3 +238,10 @@ interface ClarifyPromptProps { req: ClarifyReq t: Theme } + +interface ConfirmPromptProps { + onCancel: () => void + onConfirm: () => void + req: ConfirmReq + t: Theme +} diff --git a/ui-tui/src/domain/destructive.ts b/ui-tui/src/domain/destructive.ts deleted file mode 100644 index f808b2a30..000000000 --- a/ui-tui/src/domain/destructive.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const CONFIRM_WINDOW_MS = 30_000 - -export interface DestructiveGate { - armed: () => null | string - request: (key: string, now?: number) => boolean - reset: () => void -} - -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 && isFresh(now) - - pending = confirmed ? null : { at: now, key } - - return confirmed - }, - reset: () => { - pending = null - } - } -} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 98cc31203..3045a74a8 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -29,6 +29,15 @@ export interface ApprovalReq { description: string } +export interface ConfirmReq { + cancelLabel?: string + confirmLabel?: string + danger?: boolean + detail?: string + onConfirm: () => void + title: string +} + export interface ClarifyReq { choices: string[] | null question: string