opentui(v2): prompt history — Up/Down cycling, per-directory scope (item 6)

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/<sha1(cwd)>.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.
This commit is contained in:
alt-glitch 2026-06-08 17:52:38 +00:00
parent 325350d192
commit 1ecec7a9bc
5 changed files with 227 additions and 1 deletions

View file

@ -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}
/>
</ThemeProvider>
),

View file

@ -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/<hash>.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
}
}
}

View file

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

View file

@ -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<string, unknown>) => 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}
/>
}
>

View file

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