From 4c630d3e7b9431bc77d6eddb3ccd6f367ce04c2f Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Wed, 10 Jun 2026 21:49:17 +0530 Subject: [PATCH] =?UTF-8?q?opentui(v6):=20Esc+Esc=20session=20prompt=20his?= =?UTF-8?q?tory=20=E2=80=94=20rollback/undo=20confirm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui-opentui/src/logic/promptHistory.ts | 116 +++++++++ ui-opentui/src/logic/store.ts | 13 + ui-opentui/src/test/promptHistory.test.ts | 143 +++++++++++ .../src/test/promptHistoryView.test.tsx | 235 ++++++++++++++++++ ui-opentui/src/view/App.tsx | 21 +- ui-opentui/src/view/composer.tsx | 28 +++ .../src/view/overlays/promptHistory.tsx | 168 +++++++++++++ 7 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 ui-opentui/src/logic/promptHistory.ts create mode 100644 ui-opentui/src/test/promptHistory.test.ts create mode 100644 ui-opentui/src/test/promptHistoryView.test.tsx create mode 100644 ui-opentui/src/view/overlays/promptHistory.tsx diff --git a/ui-opentui/src/logic/promptHistory.ts b/ui-opentui/src/logic/promptHistory.ts new file mode 100644 index 00000000000..281d6ef11da --- /dev/null +++ b/ui-opentui/src/logic/promptHistory.ts @@ -0,0 +1,116 @@ +/** + * promptHistory — pure logic for the Esc+Esc session prompt viewer (Epic 5). + * + * Model: free-code's rewind dialog (`useDoublePress.ts`, `MessageSelector.tsx`) + * — 800ms double-press window, only-when-input-empty trigger, 7 visible rows + * newest-first with a centered window, Enter → confirm step. + * + * Semantics (spec Epic 5, RESOLVED block): + * - Entries are THIS session's user prompts from the store transcript + * (NOT the per-dir JSONL composer history), newest first. Empty → no modal. + * - Undo = conversation layer (`/undo` removes the LAST user/assistant + * exchange; files kept) → offered ONLY for the most recent prompt. We never + * fake arbitrary-depth conversation rewind. + * - Rollback = filesystem layer (`/rollback` checkpoints; conversation kept). + * Prompt→checkpoint mapping isn't feasible client-side (neither store + * messages nor `session.history` carry timestamps to correlate against + * `rollback.list`'s checkpoint timestamps), so the honest action is plain + * `/rollback`: the gateway's own checkpoint list lands in the transcript + * and the user picks `/rollback ` from real data. + */ + +/** Double-press window (free-code `DOUBLE_PRESS_TIMEOUT_MS`). */ +export const DOUBLE_PRESS_WINDOW_MS = 800 + +/** Max visible prompt rows before the list windows (free-code `MAX_VISIBLE_MESSAGES`). */ +export const MAX_VISIBLE = 7 + +/** + * Double-press detector (pure state machine; the free-code hook without React). + * `press(now)` returns true on the SECOND press within the window — and then + * disarms, so a third press starts a fresh cycle. `reset()` disarms (call it on + * any intervening key, and never call `press` for an Esc something else + * consumed — that's what keeps a dropdown-dismiss Esc from arming). + */ +export interface DoublePress { + press(now?: number): boolean + reset(): void +} + +export function createDoublePress(windowMs: number = DOUBLE_PRESS_WINDOW_MS): DoublePress { + let armedAt: number | undefined + return { + press(now: number = Date.now()): boolean { + if (armedAt !== undefined && now - armedAt <= windowMs) { + armedAt = undefined + return true + } + armedAt = now + return false + }, + reset(): void { + armedAt = undefined + } + } +} + +/** One viewer row: a user prompt of THIS session. `index` is its position in + * the source transcript (stable identity across renders). */ +export interface PromptEntry { + readonly index: number + readonly text: string +} + +/** + * Source the viewer entries from the store transcript: USER prompts only, + * non-empty, NEWEST FIRST. Session-only by construction (the store holds only + * this session's messages). Empty session → [] (the trigger shows nothing). + */ +export function promptHistoryEntries(messages: ReadonlyArray<{ readonly role: string; text: string }>): PromptEntry[] { + const entries: PromptEntry[] = [] + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i] + if (m && m.role === 'user' && m.text.trim() !== '') entries.push({ index: i, text: m.text }) + } + return entries +} + +/** A confirm-step action. */ +export type HistoryAction = 'undo' | 'rollback' + +export interface ConfirmOption { + readonly action: HistoryAction + readonly label: string +} + +/** The exact signed-off confirm labels (spec Epic 5). */ +export const UNDO_LABEL = 'Undo — rewind the conversation (files kept)' +export const ROLLBACK_LABEL = 'Rollback — restore files from checkpoint (conversation kept)' + +/** + * The confirm-step options for a selected entry. `/undo` only removes the LAST + * exchange, so Undo is offered ONLY for the most recent prompt (`isLatest`) — + * an option the gateway can't honor is hidden, never a dead button. Rollback + * (filesystem checkpoints) applies regardless of the selected depth. + */ +export function confirmOptions(isLatest: boolean): ConfirmOption[] { + const options: ConfirmOption[] = [] + if (isLatest) options.push({ action: 'undo', label: UNDO_LABEL }) + options.push({ action: 'rollback', label: ROLLBACK_LABEL }) + return options +} + +/** The slash command an action dispatches — through the SAME command path the + * composer uses (`dispatchSlash` → `slash.exec`/`command.dispatch`). */ +export function actionCommand(action: HistoryAction): string { + return action === 'undo' ? '/undo' : '/rollback' +} + +/** + * First visible row index for a list window: keep the selection centered until + * the window hits either end (free-code `firstVisibleIndex`). Total ≤ visible + * → 0 (everything shows). + */ +export function windowStart(selected: number, total: number, visible: number = MAX_VISIBLE): number { + return Math.max(0, Math.min(selected - Math.floor(visible / 2), total - visible)) +} diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index 234659b0d01..f76a9214b1b 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -212,6 +212,8 @@ export interface StoreState { switcher: SessionItem[] | undefined /** The open generic picker (model/skills/…); undefined when none. */ picker: PickerState | undefined + /** Whether the Esc+Esc session prompt-history viewer is open (Epic 5). */ + promptHistory: boolean /** Live completion candidates (slash-name/args or file/@-mention) shown above the composer. */ completions: CompletionItem[] | undefined /** Char offset in the input where an accepted completion should start replacing @@ -384,6 +386,7 @@ export function createSessionStore() { pager: undefined, switcher: undefined, picker: undefined, + promptHistory: false, completions: undefined, completionFrom: 0, subagents: [], @@ -565,6 +568,14 @@ export function createSessionStore() { setState('picker', undefined) } + /** Open / close the Esc+Esc session prompt-history viewer (Epic 5). */ + function openPromptHistory() { + setState('promptHistory', true) + } + function closePromptHistory() { + setState('promptHistory', false) + } + /** Cache the mapped `/model` picker rows (instant open — Epic 7). */ function setModelItems(items: PickerItem[]) { setState('modelItems', items) @@ -956,6 +967,8 @@ export function createSessionStore() { closeSwitcher, openPicker, closePicker, + openPromptHistory, + closePromptHistory, setModelItems, setCompletions, clearCompletions, diff --git a/ui-opentui/src/test/promptHistory.test.ts b/ui-opentui/src/test/promptHistory.test.ts new file mode 100644 index 00000000000..d306504b536 --- /dev/null +++ b/ui-opentui/src/test/promptHistory.test.ts @@ -0,0 +1,143 @@ +/** + * Pure tests for the Esc+Esc session prompt viewer logic (Epic 5; + * logic/promptHistory.ts): the double-press window (free-code's 800ms model), + * entry sourcing (user prompts only, newest first, session-only, empty → none), + * Undo eligibility (most-recent entry ONLY), and the 7-visible centered + * windowing math. + */ +import { describe, expect, test } from 'vitest' + +import { + actionCommand, + confirmOptions, + createDoublePress, + DOUBLE_PRESS_WINDOW_MS, + MAX_VISIBLE, + promptHistoryEntries, + ROLLBACK_LABEL, + UNDO_LABEL, + windowStart +} from '../logic/promptHistory.ts' + +describe('createDoublePress — the 800ms window', () => { + test('two presses 799ms apart fire', () => { + const dp = createDoublePress() + expect(dp.press(1000)).toBe(false) + expect(dp.press(1799)).toBe(true) + }) + + test('two presses exactly at the window boundary fire (<=, free-code parity)', () => { + const dp = createDoublePress() + expect(dp.press(0)).toBe(false) + expect(dp.press(DOUBLE_PRESS_WINDOW_MS)).toBe(true) + }) + + test('two presses 801ms apart do NOT fire — the late press re-arms instead', () => { + const dp = createDoublePress() + expect(dp.press(1000)).toBe(false) + expect(dp.press(1801)).toBe(false) + // …and the re-armed press pairs with a quick follow-up + expect(dp.press(2000)).toBe(true) + }) + + test('an intervening key (reset) disarms the pending press', () => { + const dp = createDoublePress() + expect(dp.press(1000)).toBe(false) + dp.reset() + expect(dp.press(1100)).toBe(false) // would have fired without the reset + expect(dp.press(1200)).toBe(true) + }) + + test('firing disarms — a third quick press starts a fresh cycle', () => { + const dp = createDoublePress() + dp.press(1000) + expect(dp.press(1100)).toBe(true) + expect(dp.press(1200)).toBe(false) + }) +}) + +describe('promptHistoryEntries — sourcing from the session transcript', () => { + test('user prompts only, newest first, with stable transcript indices', () => { + const messages = [ + { role: 'user', text: 'first prompt' }, + { role: 'assistant', text: 'reply one' }, + { role: 'system', text: 'a notice' }, + { role: 'user', text: 'second prompt' }, + { role: 'assistant', text: 'reply two' }, + { role: 'user', text: 'third prompt' } + ] + expect(promptHistoryEntries(messages)).toEqual([ + { index: 5, text: 'third prompt' }, + { index: 3, text: 'second prompt' }, + { index: 0, text: 'first prompt' } + ]) + }) + + test('empty session → no entries (the trigger shows nothing)', () => { + expect(promptHistoryEntries([])).toEqual([]) + }) + + test('assistant/system-only transcript → no entries', () => { + expect( + promptHistoryEntries([ + { role: 'assistant', text: 'hello' }, + { role: 'system', text: 'gateway ready' } + ]) + ).toEqual([]) + }) + + test('blank user rows are skipped', () => { + expect( + promptHistoryEntries([ + { role: 'user', text: ' ' }, + { role: 'user', text: 'real' } + ]) + ).toEqual([{ index: 1, text: 'real' }]) + }) +}) + +describe('confirmOptions — Undo only for the most recent prompt', () => { + test('latest entry offers Undo then Rollback, with the signed-off labels', () => { + expect(confirmOptions(true)).toEqual([ + { action: 'undo', label: UNDO_LABEL }, + { action: 'rollback', label: ROLLBACK_LABEL } + ]) + expect(UNDO_LABEL).toBe('Undo — rewind the conversation (files kept)') + expect(ROLLBACK_LABEL).toBe('Rollback — restore files from checkpoint (conversation kept)') + }) + + test('an older entry hides Undo (the gateway only rewinds the LAST exchange)', () => { + expect(confirmOptions(false)).toEqual([{ action: 'rollback', label: ROLLBACK_LABEL }]) + }) + + test('actions map to the existing slash commands', () => { + expect(actionCommand('undo')).toBe('/undo') + expect(actionCommand('rollback')).toBe('/rollback') + }) +}) + +describe('windowStart — 7 visible, selection centered', () => { + test('everything fits → window starts at 0', () => { + expect(windowStart(0, 5)).toBe(0) + expect(windowStart(4, 5)).toBe(0) + expect(windowStart(6, MAX_VISIBLE)).toBe(0) + }) + + test('selection centers once past the half-window', () => { + // visible=7 → half=3; selection 5 of 20 → window starts at 2 (5 centered) + expect(windowStart(5, 20)).toBe(2) + expect(windowStart(10, 20)).toBe(7) + }) + + test('clamps at the top and the bottom', () => { + expect(windowStart(0, 20)).toBe(0) + expect(windowStart(2, 20)).toBe(0) // still within the first half-window + expect(windowStart(19, 20)).toBe(13) // last 7 rows + expect(windowStart(17, 20)).toBe(13) + }) + + test('honors a custom visible count', () => { + expect(windowStart(4, 10, 3)).toBe(3) + expect(windowStart(9, 10, 3)).toBe(7) + }) +}) diff --git a/ui-opentui/src/test/promptHistoryView.test.tsx b/ui-opentui/src/test/promptHistoryView.test.tsx new file mode 100644 index 00000000000..34151ab3f74 --- /dev/null +++ b/ui-opentui/src/test/promptHistoryView.test.tsx @@ -0,0 +1,235 @@ +/** + * Composer/keymap-level tests for the Esc+Esc session prompt viewer (Epic 5): + * headless frames through the real App + Composer with a simulated keyboard. + * + * - Esc+Esc on an EMPTY composer (session has prompts) opens the viewer, + * newest first with the ▶ pointer; a single Esc opens nothing. + * - Esc+Esc with text in the composer does nothing. + * - Esc+Esc on an empty session does nothing (no empty modal). + * - An Esc that dismissed the completion dropdown does NOT arm the + * double-press (the pre-existing Esc semantics compose, never regress). + * - Enter → confirm step (BOTH options on the latest entry; Rollback ONLY on + * an older one) → Esc backs out to the list → Esc closes (composer back). + * - Confirming dispatches /undo · /rollback through the App's submit path. + * + * The onType wiring mirrors the entry (`planCompletion` → fake catalog → + * `store.setCompletions`), same as slashMenu.test.tsx, so frames are + * deterministic. kittyKeyboard: a simulated lone ESC only parses there. + */ +import { describe, expect, test } from 'vitest' + +import { ROLLBACK_LABEL, UNDO_LABEL } from '../logic/promptHistory.ts' +import { planCompletion } from '../logic/slash.ts' +import { createSessionStore, type CompletionItem, type SessionStore } from '../logic/store.ts' +import { App } from '../view/App.tsx' +import { ThemeProvider } from '../view/theme.tsx' +import { renderProbe, type RenderProbe } from './lib/render.ts' + +/** Fake gateway catalog (what `complete.slash` would return for a `/` prefix). */ +const CATALOG: CompletionItem[] = [ + { display: '/clear', meta: 'clear the transcript', text: '/clear' }, + { display: '/help', meta: 'list commands', text: '/help' } +] + +interface Harness { + probe: RenderProbe + store: SessionStore + submitted: string[] +} + +/** Mount the real App; `prompts` seeds this session's user turns (+ replies). */ +async function mount(prompts: string[] = []): Promise { + const store = createSessionStore() + store.apply({ type: 'gateway.ready' }) + for (const p of prompts) { + store.pushUser(p) + store.apply({ type: 'message.start' }) + store.apply({ payload: { text: `reply to ${p}` }, type: 'message.delta' }) + store.apply({ type: 'message.complete' }) + } + const submitted: string[] = [] + const onType = (text: string) => { + const plan = planCompletion(text) + if (!plan || plan.method !== 'complete.slash') { + store.clearCompletions() + return + } + const q = String(plan.params.text).toLowerCase() + const items = CATALOG.filter(c => c.text.startsWith(q) && c.text !== q) + if (items.length) store.setCompletions(items, plan.from) + else store.clearCompletions() + } + const probe = await renderProbe( + () => ( + store.state.theme}> + submitted.push(t)} onType={onType} /> + + ), + { height: 30, kittyKeyboard: true, width: 80 } + ) + return { probe, store, submitted } +} + +/** Esc twice (within the 800ms window — real time in-test is milliseconds). */ +async function doubleEsc(h: Harness): Promise { + h.probe.keys.pressEscape() + await h.probe.settle() + h.probe.keys.pressEscape() + await h.probe.settle() +} + +/** Let a deferClose (setTimeout 0) land, then settle a frame. */ +async function settleClose(h: Harness): Promise { + await new Promise(resolve => setTimeout(resolve, 1)) + await h.probe.settle() +} + +describe('Esc+Esc — opening the viewer', () => { + test('double Esc on an empty composer opens the viewer, newest first with ▶', async () => { + const h = await mount(['first prompt', 'second prompt', 'third prompt']) + try { + await doubleEsc(h) + const frame = await h.probe.waitForFrame(f => f.includes('⟲ Rewind')) + expect(h.store.state.promptHistory).toBe(true) + // newest first: the latest prompt carries the pointer + the (latest) tag + expect(frame).toContain('▶ third prompt') + expect(frame).toContain('(latest)') + expect(frame).toContain('second prompt') + expect(frame).toContain('first prompt') + } finally { + h.probe.destroy() + } + }) + + test('a single Esc opens nothing', async () => { + const h = await mount(['a prompt']) + try { + h.probe.keys.pressEscape() + await h.probe.settle() + expect(h.store.state.promptHistory).toBe(false) + expect(h.probe.frame()).not.toContain('⟲ Rewind') + } finally { + h.probe.destroy() + } + }) + + test('Esc+Esc with text in the composer does nothing', async () => { + const h = await mount(['a prompt']) + try { + await h.probe.keys.typeText('draft text') + await h.probe.settle() + await doubleEsc(h) + expect(h.store.state.promptHistory).toBe(false) + expect(h.probe.frame()).toContain('draft text') // composer untouched + } finally { + h.probe.destroy() + } + }) + + test('Esc+Esc on an empty session does nothing (no empty modal)', async () => { + const h = await mount([]) + try { + await doubleEsc(h) + expect(h.store.state.promptHistory).toBe(false) + expect(h.probe.frame()).not.toContain('⟲ Rewind') + } finally { + h.probe.destroy() + } + }) + + test('an Esc that dismissed the completion dropdown does NOT arm the double-press', async () => { + const h = await mount(['a prompt']) + try { + await h.probe.keys.typeText('/') + await h.probe.settle() + await h.probe.waitForFrame(f => f.includes('/clear')) // dropdown open + h.probe.keys.pressEscape() // consumed: dismisses the dropdown + await h.probe.settle() + expect(h.probe.frame()).not.toContain('/clear') + h.probe.keys.pressEscape() // quick second Esc — must NOT open the viewer + await h.probe.settle() + expect(h.store.state.promptHistory).toBe(false) + expect(h.probe.frame()).not.toContain('⟲ Rewind') + } finally { + h.probe.destroy() + } + }) +}) + +describe('viewer — confirm step and dispatch', () => { + test('Enter on the LATEST entry shows BOTH options; Esc backs out; Esc closes', async () => { + const h = await mount(['first prompt', 'second prompt']) + try { + await doubleEsc(h) + await h.probe.waitForFrame(f => f.includes('⟲ Rewind')) + h.probe.keys.pressEnter() // latest entry → confirm + await h.probe.settle() + const confirm = h.probe.frame() + expect(confirm).toContain(UNDO_LABEL) + expect(confirm).toContain(ROLLBACK_LABEL) + h.probe.keys.pressEscape() // back to the list, NOT closed + await h.probe.settle() + expect(h.store.state.promptHistory).toBe(true) + expect(h.probe.frame()).toContain('▶ second prompt') + h.probe.keys.pressEscape() // now closes + await settleClose(h) + expect(h.store.state.promptHistory).toBe(false) + expect(h.probe.frame()).not.toContain('⟲ Rewind') + expect(h.submitted).toEqual([]) // nothing was dispatched + } finally { + h.probe.destroy() + } + }) + + test('an OLDER entry hides Undo — Rollback only', async () => { + const h = await mount(['first prompt', 'second prompt']) + try { + await doubleEsc(h) + await h.probe.waitForFrame(f => f.includes('⟲ Rewind')) + h.probe.keys.pressArrow('down') // newest → older (first prompt) + await h.probe.settle() + expect(h.probe.frame()).toContain('▶ first prompt') + h.probe.keys.pressEnter() + await h.probe.settle() + const confirm = h.probe.frame() + expect(confirm).toContain(ROLLBACK_LABEL) + expect(confirm).not.toContain(UNDO_LABEL) + } finally { + h.probe.destroy() + } + }) + + test('confirming Undo on the latest entry dispatches /undo through the submit path', async () => { + const h = await mount(['only prompt']) + try { + await doubleEsc(h) + await h.probe.waitForFrame(f => f.includes('⟲ Rewind')) + h.probe.keys.pressEnter() // confirm step (Undo highlighted first) + await h.probe.settle() + h.probe.keys.pressEnter() // confirm Undo + await settleClose(h) + expect(h.submitted).toEqual(['/undo']) + expect(h.store.state.promptHistory).toBe(false) + } finally { + h.probe.destroy() + } + }) + + test('confirming Rollback dispatches /rollback', async () => { + const h = await mount(['first prompt', 'second prompt']) + try { + await doubleEsc(h) + await h.probe.waitForFrame(f => f.includes('⟲ Rewind')) + h.probe.keys.pressArrow('down') // older entry → Rollback is the only option + await h.probe.settle() + h.probe.keys.pressEnter() + await h.probe.settle() + h.probe.keys.pressEnter() + await settleClose(h) + expect(h.submitted).toEqual(['/rollback']) + expect(h.store.state.promptHistory).toBe(false) + } finally { + h.probe.destroy() + } + }) +}) diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index bcbecf2f112..647f65912ee 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -16,8 +16,9 @@ import { Match, Switch } from 'solid-js' import { deferClose } from '../logic/defer.ts' -import type { PromptHistory } from '../logic/history.ts' +import type { PromptHistory as ComposerHistory } from '../logic/history.ts' import type { PasteStore } from '../logic/pastes.ts' +import { actionCommand, promptHistoryEntries } from '../logic/promptHistory.ts' import type { SessionStore } from '../logic/store.ts' import { AgentsTray, type AgentsTrayApi } from './agentsTray.tsx' import { Composer } from './composer.tsx' @@ -26,6 +27,7 @@ import { Header } from './header.tsx' import { AgentsDashboard } from './overlays/agentsDashboard.tsx' import { Pager } from './overlays/pager.tsx' import { Picker } from './overlays/picker.tsx' +import { PromptHistory } from './overlays/promptHistory.tsx' import { SessionSwitcher } from './overlays/sessionSwitcher.tsx' import { PromptOverlay } from './prompts/promptOverlay.tsx' import { SessionInfoProvider } from './sessionInfo.tsx' @@ -41,7 +43,7 @@ export interface AppProps { readonly onRespond?: (method: string, params: Record) => void readonly onResume?: (sessionId: string) => void readonly sessionId?: () => string | undefined - readonly history?: PromptHistory + readonly history?: ComposerHistory readonly onImagePaste?: () => void readonly pasteStore?: PasteStore } @@ -63,12 +65,19 @@ export function App(props: AppProps) { const dashboard = () => props.store.state.dashboard const switcher = () => props.store.state.switcher const picker = () => props.store.state.picker + const promptHistory = () => props.store.state.promptHistory // Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in // the freshly-remounted composer (see deferClose). const closePager = () => deferClose(() => props.store.closePager()) const closeDashboard = () => deferClose(() => props.store.closeDashboard()) const closeSwitcher = () => deferClose(() => props.store.closeSwitcher()) const closePicker = () => deferClose(() => props.store.closePicker()) + const closePromptHistory = () => deferClose(() => props.store.closePromptHistory()) + // Esc+Esc viewer trigger (Epic 5): only when this session HAS user prompts — + // an empty session opens nothing (no empty modal). + const openPromptHistory = () => { + if (promptHistoryEntries(props.store.state.messages).length > 0) props.store.openPromptHistory() + } const resume = (id: string) => { ;(props.onResume ?? NOOP_RESUME)(id) closeSwitcher() @@ -113,6 +122,7 @@ export function App(props: AppProps) { pasteStore={props.pasteStore} onFocusDown={() => trayApi?.focusTray() ?? false} registerFocus={fn => (focusComposer = fn)} + onDoubleEsc={openPromptHistory} /> } > @@ -139,6 +149,13 @@ export function App(props: AppProps) { /> )} + + (props.onSubmit ?? NOOP)(actionCommand(action))} + onClose={closePromptHistory} + /> + {/* background-agents tray (Epic 2.7): renders nothing with no running agents; a one-line indicator otherwise; expands while diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index de14de0b61e..569734c084a 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -42,6 +42,7 @@ import { useKeyboard } from '@opentui/solid' import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount, Show } from 'solid-js' import { MENU_MAX, routeMenuKey } from '../logic/completionMenu.ts' +import { createDoublePress } from '../logic/promptHistory.ts' import { analyzeSlash, learnableNames, nativeCharOffset } from '../logic/skillMatch.ts' import type { CompletionItem } from '../logic/store.ts' import type { PromptHistory } from '../logic/history.ts' @@ -119,6 +120,10 @@ export function Composer(props: { onFocusDown?: (() => boolean) | undefined /** Hands the parent a "focus the textarea" callback (Esc from the tray). */ registerFocus?: ((focus: () => void) => void) | undefined + /** Esc+Esc (≤800ms) on an EMPTY composer with no dropdown open (Epic 5): the + * parent opens the session prompt-history viewer (or does nothing when the + * session has no prompts yet — never an empty modal). */ + onDoubleEsc?: (() => void) | undefined }) { const theme = useTheme() const dims = useDimensions() @@ -254,6 +259,12 @@ export function Composer(props: { props.onDismiss?.() } + // Esc+Esc → session prompt history (Epic 5; free-code's double-press model). + // ONLY an Esc that nothing else consumed counts: the dropdown-dismiss branch + // returns before press() is reached (so a dismissing Esc never arms), and any + // other key resets the window (intervening keys disarm). + const doubleEsc = createDoublePress() + const submit = () => { if (submitting || !ta) return // Expand any `[Pasted text #N]` placeholders back to their full content before @@ -270,6 +281,9 @@ export function Composer(props: { } useKeyboard(key => { + // 0) double-Esc bookkeeping: any non-Esc press is an intervening key and + // disarms the pending Esc (free-code resets on every other input). + if (key.eventType !== 'release' && key.name !== 'escape') doubleEsc.reset() // 1) completion-menu keys while the dropdown is open (Epic 8): Tab accept / // Esc dismiss for ANY menu (the pre-existing semantics — Esc stays exactly // "dismiss if open, else fall through"), plus Up/Down/Enter for the SLASH @@ -298,9 +312,23 @@ export function Composer(props: { // re-open it on the next analysis pass); any edit re-arms it. setDismissedFor(ta?.plainText ?? '') props.onDismiss?.() + // a CONSUMED Esc never counts toward the Esc+Esc viewer (Epic 5). + doubleEsc.reset() return } } + // 1.5) Esc+Esc on an EMPTY, FOCUSED composer (no dropdown — the dismiss + // branch returned above) opens the session prompt-history viewer (Epic 5). + // With text in the buffer the Esc is just an intervening key (disarms); + // unfocused (e.g. the agents tray owns the keys) it never counts. + if (key.name === 'escape' && key.eventType !== 'release' && !key.ctrl && !key.meta && !key.option) { + if (ta?.focused === true && ta.plainText === '') { + if (doubleEsc.press()) props.onDoubleEsc?.() + } else { + doubleEsc.reset() + } + return + } // 2) background-agents tray handoff (Epic 2.7): Down on an EMPTY focused // composer with NO dropdown open offers focus to the tray. The parent decides // eligibility (≥1 running agent; overlays/prompts replace the composer diff --git a/ui-opentui/src/view/overlays/promptHistory.tsx b/ui-opentui/src/view/overlays/promptHistory.tsx new file mode 100644 index 00000000000..d557e13709a --- /dev/null +++ b/ui-opentui/src/view/overlays/promptHistory.tsx @@ -0,0 +1,168 @@ +/** + * PromptHistory — the Esc+Esc session prompt viewer → Rollback/Undo confirm + * (Epic 5; the interaction model is free-code's rewind dialog, MessageSelector). + * + * Two steps, replacing the composer while open: + * 1. LIST — this session's user prompts, NEWEST FIRST, ≤7 visible with a + * centered window, `▶` pointer + themed highlight on the selection; + * ↑↓ navigate, Enter → confirm, Esc closes. + * 2. CONFIRM — the selected prompt quoted, with exactly the actions the + * gateway can honor (logic/promptHistory.confirmOptions): Undo (latest + * entry ONLY — `/undo` rewinds just the last exchange) and Rollback + * (dispatches `/rollback`; the gateway's checkpoint list lands in the + * transcript — prompt→checkpoint mapping isn't possible client-side, see + * logic/promptHistory.ts). Enter dispatches via the SAME slash path the + * composer uses; Esc backs out to the list. + * + * Keys are handled by a global useKeyboard (picker pattern — nothing inside is + * natively focusable, so a focus-within close layer would never activate). + */ +import { useKeyboard } from '@opentui/solid' +import { createMemo, createSignal, For, Index, Show } from 'solid-js' + +import { + confirmOptions, + MAX_VISIBLE, + windowStart, + type HistoryAction, + type PromptEntry +} from '../../logic/promptHistory.ts' +import { useTheme } from '../theme.tsx' + +/** One line of preview per row (flattened, ellipsized). */ +function preview(text: string, max = 72): string { + const flat = text.replace(/\s+/g, ' ').trim() + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat +} + +export function PromptHistory(props: { + /** This session's user prompts, newest first (logic/promptHistory.entries). */ + entries: PromptEntry[] + /** Dispatch the confirmed action (the App routes it through the slash path). */ + onAction: (action: HistoryAction) => void + onClose: () => void +}) { + const theme = useTheme() + const [sel, setSel] = createSignal(0) + // undefined = list step; an index into entries = confirm step for that entry. + const [confirming, setConfirming] = createSignal(undefined) + const [actionSel, setActionSel] = createSignal(0) + + const count = () => props.entries.length + const first = createMemo(() => windowStart(sel(), count())) + const visible = createMemo(() => props.entries.slice(first(), first() + MAX_VISIBLE)) + + const confirmEntry = () => { + const i = confirming() + return i === undefined ? undefined : props.entries[i] + } + // entries are newest-first → index 0 IS the most recent prompt (Undo-eligible). + const options = createMemo(() => confirmOptions(confirming() === 0)) + + useKeyboard(key => { + if (key.eventType === 'release') return + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + // Esc backs out one level: confirm → list, list → closed. + if (confirming() !== undefined) setConfirming(undefined) + else props.onClose() + return + } + const inConfirm = confirming() !== undefined + const max = inConfirm ? options().length : count() + if (key.name === 'up') { + key.preventDefault() + if (inConfirm) setActionSel(s => Math.max(0, s - 1)) + else setSel(s => Math.max(0, s - 1)) + return + } + if (key.name === 'down') { + key.preventDefault() + if (inConfirm) setActionSel(s => Math.min(max - 1, s + 1)) + else setSel(s => Math.min(max - 1, s + 1)) + return + } + if (key.name === 'return') { + key.preventDefault() + if (!inConfirm) { + if (count() > 0) { + setActionSel(0) + setConfirming(sel()) + } + return + } + const option = options()[actionSel()] + if (option) { + props.onAction(option.action) + props.onClose() + } + } + }) + + return ( + + + ⟲ Rewind + + + This session's prompts, newest first — pick a point: + 0}> + {` ↑ ${first()} more`} + + + {(entry, i) => { + const selected = () => first() + i === sel() + return ( + + + + {selected() ? '▶ ' : ' '} + + + {preview(entry().text)} + + + (latest) + + + + ) + }} + + + {` ↓ ${count() - first() - MAX_VISIBLE} more`} + + ↑↓ select · Enter continue · Esc close + + } + > + {entry => ( + <> + Rewind to the point you sent: + {` ❝ ${preview(entry().text)} ❞`} + + {(option, i) => ( + + + + {i() === actionSel() ? '▶ ' : ' '} + + + {option.label} + + + + )} + + ↑↓ select · Enter confirm · Esc back + + )} + + + ) +}