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
@ -158,8 +158,9 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||||||
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
|
||||||
logTail: () =>
|
logTail: () =>
|
||||||
getLog()
|
getLog()
|
||||||
.tail(40)
|
.tail(200)
|
||||||
.map(e => `${e.scope}: ${e.msg}`),
|
.map(e => `${e.scope}: ${e.msg}`),
|
||||||
|
openPager: (title, text) => store.openPager(title, text),
|
||||||
pushSystem: text => store.pushSystem(text),
|
pushSystem: text => store.pushSystem(text),
|
||||||
quit: () => {
|
quit: () => {
|
||||||
if (!renderer.isDestroyed) renderer.destroy()
|
if (!renderer.isDestroyed) renderer.destroy()
|
||||||
|
|||||||
@ -32,6 +32,8 @@ export interface SlashContext {
|
|||||||
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
readonly request: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||||
readonly sessionId: () => string | undefined
|
readonly sessionId: () => string | undefined
|
||||||
readonly pushSystem: (text: string) => void
|
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). */
|
/** Submit a user turn (skill/send dispatch results). */
|
||||||
readonly submit: (text: string) => void
|
readonly submit: (text: string) => void
|
||||||
/** Open a local Y/N confirm; `onConfirm` runs on Yes. */
|
/** 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
|
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 = [
|
const CLIENT_HELP = [
|
||||||
'/help — list commands',
|
'/help — list commands',
|
||||||
'/clear, /new — clear the transcript (confirm)',
|
'/clear, /new — clear the transcript (confirm)',
|
||||||
@ -71,7 +82,7 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||||||
ctx.pushSystem(CLIENT_HELP)
|
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),
|
new: (_arg, ctx) => ctx.confirm('Start fresh? (clears the transcript)', ctx.clearTranscript),
|
||||||
quit: (_arg, ctx) => ctx.quit()
|
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 result = await ctx.request('slash.exec', { command: input.slice(1), session_id: sid })
|
||||||
const output = readStr(result, 'output') || `/${parsed.name}: no output`
|
const output = readStr(result, 'output') || `/${parsed.name}: no output`
|
||||||
const warning = readStr(result, 'warning')
|
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 {
|
} catch {
|
||||||
try {
|
try {
|
||||||
const raw = await ctx.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
|
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)
|
// local (non-gateway) Y/N confirm — e.g. /clear, /new (spec §2a)
|
||||||
| { kind: 'confirm'; message: string; onConfirm: () => void }
|
| { 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 {
|
export interface StoreState {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
theme: Theme
|
theme: Theme
|
||||||
/** The active blocking prompt (composer is hidden while set); undefined when none. */
|
/** The active blocking prompt (composer is hidden while set); undefined when none. */
|
||||||
prompt: ActivePrompt | undefined
|
prompt: ActivePrompt | undefined
|
||||||
|
/** The open pager overlay (replaces the transcript while set); undefined when none. */
|
||||||
|
pager: PagerState | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const LRU_LIMIT = 1000
|
const LRU_LIMIT = 1000
|
||||||
@ -79,7 +87,8 @@ export function createSessionStore() {
|
|||||||
ready: false,
|
ready: false,
|
||||||
messages: [],
|
messages: [],
|
||||||
theme: DEFAULT_THEME,
|
theme: DEFAULT_THEME,
|
||||||
prompt: undefined
|
prompt: undefined,
|
||||||
|
pager: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
// 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 })
|
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. */
|
/** Reduce a decoded gateway event into the store. The sole boundary->Solid sink. */
|
||||||
function apply(event: GatewayEvent): void {
|
function apply(event: GatewayEvent): void {
|
||||||
if (buffering) {
|
if (buffering) {
|
||||||
@ -335,6 +354,8 @@ export function createSessionStore() {
|
|||||||
pushSystem,
|
pushSystem,
|
||||||
clearTranscript,
|
clearTranscript,
|
||||||
setConfirm,
|
setConfirm,
|
||||||
|
openPager,
|
||||||
|
closePager,
|
||||||
hydrate,
|
hydrate,
|
||||||
beginBuffer,
|
beginBuffer,
|
||||||
commitSnapshot,
|
commitSnapshot,
|
||||||
|
|||||||
@ -103,4 +103,26 @@ describe('App render (Phase 1, themed)', () => {
|
|||||||
expect(frame).toContain('Deny')
|
expect(frame).toContain('Deny')
|
||||||
expect(frame).not.toContain('Type your message') // composer is hidden while blocked
|
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[]
|
system: string[]
|
||||||
submitted: string[]
|
submitted: string[]
|
||||||
confirmed: Array<{ message: string; onConfirm: () => void }>
|
confirmed: Array<{ message: string; onConfirm: () => void }>
|
||||||
|
paged: Array<{ title: string; text: string }>
|
||||||
quit: { value: boolean }
|
quit: { value: boolean }
|
||||||
cleared: { value: boolean }
|
cleared: { value: boolean }
|
||||||
}
|
}
|
||||||
@ -30,12 +31,14 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||||||
const system: string[] = []
|
const system: string[] = []
|
||||||
const submitted: string[] = []
|
const submitted: string[] = []
|
||||||
const confirmed: Probe['confirmed'] = []
|
const confirmed: Probe['confirmed'] = []
|
||||||
|
const paged: Probe['paged'] = []
|
||||||
const quit = { value: false }
|
const quit = { value: false }
|
||||||
const cleared = { value: false }
|
const cleared = { value: false }
|
||||||
const ctx: SlashContext = {
|
const ctx: SlashContext = {
|
||||||
clearTranscript: () => (cleared.value = true),
|
clearTranscript: () => (cleared.value = true),
|
||||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||||
|
openPager: (title, text) => paged.push({ text, title }),
|
||||||
pushSystem: text => system.push(text),
|
pushSystem: text => system.push(text),
|
||||||
quit: () => (quit.value = true),
|
quit: () => (quit.value = true),
|
||||||
request: (method, params) => {
|
request: (method, params) => {
|
||||||
@ -45,7 +48,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||||||
sessionId: () => 'sid-1',
|
sessionId: () => 'sid-1',
|
||||||
submit: text => submitted.push(text)
|
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', () => {
|
describe('dispatchSlash — client commands', () => {
|
||||||
@ -65,10 +68,11 @@ describe('dispatchSlash — client commands', () => {
|
|||||||
expect(p.cleared.value).toBe(true)
|
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 () => ({}))
|
const p = makeCtx(async () => ({}))
|
||||||
await dispatchSlash('/logs', p.ctx)
|
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 () => {
|
test('/help renders the gateway catalog', async () => {
|
||||||
@ -82,11 +86,22 @@ describe('dispatchSlash — client commands', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('dispatchSlash — server ladder', () => {
|
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' } : {}))
|
const p = makeCtx(async method => (method === 'slash.exec' ? { output: 'all good' } : {}))
|
||||||
await dispatchSlash('/status', p.ctx)
|
await dispatchSlash('/status', p.ctx)
|
||||||
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
|
expect(p.calls[0]).toEqual({ method: 'slash.exec', params: { command: 'status', session_id: 'sid-1' } })
|
||||||
expect(p.system).toContain('all good')
|
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 () => {
|
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
|
* App — the Solid view shell (spec v4 §2 `view/App.tsx`). Header + a content zone
|
||||||
* transcript + an input zone that swaps the composer for a blocking-prompt
|
* 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).
|
* overlay when one is active. Fully themed via the ThemeProvider (§7.5).
|
||||||
*
|
*
|
||||||
* header flexShrink:0 (top chrome line)
|
* 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)
|
* transcript flexGrow:1, minHeight:0 (the one <scrollbox>; §8 #2 gotchas)
|
||||||
* input zone flexShrink:0 (Composer, OR PromptOverlay when blocked)
|
* input zone flexShrink:0 (Composer, OR PromptOverlay when blocked)
|
||||||
*
|
*
|
||||||
* When `store.state.prompt` is set the composer is REPLACED by the prompt overlay
|
* Overlays REPLACE rather than stack: a blocking prompt replaces the composer
|
||||||
* (so the composer's textarea no longer captures keys, and the prompt's
|
* (§8 #6 deadlock fix); the pager replaces transcript+composer. Replacing (not
|
||||||
* select/input/masked-buffer owns input) — the §8 #6 deadlock fix. `onSubmit`/
|
* hiding) means the composer remounts + refocuses when an overlay closes, and the
|
||||||
* `onRespond`/`sessionId` are wired by the entry (Effect boundary); all optional
|
* key that closed the overlay can't leak into it (the close is deferred a tick).
|
||||||
* so headless frame tests can mount the shell without a gateway.
|
|
||||||
*/
|
*/
|
||||||
import { Show } from 'solid-js'
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
import type { SessionStore } from '../logic/store.ts'
|
import type { SessionStore } from '../logic/store.ts'
|
||||||
import { Composer } from './composer.tsx'
|
import { Composer } from './composer.tsx'
|
||||||
import { Header } from './header.tsx'
|
import { Header } from './header.tsx'
|
||||||
|
import { Pager } from './overlays/pager.tsx'
|
||||||
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
import { PromptOverlay } from './prompts/promptOverlay.tsx'
|
||||||
import { Transcript } from './transcript.tsx'
|
import { Transcript } from './transcript.tsx'
|
||||||
|
|
||||||
@ -34,9 +36,18 @@ const NO_SESSION = () => undefined
|
|||||||
|
|
||||||
export function App(props: AppProps) {
|
export function App(props: AppProps) {
|
||||||
const blocked = () => props.store.state.prompt !== undefined
|
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 (
|
return (
|
||||||
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
|
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
|
||||||
<Header store={props.store} />
|
<Header store={props.store} />
|
||||||
|
<Show
|
||||||
|
when={pager()}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
<Transcript store={props.store} />
|
<Transcript store={props.store} />
|
||||||
<Show when={blocked()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
|
<Show when={blocked()} fallback={<Composer onSubmit={props.onSubmit ?? NOOP} />}>
|
||||||
<PromptOverlay
|
<PromptOverlay
|
||||||
@ -45,6 +56,11 @@ export function App(props: AppProps) {
|
|||||||
sessionId={props.sessionId ?? NO_SESSION}
|
sessionId={props.sessionId ?? NO_SESSION}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p => <Pager title={p().title} text={p().text} onClose={closePager} />}
|
||||||
|
</Show>
|
||||||
</box>
|
</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…
x
Reference in New Issue
Block a user