mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): Esc+Esc session prompt history — rollback/undo confirm
This commit is contained in:
parent
bc71c57ba9
commit
4c630d3e7b
7 changed files with 722 additions and 2 deletions
116
ui-opentui/src/logic/promptHistory.ts
Normal file
116
ui-opentui/src/logic/promptHistory.ts
Normal 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).
|
||||
* 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 <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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
143
ui-opentui/src/test/promptHistory.test.ts
Normal file
143
ui-opentui/src/test/promptHistory.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
235
ui-opentui/src/test/promptHistoryView.test.tsx
Normal file
235
ui-opentui/src/test/promptHistoryView.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
168
ui-opentui/src/view/overlays/promptHistory.tsx
Normal file
168
ui-opentui/src/view/overlays/promptHistory.tsx
Normal 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 — 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<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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue