opentui(v6): Esc+Esc session prompt history — rollback/undo confirm

This commit is contained in:
alt-glitch 2026-06-10 21:49:17 +05:30
parent bc71c57ba9
commit 4c630d3e7b
7 changed files with 722 additions and 2 deletions

View file

@ -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).
* Promptcheckpoint 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 <n>` 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))
}

View file

@ -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,

View file

@ -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)
})
})

View file

@ -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<Harness> {
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(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} onSubmit={t => submitted.push(t)} onType={onType} />
</ThemeProvider>
),
{ 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<void> {
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<void> {
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()
}
})
})

View file

@ -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<string, unknown>) => 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) {
/>
)}
</Match>
<Match when={promptHistory()}>
<PromptHistory
entries={promptHistoryEntries(props.store.state.messages)}
onAction={action => (props.onSubmit ?? NOOP)(actionCommand(action))}
onClose={closePromptHistory}
/>
</Match>
</Switch>
{/* background-agents tray (Epic 2.7): renders nothing with no
running agents; a one-line indicator otherwise; expands while

View file

@ -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

View file

@ -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 promptcheckpoint 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<number | undefined>(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 (
<box
style={{ borderColor: theme().color.border, flexDirection: 'column', flexShrink: 0, marginTop: 1, padding: 1 }}
border
>
<text fg={theme().color.accent}>
<b> Rewind</b>
</text>
<Show
when={confirmEntry()}
fallback={
<>
<text fg={theme().color.muted}>This session's prompts, newest first pick a point:</text>
<Show when={first() > 0}>
<text fg={theme().color.muted}>{`${first()} more`}</text>
</Show>
<Index each={visible()}>
{(entry, i) => {
const selected = () => first() + i === sel()
return (
<box style={{ backgroundColor: selected() ? theme().color.selectionBg : 'transparent' }}>
<text selectable={false}>
<span style={{ fg: selected() ? theme().color.accent : theme().color.muted }}>
{selected() ? '▶ ' : ' '}
</span>
<span style={{ fg: selected() ? theme().color.text : theme().color.muted }}>
{preview(entry().text)}
</span>
<Show when={first() + i === 0}>
<span style={{ fg: theme().color.muted }}> (latest)</span>
</Show>
</text>
</box>
)
}}
</Index>
<Show when={first() + MAX_VISIBLE < count()}>
<text fg={theme().color.muted}>{`${count() - first() - MAX_VISIBLE} more`}</text>
</Show>
<text fg={theme().color.muted}> select · Enter continue · Esc close</text>
</>
}
>
{entry => (
<>
<text fg={theme().color.muted}>Rewind to the point you sent:</text>
<text fg={theme().color.text}>{`${preview(entry().text)}`}</text>
<For each={options()}>
{(option, i) => (
<box style={{ backgroundColor: i() === actionSel() ? theme().color.selectionBg : 'transparent' }}>
<text selectable={false}>
<span style={{ fg: i() === actionSel() ? theme().color.accent : theme().color.muted }}>
{i() === actionSel() ? '▶ ' : ' '}
</span>
<span style={{ fg: i() === actionSel() ? theme().color.text : theme().color.muted }}>
{option.label}
</span>
</text>
</box>
)}
</For>
<text fg={theme().color.muted}> select · Enter confirm · Esc back</text>
</>
)}
</Show>
</box>
)
}