mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 14:02:16 +00:00
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:
parent
325350d192
commit
1ecec7a9bc
5 changed files with 227 additions and 1 deletions
|
|
@ -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>
|
||||
),
|
||||
|
|
|
|||
122
ui-tui-opentui-v2/src/logic/history.ts
Normal file
122
ui-tui-opentui-v2/src/logic/history.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
60
ui-tui-opentui-v2/src/test/history.test.ts
Normal file
60
ui-tui-opentui-v2/src/test/history.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue