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:
alt-glitch 2026-06-08 15:52:36 +00:00
parent c704d384f4
commit abce50e34d
7 changed files with 165 additions and 22 deletions

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View 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>
)
}