From 1ecec7a9bc53477ad39c2587b430ca28d7e34942 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 17:52:38 +0000 Subject: [PATCH] =?UTF-8?q?opentui(v2):=20prompt=20history=20=E2=80=94=20U?= =?UTF-8?q?p/Down=20cycling,=20per-directory=20scope=20(item=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New logic/history.ts: createPromptHistory (pure cursor cycling — Up walks older, Down walks newer back to the stashed draft, push dedupes a consecutive duplicate + resets) plus best-effort per-dir JSONL persistence under $HERMES_HOME/tui-history/.jsonl (one JSON-encoded prompt per line, multiline-safe). Scoping matches the ask: prior prompts from the SAME launch dir are loaded on start (recallable across relaunches), but a different dir keeps its own list — no cross-dir/cross-session bleed. Composer: Up at the first line → older prompt; Down at the last line → newer/draft (at the boundary the textarea's own up/down is a no-op, so no conflict; mid-buffer it still moves the cursor). setText + cursor-to-end on recall; any edit resets the recall cursor. submit() pushes the prompt. Threaded entry → App → Composer; cwd = process.cwd() (the launch dir under the real launcher). Tests: 5 pure cursor-cycling cases. Live-smoked: seeded a dir file → Up/Up/Down cycled two→one→two; a freshly submitted prompt was recalled via Up. 65 pass. --- ui-tui-opentui-v2/src/entry/main.tsx | 12 ++ ui-tui-opentui-v2/src/logic/history.ts | 122 +++++++++++++++++++++ ui-tui-opentui-v2/src/test/history.test.ts | 60 ++++++++++ ui-tui-opentui-v2/src/view/App.tsx | 3 + ui-tui-opentui-v2/src/view/composer.tsx | 31 +++++- 5 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 ui-tui-opentui-v2/src/logic/history.ts create mode 100644 ui-tui-opentui-v2/src/test/history.test.ts diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index a50367362a6..caf58f7e0ce 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -27,6 +27,7 @@ import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts' import { getLog } from '../boundary/log.ts' import { acquireRenderer } from '../boundary/renderer.ts' import { makeAppLayer } from '../boundary/runtime.ts' +import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../logic/history.ts' import { mapResumeHistory, mapSessionList } from '../logic/resume.ts' import { dispatchSlash, mapCompletions, type SlashContext } from '../logic/slash.ts' import { createSessionStore, type SessionStore } from '../logic/store.ts' @@ -139,6 +140,16 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { // Solid side: the store + reducer. Created here, lives in Solid-land. const store = createSessionStore() + // Prompt history (item 6): scoped to the launch directory so prior prompts + // from the same project dir are recallable (Up/Down), without bleeding + // across different dirs. process.cwd() is the user's launch dir under the + // real launcher. + const historyCwd = process.cwd() + const history = createPromptHistory({ + initial: loadDirHistory(historyCwd), + persist: dirHistoryPersister(historyCwd) + }) + // Contact point #2: boundary pushes decoded events into the Solid store. const gateway = yield* GatewayService yield* gateway.subscribe(event => store.apply(event)) @@ -297,6 +308,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { onRespond={respond} onResume={onResume} sessionId={() => gateway.sessionId()} + history={history} /> ), diff --git a/ui-tui-opentui-v2/src/logic/history.ts b/ui-tui-opentui-v2/src/logic/history.ts new file mode 100644 index 00000000000..3f6a069c6b1 --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/history.ts @@ -0,0 +1,122 @@ +/** + * Prompt history (item 6) — the SOLID side, plain TS. Up/Down cycle through the + * prompts you've sent, scoped PER DIRECTORY: launching Hermes again in the same + * project dir reuses that dir's prior prompts (the "bleed for the same dir" the + * user asked for), while a session in a different dir keeps its own list. + * + * `createPromptHistory` is pure + injectable (initial entries + a `persist` + * sink) so the cursor logic is unit-tested with no filesystem. The real wiring + * uses `loadDirHistory(cwd)` / `dirHistoryPersister(cwd)` to read/append a + * per-dir JSONL file under `$HERMES_HOME/tui-history/.jsonl` (one + * JSON-encoded prompt per line, multiline-safe; opencode's prompt-history.jsonl + * model, Ink's ~/.hermes/.hermes_history idea, scoped by dir). + */ +import { appendFileSync, mkdirSync, readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { createHash } from 'node:crypto' +import { dirname, join } from 'node:path' + +const DEFAULT_MAX = 200 + +export interface PromptHistoryOptions { + /** Entries already on disk for this dir (oldest → newest). */ + initial?: string[] + /** Persist a newly pushed prompt (real use: append to the per-dir file). */ + persist?: (text: string) => void + /** Cap on retained entries (oldest dropped). */ + max?: number +} + +export interface PromptHistory { + /** All cycleable entries (oldest → newest) — loaded prev-session + this session. */ + entries: () => string[] + /** Record a submitted prompt (skips a consecutive duplicate) and reset the cursor. */ + push: (text: string) => void + /** Cycle to the OLDER entry (Up). Stashes `currentInput` as the draft on the first step. */ + prev: (currentInput: string) => string | null + /** Cycle to the NEWER entry (Down); returns the stashed draft at the bottom. */ + next: () => string | null + /** Reset the cursor to the live draft (call on any edit). */ + reset: () => void +} + +export function createPromptHistory(opts: PromptHistoryOptions = {}): PromptHistory { + const entries = [...(opts.initial ?? [])] + const max = opts.max ?? DEFAULT_MAX + // `idx === entries.length` means "at the live draft" (past the newest entry). + let idx = entries.length + let draft = '' + + return { + entries: () => entries.slice(), + push(text) { + if (!text.trim()) return + if (entries[entries.length - 1] !== text) { + entries.push(text) + if (entries.length > max) entries.shift() + opts.persist?.(text) + } + idx = entries.length + draft = '' + }, + prev(currentInput) { + if (entries.length === 0) return null + if (idx === entries.length) draft = currentInput // leaving the bottom — stash the draft + if (idx > 0) idx-- + return entries[idx] ?? null + }, + next() { + if (idx >= entries.length) return null + idx++ + return idx === entries.length ? draft : (entries[idx] ?? null) + }, + reset() { + idx = entries.length + } + } +} + +// ── per-directory file persistence (best-effort; never throws) ────────── + +function hermesHome(): string { + return process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes') +} + +/** The history file for a given working directory (keyed by a hash of the abs path). */ +function dirHistoryPath(cwd: string): string { + const key = createHash('sha1').update(cwd).digest('hex').slice(0, 16) + return join(hermesHome(), 'tui-history', `${key}.jsonl`) +} + +/** Load a directory's prior prompts (oldest → newest); [] if none / unreadable. */ +export function loadDirHistory(cwd: string, max = DEFAULT_MAX): string[] { + try { + const raw = readFileSync(dirHistoryPath(cwd), 'utf8') + const out: string[] = [] + for (const line of raw.split('\n')) { + if (!line.trim()) continue + try { + const v: unknown = JSON.parse(line) + if (typeof v === 'string') out.push(v) + } catch { + // skip a corrupt line — never let it break loading + } + } + return out.length > max ? out.slice(out.length - max) : out + } catch { + return [] + } +} + +/** A persister that appends each pushed prompt to the dir's JSONL file (best-effort). */ +export function dirHistoryPersister(cwd: string): (text: string) => void { + const path = dirHistoryPath(cwd) + return text => { + try { + mkdirSync(dirname(path), { recursive: true }) + appendFileSync(path, JSON.stringify(text) + '\n', 'utf8') + } catch { + // history persistence is non-essential — a write failure must not disrupt the turn + } + } +} diff --git a/ui-tui-opentui-v2/src/test/history.test.ts b/ui-tui-opentui-v2/src/test/history.test.ts new file mode 100644 index 00000000000..66c02df008b --- /dev/null +++ b/ui-tui-opentui-v2/src/test/history.test.ts @@ -0,0 +1,60 @@ +/** + * Prompt history (item 6) — pure cursor-cycling behaviour, no filesystem. + * Up walks older, Down walks newer back to the stashed draft; push dedupes a + * consecutive duplicate, persists, and resets the cursor; an edit (reset) puts + * the next Up back at the newest. Per-directory file persistence is exercised + * only via the injected `persist` sink here. + */ +import { describe, expect, test } from 'bun:test' + +import { createPromptHistory } from '../logic/history.ts' + +describe('prompt history — cursor cycling', () => { + test('Up walks older entries, Down walks back to the live draft', () => { + const h = createPromptHistory({ initial: ['first', 'second', 'third'] }) + // start typing a draft, then press Up + expect(h.prev('draft')).toBe('third') + expect(h.prev('draft')).toBe('second') + expect(h.prev('draft')).toBe('first') + expect(h.prev('draft')).toBe('first') // clamped at the oldest + // Down walks newer, then restores the stashed draft at the bottom + expect(h.next()).toBe('second') + expect(h.next()).toBe('third') + expect(h.next()).toBe('draft') + expect(h.next()).toBeNull() // already at the bottom + }) + + test('push appends, dedupes a consecutive duplicate, persists, resets cursor', () => { + const persisted: string[] = [] + const h = createPromptHistory({ initial: ['a'], persist: t => persisted.push(t) }) + h.push('b') + h.push('b') // consecutive duplicate — not stored again + h.push('c') + expect(h.entries()).toEqual(['a', 'b', 'c']) + expect(persisted).toEqual(['b', 'c']) + // after push the cursor is at the bottom → Up returns the newest + expect(h.prev('')).toBe('c') + }) + + test('reset returns the cursor to the bottom (called on edit)', () => { + const h = createPromptHistory({ initial: ['x', 'y'] }) + expect(h.prev('')).toBe('y') + expect(h.prev('')).toBe('x') + h.reset() // user edited the buffer + expect(h.prev('newdraft')).toBe('y') // next Up starts from the newest again + }) + + test('empty history: prev/next are inert', () => { + const h = createPromptHistory() + expect(h.prev('draft')).toBeNull() + expect(h.next()).toBeNull() + }) + + test('max cap drops the oldest entries', () => { + const h = createPromptHistory({ max: 2 }) + h.push('1') + h.push('2') + h.push('3') + expect(h.entries()).toEqual(['2', '3']) + }) +}) diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index eda0cb44539..e00825de5cb 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -15,6 +15,7 @@ */ import { Match, Switch } from 'solid-js' +import type { PromptHistory } from '../logic/history.ts' import type { SessionStore } from '../logic/store.ts' import { Composer } from './composer.tsx' import { Header } from './header.tsx' @@ -35,6 +36,7 @@ export interface AppProps { readonly onRespond?: (method: string, params: Record) => void readonly onResume?: (sessionId: string) => void readonly sessionId?: () => string | undefined + readonly history?: PromptHistory } const NOOP = () => {} @@ -85,6 +87,7 @@ export function App(props: AppProps) { onType={props.onType} completions={() => props.store.state.completions ?? []} onDismiss={() => props.store.clearCompletions()} + history={props.history} /> } > diff --git a/ui-tui-opentui-v2/src/view/composer.tsx b/ui-tui-opentui-v2/src/view/composer.tsx index cbca9aea866..abdb737f930 100644 --- a/ui-tui-opentui-v2/src/view/composer.tsx +++ b/ui-tui-opentui-v2/src/view/composer.tsx @@ -24,6 +24,7 @@ import { useKeyboard } from '@opentui/solid' import { For, onMount, Show } from 'solid-js' import type { CompletionItem } from '../logic/store.ts' +import type { PromptHistory } from '../logic/history.ts' import { useTheme } from './theme.tsx' /** Keys that must NOT steal focus back to the composer (scroll/edit/nav). */ @@ -75,18 +76,27 @@ export function Composer(props: { onType?: ((text: string) => void) | undefined completions?: (() => CompletionItem[]) | undefined onDismiss?: (() => void) | undefined + history?: PromptHistory | undefined }) { const theme = useTheme() let ta: TextareaRenderable | undefined let submitting = false const completions = () => props.completions?.() ?? [] + /** Replace the textarea content and park the cursor at the end (history recall). */ + const setBuffer = (text: string) => { + if (!ta) return + ta.setText(text) + ta.cursorOffset = text.length + } + const submit = () => { if (submitting || !ta) return const text = ta.plainText.trim() if (!text) return submitting = true props.onSubmit(text) + props.history?.push(text) ta.clear() props.onDismiss?.() submitting = false @@ -109,7 +119,26 @@ export function Composer(props: { return } } - // 2) always-active input (item 2): a printable key while the textarea lost + // 2) prompt history (item 6): Up at the first line → older prompt; Down at the + // last line → newer/draft. At the boundary the textarea's own up/down is a + // no-op, so there's no conflict; mid-buffer it falls through to cursor moves. + if (ta && props.history) { + if (key.name === 'up' && ta.logicalCursor.row === 0) { + const entry = props.history.prev(ta.plainText) + if (entry !== null) setBuffer(entry) + return + } + if (key.name === 'down' && ta.logicalCursor.row === ta.lineCount - 1) { + const entry = props.history.next() + if (entry !== null) setBuffer(entry) + return + } + // any edit resets the recall cursor so the next Up starts from the bottom + if (key.name === 'backspace' || key.name === 'delete' || isPrintableKey(key)) { + props.history.reset() + } + } + // 3) always-active input (item 2): a printable key while the textarea lost // focus reclaims it AND recovers the char (the in-flight event went to this // global handler, not the unfocused textarea). Nav/scroll keys are untouched. if (ta && !ta.focused && isPrintableKey(key)) {