mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
feat(opentui-v2): Phase 5a — pager overlay for long slash output
A full-height scrollable pager (the FloatBox analog) — porting it unlocks the long-output slash commands (/status /logs /history /tools) at once (spec §2b). - view/overlays/pager.tsx: bordered full-height overlay (title + scrollbox + footer), scrolling driven explicitly via useKeyboard → scrollBy/scrollTo (no reliance on scrollbox auto-focus), Esc/q/Ctrl+C close. §8 #2 scrollbox gotchas. - store: pager state + openPager/closePager. - view/App.tsx: content zone swaps to the Pager (replacing transcript+composer) when store.state.pager is set; the close is deferred a tick so the closing key can't leak into the remounting composer. - logic/slash.ts: present() routes output to the pager when long (>180 chars or >2 non-empty lines, Ink parity) else a system line; titled by command; /logs always pages. New openPager on SlashContext. Verified: bun run check green (41 tests / 7 files) — present() routing (short→system, long→pager) + a pager frame test (renders title/content, replaces the transcript/composer). LIVE tmux: /logs → pager (title "Logs", scroll via PageDown, Esc closed → composer refocused, no key-leak); /version (5-line output) → pager titled "Version". Smoke P5a + parity matrix updated. Completions dropdown + pickers + chrome are the next slices.
This commit is contained in:
parent
c704d384f4
commit
abce50e34d
7 changed files with 165 additions and 22 deletions
|
|
@ -158,8 +158,9 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
||||
logTail: () =>
|
||||
getLog()
|
||||
.tail(40)
|
||||
.tail(200)
|
||||
.map(e => `${e.scope}: ${e.msg}`),
|
||||
openPager: (title, text) => store.openPager(title, text),
|
||||
pushSystem: text => store.pushSystem(text),
|
||||
quit: () => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export interface SlashContext {
|
|||
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
readonly sessionId: () => string | undefined
|
||||
readonly pushSystem: (text: string) => void
|
||||
/** Open the full-screen pager (long output: /status, /logs, …). */
|
||||
readonly openPager: (title: string, text: string) => void
|
||||
/** Submit a user turn (skill/send dispatch results). */
|
||||
readonly submit: (text: string) => void
|
||||
/** Open a local Y/N confirm; `onConfirm` runs on Yes. */
|
||||
|
|
@ -48,6 +50,15 @@ function readStr(value: unknown, key: string): string | undefined {
|
|||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1)
|
||||
|
||||
/** Long output → the pager; short → a system line (Ink: >180 chars or >2 lines). */
|
||||
function present(ctx: SlashContext, title: string, text: string): void {
|
||||
const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2
|
||||
if (long) ctx.openPager(title, text)
|
||||
else ctx.pushSystem(text)
|
||||
}
|
||||
|
||||
const CLIENT_HELP = [
|
||||
'/help — list commands',
|
||||
'/clear, /new — clear the transcript (confirm)',
|
||||
|
|
@ -71,7 +82,7 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||
ctx.pushSystem(CLIENT_HELP)
|
||||
}
|
||||
},
|
||||
logs: (_arg, ctx) => ctx.pushSystem(ctx.logTail().slice(-40).join('\n') || '(log empty)'),
|
||||
logs: (_arg, ctx) => ctx.openPager('Logs', ctx.logTail().join('\n') || '(log empty)'),
|
||||
new: (_arg, ctx) => ctx.confirm('Start fresh? (clears the transcript)', ctx.clearTranscript),
|
||||
quit: (_arg, ctx) => ctx.quit()
|
||||
}
|
||||
|
|
@ -142,7 +153,9 @@ export async function dispatchSlash(input: string, ctx: SlashContext): Promise<v
|
|||
const result = await ctx.request('slash.exec', { command: input.slice(1), session_id: sid })
|
||||
const output = readStr(result, 'output') || `/${parsed.name}: no output`
|
||||
const warning = readStr(result, 'warning')
|
||||
ctx.pushSystem(warning ? `warning: ${warning}\n${output}` : output)
|
||||
const text = warning ? `warning: ${warning}\n${output}` : output
|
||||
// Long output → pager (Ink: >180 chars or >2 non-empty lines), else a system line.
|
||||
present(ctx, titleCase(parsed.name), text)
|
||||
} catch {
|
||||
try {
|
||||
const raw = await ctx.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
||||
|
|
|
|||
|
|
@ -58,12 +58,20 @@ export type ActivePrompt =
|
|||
// local (non-gateway) Y/N confirm — e.g. /clear, /new (spec §2a)
|
||||
| { kind: 'confirm'; message: string; onConfirm: () => void }
|
||||
|
||||
/** A full-screen scrollable text viewer (long slash output: /status, /logs, …). */
|
||||
export interface PagerState {
|
||||
title: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface StoreState {
|
||||
ready: boolean
|
||||
messages: Message[]
|
||||
theme: Theme
|
||||
/** The active blocking prompt (composer is hidden while set); undefined when none. */
|
||||
prompt: ActivePrompt | undefined
|
||||
/** The open pager overlay (replaces the transcript while set); undefined when none. */
|
||||
pager: PagerState | undefined
|
||||
}
|
||||
|
||||
const LRU_LIMIT = 1000
|
||||
|
|
@ -79,7 +87,8 @@ export function createSessionStore() {
|
|||
ready: false,
|
||||
messages: [],
|
||||
theme: DEFAULT_THEME,
|
||||
prompt: undefined
|
||||
prompt: undefined,
|
||||
pager: undefined
|
||||
})
|
||||
|
||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
||||
|
|
@ -173,6 +182,16 @@ export function createSessionStore() {
|
|||
setState('prompt', { kind: 'confirm', message, onConfirm })
|
||||
}
|
||||
|
||||
/** Open the pager overlay (long slash output: /status, /logs, …). */
|
||||
function openPager(title: string, text: string) {
|
||||
setState('pager', { title, text })
|
||||
}
|
||||
|
||||
/** Close the pager overlay. */
|
||||
function closePager() {
|
||||
setState('pager', undefined)
|
||||
}
|
||||
|
||||
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
|
||||
function apply(event: GatewayEvent): void {
|
||||
if (buffering) {
|
||||
|
|
@ -335,6 +354,8 @@ export function createSessionStore() {
|
|||
pushSystem,
|
||||
clearTranscript,
|
||||
setConfirm,
|
||||
openPager,
|
||||
closePager,
|
||||
hydrate,
|
||||
beginBuffer,
|
||||
commitSnapshot,
|
||||
|
|
|
|||
|
|
@ -103,4 +103,26 @@ describe('App render (Phase 1, themed)', () => {
|
|||
expect(frame).toContain('Deny')
|
||||
expect(frame).not.toContain('Type your message') // composer is hidden while blocked
|
||||
})
|
||||
|
||||
test('the pager overlay renders title + content and replaces the transcript/composer', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.pushUser('a previous message')
|
||||
store.openPager('Status', 'status line one\nstatus line two\nstatus line three')
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Status', width: 72, height: 18 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Status') // pager title
|
||||
expect(frame).toContain('status line one') // paged content
|
||||
expect(frame).toContain('Esc/q close') // pager footer hint
|
||||
expect(frame).not.toContain('a previous message') // transcript replaced by the pager
|
||||
expect(frame).not.toContain('Type your message') // composer hidden while the pager is open
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ interface Probe {
|
|||
system: string[]
|
||||
submitted: string[]
|
||||
confirmed: Array<{ message: string; onConfirm: () => void }>
|
||||
paged: Array<{ title: string; text: string }>
|
||||
quit: { value: boolean }
|
||||
cleared: { value: boolean }
|
||||
}
|
||||
|
|
@ -30,12 +31,14 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
const system: string[] = []
|
||||
const submitted: string[] = []
|
||||
const confirmed: Probe['confirmed'] = []
|
||||
const paged: Probe['paged'] = []
|
||||
const quit = { value: false }
|
||||
const cleared = { value: false }
|
||||
const ctx: SlashContext = {
|
||||
clearTranscript: () => (cleared.value = true),
|
||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||
openPager: (title, text) => paged.push({ text, title }),
|
||||
pushSystem: text => system.push(text),
|
||||
quit: () => (quit.value = true),
|
||||
request: (method, params) => {
|
||||
|
|
@ -45,7 +48,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
sessionId: () => 'sid-1',
|
||||
submit: text => submitted.push(text)
|
||||
}
|
||||
return { calls, cleared, confirmed, ctx, quit, submitted, system }
|
||||
return { calls, cleared, confirmed, ctx, paged, quit, submitted, system }
|
||||
}
|
||||
|
||||
describe('dispatchSlash — client commands', () => {
|
||||
|
|
@ -65,10 +68,11 @@ describe('dispatchSlash — client commands', () => {
|
|||
expect(p.cleared.value).toBe(true)
|
||||
})
|
||||
|
||||
test('/logs prints the recent ring lines', async () => {
|
||||
test('/logs opens the pager with the recent ring lines', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/logs', p.ctx)
|
||||
expect(p.system.join('\n')).toContain('session created')
|
||||
expect(p.paged[0]?.title).toBe('Logs')
|
||||
expect(p.paged[0]?.text).toContain('session created')
|
||||
})
|
||||
|
||||
test('/help renders the gateway catalog', async () => {
|
||||
|
|
@ -82,11 +86,22 @@ describe('dispatchSlash — client commands', () => {
|
|||
})
|
||||
|
||||
describe('dispatchSlash — server ladder', () => {
|
||||
test('unknown command → slash.exec; output shown as a system line', async () => {
|
||||
test('unknown command → slash.exec; SHORT output shown as a system line', async () => {
|
||||
const p = makeCtx(async method => (method === 'slash.exec' ? { output: 'all good' } : {}))
|
||||
await dispatchSlash('/status', p.ctx)
|
||||
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
|
||||
expect(p.system).toContain('all good')
|
||||
expect(p.paged).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('LONG slash.exec output opens the pager (titled by command)', async () => {
|
||||
const longText = Array.from({ length: 6 }, (_, i) => `output line ${i}`).join('\n')
|
||||
const p = makeCtx(async method => (method === 'slash.exec' ? { output: longText } : {}))
|
||||
await dispatchSlash('/status', p.ctx)
|
||||
expect(p.paged).toHaveLength(1)
|
||||
expect(p.paged[0]?.title).toBe('Status')
|
||||
expect(p.paged[0]?.text).toContain('output line 5')
|
||||
expect(p.system).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('slash.exec rejects → command.dispatch; send result submits a user turn', async () => {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,25 @@
|
|||
/**
|
||||
* App — the Solid view shell (spec v4 §2 `view/App.tsx`). Header + scrolling
|
||||
* transcript + an input zone that swaps the composer for a blocking-prompt
|
||||
* App — the Solid view shell (spec v4 §2 `view/App.tsx`). Header + a content zone
|
||||
* that is either the PAGER overlay (long slash output) or the normal
|
||||
* transcript + input zone; the input zone swaps the composer for a blocking-prompt
|
||||
* overlay when one is active. Fully themed via the ThemeProvider (§7.5).
|
||||
*
|
||||
* header flexShrink:0 (top chrome line)
|
||||
* content flexGrow:1, minHeight:0 — Pager OR (transcript + input zone)
|
||||
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
|
||||
* input zone flexShrink:0 (Composer, OR PromptOverlay when blocked)
|
||||
*
|
||||
* When `store.state.prompt` is set the composer is REPLACED by the prompt overlay
|
||||
* (so the composer's textarea no longer captures keys, and the prompt's
|
||||
* select/input/masked-buffer owns input) — the §8 #6 deadlock fix. `onSubmit`/
|
||||
* `onRespond`/`sessionId` are wired by the entry (Effect boundary); all optional
|
||||
* so headless frame tests can mount the shell without a gateway.
|
||||
* Overlays REPLACE rather than stack: a blocking prompt replaces the composer
|
||||
* (§8 #6 deadlock fix); the pager replaces transcript+composer. Replacing (not
|
||||
* hiding) means the composer remounts + refocuses when an overlay closes, and the
|
||||
* key that closed the overlay can't leak into it (the close is deferred a tick).
|
||||
*/
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { Composer } from './composer.tsx'
|
||||
import { Header } from './header.tsx'
|
||||
import { Pager } from './overlays/pager.tsx'
|
||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||
import { Transcript } from './transcript.tsx'
|
||||
|
||||
|
|
@ -34,16 +36,30 @@ const NO_SESSION = () => undefined
|
|||
|
||||
export function App(props: AppProps) {
|
||||
const blocked = () => props.store.state.prompt !== undefined
|
||||
const pager = () => props.store.state.pager
|
||||
// Defer the close so the key that closed the overlay (Esc/q) can't land in the
|
||||
// freshly-remounted composer.
|
||||
const closePager = () => setTimeout(() => props.store.closePager(), 0)
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
|
||||
<Header store={props.store} />
|
||||
<Transcript store={props.store} />
|
||||
<Show when={blocked()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
|
||||
<PromptOverlay
|
||||
store={props.store}
|
||||
onRespond={props.onRespond ?? NOOP_RESPOND}
|
||||
sessionId={props.sessionId ?? NO_SESSION}
|
||||
/>
|
||||
<Show
|
||||
when={pager()}
|
||||
fallback={
|
||||
<>
|
||||
<Transcript store={props.store} />
|
||||
<Show when={blocked()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
|
||||
<PromptOverlay
|
||||
store={props.store}
|
||||
onRespond={props.onRespond ?? NOOP_RESPOND}
|
||||
sessionId={props.sessionId ?? NO_SESSION}
|
||||
/>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{p => <Pager title={p().title} text={p().text} onClose={closePager} />}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
55
ui-tui-opentui-v2/src/view/overlays/pager.tsx
Normal file
55
ui-tui-opentui-v2/src/view/overlays/pager.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Pager — a full-height scrollable text viewer (spec §2b `FloatBox` pager).
|
||||
* Porting it unlocks the long-output slash commands (/status /logs /history
|
||||
* /tools) at once. Replaces the transcript+composer while open (the App swaps it
|
||||
* in on `store.state.pager`).
|
||||
*
|
||||
* Scrolling is driven explicitly via `useKeyboard` → `scrollBy`/`scrollTo` (no
|
||||
* reliance on scrollbox auto-focus); Esc/q/Ctrl+C close. Carries the §8 #2
|
||||
* scrollbox gotchas (minHeight:0 wrapper+box, NO flexDirection on the box root).
|
||||
*/
|
||||
import { type ScrollBoxRenderable } from '@opentui/core'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { For } from 'solid-js'
|
||||
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
const PAGE = 10
|
||||
|
||||
export function Pager(props: { title: string; text: string; onClose: () => void }) {
|
||||
const theme = useTheme()
|
||||
let box: ScrollBoxRenderable | undefined
|
||||
const lines = () => props.text.split('\n')
|
||||
|
||||
useKeyboard(key => {
|
||||
if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
if (!box) return
|
||||
if (key.name === 'up') box.scrollBy(-1)
|
||||
else if (key.name === 'down') box.scrollBy(1)
|
||||
else if (key.name === 'pageup') box.scrollBy(-PAGE)
|
||||
else if (key.name === 'pagedown') box.scrollBy(PAGE)
|
||||
else if (key.name === 'home') box.scrollTo(0)
|
||||
else if (key.name === 'end') box.scrollTo({ x: 0, y: box.scrollHeight })
|
||||
})
|
||||
|
||||
return (
|
||||
<box style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }} border>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>{props.title}</b>
|
||||
</text>
|
||||
</box>
|
||||
<box style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<scrollbox ref={el => (box = el)} style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<For each={lines()}>{line => <text fg={theme().color.text}>{line}</text>}</For>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.muted}>Esc/q close · ↑↓/PgUp/PgDn/Home/End scroll</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue