diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 1a48c796542..b10ac70a18f 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -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() diff --git a/ui-tui-opentui-v2/src/logic/slash.ts b/ui-tui-opentui-v2/src/logic/slash.ts index b8e3309d566..47233aa3f16 100644 --- a/ui-tui-opentui-v2/src/logic/slash.ts +++ b/ui-tui-opentui-v2/src/logic/slash.ts @@ -32,6 +32,8 @@ export interface SlashContext { readonly request: (method: string, params: Record) => Promise 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 = { 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): Promise180 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 }) diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 66b88364893..0865d47b8a8 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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, diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 517e174f298..d47843129f6 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -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( + () => ( + store.state.theme}> + + + ), + { 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 + }) }) diff --git a/ui-tui-opentui-v2/src/test/slash.test.ts b/ui-tui-opentui-v2/src/test/slash.test.ts index 586dcc39f69..e81ed7ef291 100644 --- a/ui-tui-opentui-v2/src/test/slash.test.ts +++ b/ui-tui-opentui-v2/src/test/slash.test.ts @@ -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) => 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) => 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 () => { diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index 9f607a18255..6cbefcf79cd 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -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 ; §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 (
- - }> - + + + }> + + + + } + > + {p => } ) diff --git a/ui-tui-opentui-v2/src/view/overlays/pager.tsx b/ui-tui-opentui-v2/src/view/overlays/pager.tsx new file mode 100644 index 00000000000..41e0b343302 --- /dev/null +++ b/ui-tui-opentui-v2/src/view/overlays/pager.tsx @@ -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 ( + + + + {props.title} + + + + (box = el)} style={{ flexGrow: 1, minHeight: 0 }}> + {line => {line}} + + + + Esc/q close · ↑↓/PgUp/PgDn/Home/End scroll + + + ) +}