opentui(v6): port utility commands — compact, details, replay, heapdump, mem

This commit is contained in:
alt-glitch 2026-06-10 22:09:39 +05:30
parent 73b261b94f
commit c007d08419
14 changed files with 1157 additions and 22 deletions

View file

@ -67,6 +67,19 @@ const READY_TIMEOUT_MS = 20_000
/** Window after a Ctrl+C in which a second Ctrl+C quits the TUI (item 11). */
const QUIT_WINDOW_MS = 3_000
/** Recursive renderable count under a node (the /mem store-cap diagnostic
* same walk as scripts/mem-bench.tsx; cheap: one tree pass on demand). */
function descendantCount(node: { getChildren(): unknown[] }): number {
let n = 0
for (const child of node.getChildren()) {
n += 1
if (child && typeof child === 'object' && 'getChildren' in child) {
n += descendantCount(child as { getChildren(): unknown[] })
}
}
return n
}
/**
* Resume a session INTO the store: buffer live events across the `session.resume`
* RPC, then replace history + replay (gotcha §8 #5 tool rows handled by
@ -366,6 +379,17 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
// Promise-returning `request` + the host capabilities it needs).
const slashCtx: SlashContext = {
clearTranscript: () => store.clearTranscript(),
compact: () => store.state.compact,
setCompact: on => store.setCompact(on),
details: () => store.state.details,
setDetails: mode => store.setDetails(mode),
renderableCount: () => {
try {
return descendantCount(renderer.root)
} catch {
return undefined
}
},
confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
copyResponse: n => {
const text = nthAssistantResponse(store.state.messages, n)

View file

@ -0,0 +1,84 @@
/**
* Global detail-mode logic (/details Epic 3 utility-command port; mirrors Ink
* `domain/details.ts`, GLOBAL mode only per-section overrides are explicitly
* deferred). The mode drives how the transcript treats tool + reasoning rows:
*
* - `collapsed` (default): today's behaviour headers with click-to-expand.
* - `expanded`: tool bodies + settled reasoning previews default-OPEN.
* - `hidden`: tool/reasoning runs reduce to ONE muted `⚡ N tools hidden`-style
* line per run (never silently dropped flipping the mode back restores,
* since the parts stay in the store untouched).
*
* Pure data + functions; the store carries the flag, messageLine/toolPart/
* reasoningPart read it via the display context.
*/
import type { Part } from './store.ts'
export type DetailsMode = 'hidden' | 'collapsed' | 'expanded'
/** Cycle order (Ink parity: hidden → collapsed → expanded → hidden). */
export const DETAILS_MODES = ['hidden', 'collapsed', 'expanded'] as const
/** Gateway `complete.slash` suggests these per-section names after `/details `
* recognized so picking one yields an honest "not supported yet" notice instead
* of the generic usage line (per-section overrides are deferred). */
export const DETAILS_SECTIONS = ['thinking', 'tools', 'subagents', 'activity'] as const
export const DETAILS_USAGE = 'usage: /details [hidden|collapsed|expanded|cycle]'
/** Parse a mode word; null for anything unrecognized (non-strings included). */
export function parseDetailsMode(v: unknown): DetailsMode | null {
if (typeof v !== 'string') return null
const norm = v.trim().toLowerCase()
return DETAILS_MODES.find(m => m === norm) ?? null
}
/** The next mode in the cycle (`/details cycle`). */
export function nextDetailsMode(m: DetailsMode): DetailsMode {
return DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length] ?? 'collapsed'
}
/** One collapsed RUN of consecutive tool/reasoning parts (hidden mode). */
export interface HiddenRun {
type: 'hiddenRun'
/** Stable-ish key: the first hidden part's id. */
id: string
tools: number
thoughts: number
}
/** What the transcript renders per part slot: a real part, or a hidden-run marker. */
export type DisplayPart = Part | HiddenRun
/**
* Hidden mode: keep text parts, fold each consecutive run of tool/reasoning
* parts into ONE HiddenRun marker (so a 5-tool fan-out reads as a single muted
* line, not 5 of them). Pure the source parts are untouched, so switching
* the mode back restores everything.
*/
export function collapseHiddenParts(parts: readonly Part[]): DisplayPart[] {
const out: DisplayPart[] = []
let run: HiddenRun | undefined
for (const part of parts) {
if (part.type === 'text') {
run = undefined
out.push(part)
continue
}
if (!run) {
run = { id: `hidden-${part.id}`, thoughts: 0, tools: 0, type: 'hiddenRun' }
out.push(run)
}
if (part.type === 'tool') run.tools += 1
else run.thoughts += 1
}
return out
}
/** Muted one-liner for a hidden run: `2 tools · 1 thought hidden`. */
export function hiddenRunLabel(run: HiddenRun): string {
const segs: string[] = []
if (run.tools) segs.push(`${run.tools} tool${run.tools === 1 ? '' : 's'}`)
if (run.thoughts) segs.push(`${run.thoughts} thought${run.thoughts === 1 ? '' : 's'}`)
return `${segs.join(' · ')} hidden — /details collapsed to show`
}

View file

@ -0,0 +1,82 @@
/**
* Process diagnostics for the /mem + /heapdump utility commands (Epic 3 port;
* Ink ref `app/slash/commands/debug.ts` + `lib/memory.ts`). Pure formatters plus
* the one impure seam (`performHeapdump` `v8.writeHeapSnapshot`), kept out of
* slash.ts so the dispatcher stays plain and tests can mock `node:v8`.
*/
import { mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { writeHeapSnapshot } from 'node:v8'
/** `123456789` → `117.7 MB` (binary units, one decimal above bytes). */
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n < 0) return '0 B'
if (n < 1024) return `${Math.round(n)} B`
const units = ['KB', 'MB', 'GB', 'TB'] as const
let v = n
let i = -1
do {
v /= 1024
i += 1
} while (v >= 1024 && i < units.length - 1)
return `${v.toFixed(1)} ${units[i]}`
}
/** Where heap snapshots land: `$HERMES_HOME`/`~/.hermes` + `logs/opentui-heap-<ts>.heapsnapshot`. */
export function heapSnapshotPath(now = new Date()): string {
const home = process.env.HERMES_HOME?.trim() || join(homedir(), '.hermes')
const ts = now.toISOString().replace(/[:.]/g, '-')
return join(home, 'logs', `opentui-heap-${ts}.heapsnapshot`)
}
export interface HeapdumpResult {
path: string
before: { heapUsed: number; rss: number }
after: { heapUsed: number; rss: number }
}
/**
* Write a V8 heap snapshot (SYNCHRONOUS blocks the event loop while V8 walks
* the heap; that's inherent to writeHeapSnapshot) and report heap/rss before
* vs after. Throws on I/O failure the caller renders the error.
*/
export function performHeapdump(): HeapdumpResult {
const before = process.memoryUsage()
const path = heapSnapshotPath()
mkdirSync(dirname(path), { recursive: true })
const written = writeHeapSnapshot(path)
const after = process.memoryUsage()
return {
after: { heapUsed: after.heapUsed, rss: after.rss },
before: { heapUsed: before.heapUsed, rss: before.rss },
path: written
}
}
export interface MemSnapshot {
heapUsed: number
heapTotal: number
external: number
arrayBuffers: number
rss: number
}
/**
* The /mem system-line body (Ink's Memory panel as aligned rows). `renderables`
* is the mounted-renderable count under the live renderer root (the store-cap
* diagnostic) omitted when unavailable (e.g. no renderer in tests).
*/
export function memReport(usage: MemSnapshot, uptimeSeconds: number, renderables?: number): string {
const rows: Array<[string, string]> = [
['heap used', formatBytes(usage.heapUsed)],
['heap total', formatBytes(usage.heapTotal)],
['external', formatBytes(usage.external)],
['array buffers', formatBytes(usage.arrayBuffers)],
['rss', formatBytes(usage.rss)],
['uptime', `${Math.round(uptimeSeconds)}s`]
]
if (renderables !== undefined) rows.push(['renderables', String(renderables)])
const pad = Math.max(...rows.map(([k]) => k.length))
return ['memory', ...rows.map(([k, v]) => ` ${k.padEnd(pad)} ${v}`)].join('\n')
}

View file

@ -0,0 +1,126 @@
/**
* /replay spawn-tree inspector logic (Epic 3 port; Ink ref
* `app/slash/commands/ops.ts` /replay + `spawnHistoryStore.ts`). The gateway
* archives each completed delegation fan-out as a JSON snapshot
* (`spawn_tree.save`); these helpers read `spawn_tree.list` / `spawn_tree.load`
* payloads and format them as PAGER TEXT the native engine renders replays
* through the existing pager overlay instead of Ink's agents overlay.
*
* All readers are defensive (wire JSON is loose, snapshots cross versions).
*/
export interface SpawnTreeEntry {
path: string
label: string
count: number
/** Epoch SECONDS (gateway convention). */
finishedAt?: number
sessionId?: string
}
function str(v: unknown): string | undefined {
return typeof v === 'string' && v ? v : undefined
}
function num(v: unknown): number | undefined {
return typeof v === 'number' && Number.isFinite(v) ? v : undefined
}
/** Map a `spawn_tree.list` result ({entries:[…]}) into typed rows (pathless rows dropped). */
export function readSpawnTreeEntries(result: unknown): SpawnTreeEntry[] {
if (!result || typeof result !== 'object') return []
const entries = (result as { entries?: unknown }).entries
if (!Array.isArray(entries)) return []
const out: SpawnTreeEntry[] = []
for (const e of entries) {
if (!e || typeof e !== 'object') continue
const o = e as { [k: string]: unknown }
const path = str(o['path'])
if (!path) continue
const entry: SpawnTreeEntry = {
count: num(o['count']) ?? 0,
label: str(o['label']) ?? '',
path
}
const finishedAt = num(o['finished_at'])
if (finishedAt !== undefined) entry.finishedAt = finishedAt
const sessionId = str(o['session_id'])
if (sessionId !== undefined) entry.sessionId = sessionId
out.push(entry)
}
return out
}
function fmtWhen(epochSeconds: number | undefined): string {
if (epochSeconds === undefined) return '?'
try {
return new Date(epochSeconds * 1000).toLocaleString()
} catch {
return '?'
}
}
/** The bare `/replay` listing: indexed rows the user replays by number. */
export function formatSpawnTreeList(entries: readonly SpawnTreeEntry[]): string {
const lines: string[] = ['Archived spawn trees — /replay <n> to view, /replay <path> for any snapshot', '']
entries.forEach((e, i) => {
const label = e.label || `${e.count} subagent${e.count === 1 ? '' : 's'}`
lines.push(`${String(i + 1).padStart(3)}. ${fmtWhen(e.finishedAt)} · ${e.count}×${label}`)
lines.push(` ${e.path}`)
})
return lines.join('\n')
}
/** Status glyph for an archived subagent row. */
function statusGlyph(status: string): string {
if (status === 'completed') return '✓'
if (status === 'error' || status === 'failed' || status === 'timeout') return '✗'
if (status === 'interrupted') return '⏹'
return '●'
}
/** One archived subagent → its pager lines (indented by spawn depth). */
function subagentLines(raw: unknown, index: number): string[] {
const o = (raw && typeof raw === 'object' ? raw : {}) as { [k: string]: unknown }
const depth = num(o['depth']) ?? 0
const pad = ' '.repeat(Math.max(0, depth))
const status = str(o['status']) ?? 'completed'
const goal = str(o['goal']) ?? 'subagent'
const lines = [`${pad}${statusGlyph(status)} [${index + 1}] ${goal}`]
const meta: string[] = [status]
const model = str(o['model'])
if (model) meta.push(model)
const duration = num(o['durationSeconds'])
if (duration !== undefined) meta.push(`${Math.round(duration)}s`)
const tools = num(o['toolCount'])
if (tools) meta.push(`${tools} tool${tools === 1 ? '' : 's'}`)
const tokIn = num(o['inputTokens'])
const tokOut = num(o['outputTokens'])
if (tokIn !== undefined || tokOut !== undefined) meta.push(`${tokIn ?? 0} in / ${tokOut ?? 0} out tok`)
lines.push(`${pad} ${meta.join(' · ')}`)
const summary = str(o['summary'])
if (summary) for (const s of summary.split('\n')) lines.push(`${pad} ${s}`)
const notes = o['notes']
if (Array.isArray(notes)) {
for (const note of notes) if (typeof note === 'string' && note) lines.push(`${pad} · ${note}`)
}
return lines
}
/** A loaded snapshot (`spawn_tree.load` payload) → the full pager text. */
export function formatSpawnTree(payload: unknown): string {
const o = (payload && typeof payload === 'object' ? payload : {}) as { [k: string]: unknown }
const subagents = Array.isArray(o['subagents']) ? (o['subagents'] as unknown[]) : []
const header: string[] = []
const label = str(o['label'])
header.push(label ?? 'spawn tree')
const meta: string[] = []
const sessionId = str(o['session_id'])
if (sessionId) meta.push(`session ${sessionId}`)
meta.push(`finished ${fmtWhen(num(o['finished_at']))}`)
meta.push(`${subagents.length} subagent${subagents.length === 1 ? '' : 's'}`)
header.push(meta.join(' · '))
if (!subagents.length) return [...header, '', '(snapshot empty or unreadable)'].join('\n')
const body = subagents.flatMap((s, i) => ['', ...subagentLines(s, i)])
return [...header, ...body].join('\n')
}

View file

@ -11,6 +11,9 @@
* (exec/plugin system · alias re-dispatch · skill/send submit a turn ·
* prefill notice). Long output routes to the pager (Phase 5a).
*/
import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts'
import { formatBytes, memReport, performHeapdump } from './diagnostics.ts'
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts'
import type { CompletionItem, PickerItem, PickerState, SessionItem } from './store.ts'
export interface ParsedSlash {
@ -57,6 +60,15 @@ export interface SlashContext {
readonly modelItems: () => PickerItem[] | undefined
/** Update the cached `/model` picker rows. */
readonly setModelItems: (items: PickerItem[]) => void
/** Read / set the compact-transcript display flag (/compact — Epic 3). */
readonly compact: () => boolean
readonly setCompact: (on: boolean) => void
/** Read / set the global tool/reasoning detail mode (/details — Epic 3). */
readonly details: () => DetailsMode
readonly setDetails: (mode: DetailsMode) => void
/** Mounted-renderable count under the live renderer root (a /mem diagnostic);
* undefined when no renderer is reachable (tests). */
readonly renderableCount: () => number | undefined
}
function readStr(value: unknown, key: string): string | undefined {
@ -143,6 +155,11 @@ const CLIENT_HELP = [
'/skills — browse skills',
'/sessions, /resume — switch/resume a session',
'/clear, /new — clear the transcript (confirm)',
'/compact [on|off|toggle] — compact transcript spacing',
'/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail',
'/replay [n|path] — inspect an archived spawn tree',
'/mem — live memory stats',
'/heapdump — write a V8 heap snapshot',
'/logs — recent engine log lines',
'/quit, /exit — quit',
'(other /commands run on the gateway)'
@ -337,6 +354,137 @@ const skillsCmd: ClientHandler = async (_arg, ctx) => {
})
}
/** `on`/`off`/`toggle`/bare → the next flag value; null on garbage (Ink flagFromArg). */
function flagFromArg(arg: string, current: boolean): boolean | null {
const mode = arg.trim().toLowerCase()
if (!mode || mode === 'toggle') return !current
if (mode === 'on') return true
if (mode === 'off') return false
return null
}
/** `/compact [on|off|toggle]` compact transcript spacing. The flag flips locally
* (the store drives the render); persistence mirrors Ink: a fire-and-forget
* `config.set {key:'compact'}` so the Ink TUI + future launches share the pref
* (the gateway does NOT send the persisted value to this TUI, so each launch
* starts off see store.ts `compact`). */
const compactCmd: ClientHandler = (arg, ctx) => {
const next = flagFromArg(arg, ctx.compact())
if (next === null) {
ctx.pushSystem('usage: /compact [on|off|toggle]')
return
}
ctx.setCompact(next)
void ctx.request('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
ctx.pushSystem(`compact ${next ? 'on' : 'off'}`)
}
/**
* `/details [hidden|collapsed|expanded|cycle]` GLOBAL detail mode (per-section
* overrides deferred; the gateway's arg completion also suggests section names,
* so those get an honest "not supported yet" notice). Bare `/details` reports the
* persisted mode (`config.get details_mode`) and syncs the local flag to it; a
* mode set persists via `config.set` (fire-and-forget, Ink parity).
*/
const detailsCmd: ClientHandler = async (arg, ctx) => {
const first = arg.trim().toLowerCase().split(/\s+/)[0] ?? ''
if (!first) {
try {
const r = await ctx.request('config.get', { key: 'details_mode' })
const mode = parseDetailsMode(readStr(r, 'value')) ?? ctx.details()
ctx.setDetails(mode)
ctx.pushSystem(`details: ${mode}`)
} catch {
ctx.pushSystem(`details: ${ctx.details()}`)
}
return
}
if ((DETAILS_SECTIONS as readonly string[]).includes(first)) {
ctx.pushSystem(`per-section detail overrides are not supported in the native engine yet — ${DETAILS_USAGE}`)
return
}
const next = first === 'cycle' || first === 'toggle' ? nextDetailsMode(ctx.details()) : parseDetailsMode(first)
if (!next) {
ctx.pushSystem(DETAILS_USAGE)
return
}
ctx.setDetails(next)
void ctx.request('config.set', { key: 'details_mode', value: next }).catch(() => {})
ctx.pushSystem(`details: ${next}`)
}
/** Fetch + map the session's archived spawn trees (`spawn_tree.list`). */
async function listSpawnTrees(ctx: SlashContext) {
const r = await ctx.request('spawn_tree.list', { limit: 30, session_id: ctx.sessionId() ?? 'default' })
return readSpawnTreeEntries(r)
}
/**
* `/replay [n|path]` spawn-tree inspector through the pager (Ink renders these
* in its agents overlay; the flow + RPCs are the same): bare lists the archived
* trees with indices, `<n>` loads the n-th listed tree, anything else is treated
* as a snapshot path on disk (`load <path>` accepted for Ink muscle memory).
*/
const replayCmd: ClientHandler = async (arg, ctx) => {
const raw = arg.trim()
const lower = raw.toLowerCase()
try {
if (!raw || lower === 'list' || lower === 'ls') {
const entries = await listSpawnTrees(ctx)
if (!entries.length) {
ctx.pushSystem('no archived spawn trees for this session — completed delegations are archived automatically')
return
}
ctx.openPager('Spawn trees', formatSpawnTreeList(entries))
return
}
if (/^\d+$/.test(raw)) {
const n = Number.parseInt(raw, 10)
const entries = await listSpawnTrees(ctx)
const entry = entries[n - 1]
if (!entry) {
ctx.pushSystem(
entries.length
? `replay: index out of range 1..${entries.length} — /replay to list`
: 'no archived spawn trees for this session'
)
return
}
const tree = await ctx.request('spawn_tree.load', { path: entry.path })
ctx.openPager(`Replay ${n}`, formatSpawnTree(tree))
return
}
const path = lower.startsWith('load ') ? raw.slice(5).trim() : raw
const tree = await ctx.request('spawn_tree.load', { path })
ctx.openPager('Replay', formatSpawnTree(tree))
} catch (error) {
ctx.pushSystem(`/replay: ${error instanceof Error ? error.message : 'failed'}`)
}
}
/** `/heapdump` write a V8 heap snapshot to `$HERMES_HOME|~/.hermes/logs/` and
* report the path + heap/rss before vs after (Ink ref debug.ts /heapdump). */
const heapdumpCmd: ClientHandler = (_arg, ctx) => {
const pre = process.memoryUsage()
ctx.pushSystem(`writing heap dump (heap ${formatBytes(pre.heapUsed)} · rss ${formatBytes(pre.rss)})…`)
try {
const { after, before, path } = performHeapdump()
ctx.pushSystem(
`heapdump: ${path}\n` +
`heap ${formatBytes(before.heapUsed)}${formatBytes(after.heapUsed)} · ` +
`rss ${formatBytes(before.rss)}${formatBytes(after.rss)}`
)
} catch (error) {
ctx.pushSystem(`heapdump failed: ${error instanceof Error ? error.message : String(error)}`)
}
}
/** `/mem` live V8 heap/rss numbers + uptime + the mounted-renderable count
* (the store-cap diagnostic) as one system block (Ink ref debug.ts /mem). */
const memCmd: ClientHandler = (_arg, ctx) => {
ctx.pushSystem(memReport(process.memoryUsage(), process.uptime(), ctx.renderableCount()))
}
/** `/tools` — fetch the tool roster from the gateway and show it in the pager (navigable). */
const toolsCmd: ClientHandler = async (arg, ctx) => {
const command = arg.trim() ? `tools ${arg.trim()}` : 'tools'
@ -352,12 +500,18 @@ const toolsCmd: ClientHandler = async (arg, ctx) => {
const CLIENT: Record<string, ClientHandler> = {
agents: (_arg, ctx) => ctx.openDashboard(),
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
compact: compactCmd,
copy: (arg, ctx) => {
const n = Math.max(1, Number.parseInt(arg, 10) || 1)
if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.')
},
detail: detailsCmd,
details: detailsCmd,
exit: (_arg, ctx) => ctx.quit(),
heapdump: heapdumpCmd,
mem: memCmd,
model: modelCmd,
replay: replayCmd,
resume: openSwitcher,
session: openSwitcher,
sessions: openSwitcher,
@ -379,6 +533,11 @@ const CLIENT: Record<string, ClientHandler> = {
quit: (_arg, ctx) => ctx.quit()
}
/** The registered client-command names (catalog introspection — tests/menus). */
export function clientCommandNames(): string[] {
return Object.keys(CLIENT).sort()
}
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
* (tui_gateway/server.py `commands.catalog`). */

View file

@ -21,6 +21,7 @@ import {
type CatalogDecoded,
type SessionInfoPatchDecoded
} from '../boundary/schema/SessionInfo.ts'
import type { DetailsMode } from './details.ts'
import { diffStats, type DiffStats } from './diff.ts'
import { envOutputLinesSet } from './env.ts'
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
@ -242,6 +243,14 @@ export interface StoreState {
modelItems: PickerItem[] | undefined
/** The current session id (shown in the home panel; updated on create/resume). */
sessionId: string | undefined
// ── display flags (utility commands — Epic 3) ────────────────────────────
/** Compact transcript (/compact): collapses the blank line between turns/parts.
* Defaults OFF the persisted `display.tui_compact` config doesn't reach the
* TUI via session.info, so the flag starts false each launch. */
compact: boolean
/** Global tool/reasoning detail mode (/details): collapsed (default) /
* expanded (bodies default-open) / hidden (runs fold to one muted line). */
details: DetailsMode
}
const LRU_LIMIT = 1000
@ -399,7 +408,9 @@ export function createSessionStore() {
hint: undefined,
catalog: undefined,
modelItems: undefined,
sessionId: undefined
sessionId: undefined,
compact: false,
details: 'collapsed'
})
// Monotonic part id (stable `key` per part so a new tool part below a streaming
@ -586,6 +597,16 @@ export function createSessionStore() {
setState('hint', text)
}
/** /compact — set the compact-transcript display flag (Epic 3). */
function setCompact(on: boolean): void {
setState('compact', on)
}
/** /details — set the global tool/reasoning detail mode (Epic 3). */
function setDetails(mode: DetailsMode): void {
setState('details', mode)
}
/** Merge a session-info patch into the chrome state (status bar — item 14). */
function applyInfo(raw: { readonly [k: string]: unknown }): void {
const patch = readInfoPatch(raw)
@ -974,6 +995,8 @@ export function createSessionStore() {
clearCompletions,
applyInfo,
setHint,
setCompact,
setDetails,
openDashboard,
closeDashboard,
hydrate,

View file

@ -0,0 +1,137 @@
/**
* Display-mode frame tests (Epic 3: /compact + /details store flags render).
* Headless frames through the real App tree (store Transcript
* DisplayProvider messageLine/toolPart/reasoningPart):
* - details collapsed (default) vs expanded vs hidden on tool + reasoning rows,
* including that flipping hidden back RESTORES the rows (nothing dropped),
* - compact collapses the blank line between messages (frame line-distance).
*/
import { describe, expect, test } from 'vitest'
import { createSessionStore } from '../logic/store.ts'
import { App } from '../view/App.tsx'
import { ThemeProvider } from '../view/theme.tsx'
import { renderProbe, type RenderProbe } from './lib/render.ts'
type Store = ReturnType<typeof createSessionStore>
async function mountApp(store: Store, width = 80, height = 30): Promise<RenderProbe> {
return renderProbe(
() => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
),
{ height, width }
)
}
/** Seed one settled assistant turn: reasoning + a multi-line tool + answer text. */
function seedDetailedTurn(store: Store) {
store.apply({ type: 'gateway.ready' })
store.apply({ type: 'message.start' })
store.apply({ payload: { text: '**Plan**\n\nthink about the steps' }, type: 'reasoning.delta' })
store.apply({ payload: { context: 'ls -la', name: 'terminal', tool_id: 't1' }, type: 'tool.start' })
store.apply({
payload: {
args: { command: 'ls -la' },
duration_s: 0.3,
name: 'terminal',
result_text: 'alpha.txt\nbeta.txt\ngamma.txt',
tool_id: 't1'
},
type: 'tool.complete'
})
store.apply({ payload: { text: 'done listing' }, type: 'message.delta' })
store.apply({ type: 'message.complete' })
}
describe('/details — global detail mode drives default expansion (frame)', () => {
test('collapsed (default) → headers only; expanded → tool body + reasoning preview open', async () => {
const store = createSessionStore()
seedDetailedTurn(store)
const probe = await mountApp(store)
try {
// default: collapsed — tool body lines stay hidden, Thought folded.
// (Markdown BODY text never paints in the headless char frame — a known
// harness limitation, see render.test.tsx — so assertions stick to the
// plain-text renderables: tool output lines + the ▶/▼ headers.)
const collapsed = await probe.waitForFrame(f => f.includes('terminal'))
expect(collapsed).toContain('▶ Thought: Plan')
expect(collapsed).not.toContain('beta.txt')
// /details expanded → tool body + reasoning preview default-open (no clicks)
store.setDetails('expanded')
const expanded = await probe.waitForFrame(f => f.includes('beta.txt'))
expect(expanded).toContain('alpha.txt')
expect(expanded).toContain('▼ Thought: Plan')
// back to collapsed → bodies fold again
store.setDetails('collapsed')
const back = await probe.waitForFrame(f => !f.includes('beta.txt'))
expect(back).toContain('terminal')
expect(back).toContain('▶ Thought: Plan')
} finally {
probe.destroy()
}
})
test('hidden → one muted run line replaces the tool+reasoning rows; flipping back restores', async () => {
const store = createSessionStore()
seedDetailedTurn(store)
const probe = await mountApp(store)
try {
await probe.waitForFrame(f => f.includes('terminal'))
store.setDetails('hidden')
// reasoning + tool fold into ONE honest run line
const hidden = await probe.waitForFrame(f => f.includes('hidden'))
expect(hidden).toContain('⚡ 1 tool · 1 thought hidden — /details collapsed to show')
expect(hidden).not.toContain('terminal')
expect(hidden).not.toContain('Thought: Plan')
// the parts are still in the store (folding is render-only — recoverable)
expect((store.state.messages.at(-1)!.parts ?? []).map(p => p.type)).toEqual(['reasoning', 'tool', 'text'])
// restore — flipping the mode back brings the rows straight back
store.setDetails('collapsed')
const restored = await probe.waitForFrame(f => f.includes('terminal'))
expect(restored).toContain('▶ Thought: Plan')
expect(restored).not.toContain('hidden — /details')
} finally {
probe.destroy()
}
})
})
describe('/compact — transcript spacing (frame line-count)', () => {
test('compact on collapses the blank line between messages; off restores it', async () => {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.pushUser('alpha-line')
store.pushUser('beta-line')
const probe = await mountApp(store)
try {
const spaced = await probe.waitForFrame(f => f.includes('beta-line'))
const rows = spaced.split('\n')
const a = rows.findIndex(r => r.includes('alpha-line'))
const b = rows.findIndex(r => r.includes('beta-line'))
expect(a).toBeGreaterThanOrEqual(0)
expect(b - a).toBe(2) // one blank line between turns
store.setCompact(true)
await probe.settle()
const dense = probe.frame().split('\n')
const a2 = dense.findIndex(r => r.includes('alpha-line'))
const b2 = dense.findIndex(r => r.includes('beta-line'))
expect(b2 - a2).toBe(1) // adjacent rows — densified
store.setCompact(false)
await probe.settle()
const again = probe.frame().split('\n')
const a3 = again.findIndex(r => r.includes('alpha-line'))
const b3 = again.findIndex(r => r.includes('beta-line'))
expect(b3 - a3).toBe(2)
} finally {
probe.destroy()
}
})
})

View file

@ -4,6 +4,7 @@
*/
import { afterEach, describe, expect, test } from 'vitest'
import type { DetailsMode } from '../logic/details.ts'
import {
dispatchSlash,
mapCompletions,
@ -137,6 +138,9 @@ interface Probe {
copyN: { value: (n: number) => boolean }
/** The cached /model rows (Epic 7) — seed to simulate a prefetched catalog. */
modelCache: { value: PickerItem[] | undefined }
/** Display flags (/compact, /details — Epic 3). */
compactFlag: { value: boolean }
detailsFlag: { value: DetailsMode }
}
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
@ -153,8 +157,15 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
const copied: number[] = []
const copyN: Probe['copyN'] = { value: () => false }
const modelCache: Probe['modelCache'] = { value: undefined }
const compactFlag: Probe['compactFlag'] = { value: false }
const detailsFlag: Probe['detailsFlag'] = { value: 'collapsed' }
const ctx: SlashContext = {
clearTranscript: () => (cleared.value = true),
compact: () => compactFlag.value,
setCompact: on => (compactFlag.value = on),
details: () => detailsFlag.value,
setDetails: mode => (detailsFlag.value = mode),
renderableCount: () => undefined,
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
copyResponse: n => {
copied.push(n)
@ -180,11 +191,13 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
return {
calls,
cleared,
compactFlag,
confirmed,
copied,
copyN,
ctx,
dashboard,
detailsFlag,
modelCache,
paged,
pickers,

View file

@ -0,0 +1,427 @@
/**
* Utility slash commands (Epic 3 port: /compact /details /replay /heapdump /mem).
* Pure logic + dispatch tests against a fake SlashContext: catalog registration,
* arg parsing (incl. garbage usage lines), the store display-flag effects,
* replay RPC call shapes against a fake gateway, and the mem/heapdump system
* lines with node:v8 / process.memoryUsage mocked.
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
collapseHiddenParts,
hiddenRunLabel,
nextDetailsMode,
parseDetailsMode,
type DetailsMode
} from '../logic/details.ts'
import { formatBytes, heapSnapshotPath, memReport } from '../logic/diagnostics.ts'
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from '../logic/replay.ts'
import { clientCommandNames, dispatchSlash, type SlashContext } from '../logic/slash.ts'
import type { Part } from '../logic/store.ts'
// /heapdump must not write a REAL multi-MB snapshot per test run — stub the V8
// seam; the path/mkdir plumbing still runs for real (under a temp HERMES_HOME).
vi.mock('node:v8', () => ({ writeHeapSnapshot: vi.fn((path?: string) => path ?? 'unnamed.heapsnapshot') }))
interface Probe {
ctx: SlashContext
calls: Array<{ method: string; params: Record<string, unknown> }>
system: string[]
paged: Array<{ title: string; text: string }>
compactFlag: { value: boolean }
detailsFlag: { value: DetailsMode }
renderables: { value: number | undefined }
}
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
const calls: Probe['calls'] = []
const system: string[] = []
const paged: Probe['paged'] = []
const compactFlag = { value: false }
const detailsFlag: Probe['detailsFlag'] = { value: 'collapsed' }
const renderables: Probe['renderables'] = { value: undefined }
const ctx: SlashContext = {
clearTranscript: () => {},
compact: () => compactFlag.value,
setCompact: on => (compactFlag.value = on),
details: () => detailsFlag.value,
setDetails: mode => (detailsFlag.value = mode),
renderableCount: () => renderables.value,
confirm: () => {},
copyResponse: () => false,
listSessions: () => Promise.resolve([]),
logTail: () => [],
modelItems: () => undefined,
setModelItems: () => {},
openDashboard: () => {},
openPager: (title, text) => paged.push({ text, title }),
openPicker: () => {},
openSwitcher: () => {},
pushSystem: text => system.push(text),
quit: () => {},
request: (method, params) => {
calls.push({ method, params })
return request(method, params)
},
sessionId: () => 'sid-1',
submit: () => {}
}
return { calls, compactFlag, ctx, detailsFlag, paged, renderables, system }
}
/** Let the fire-and-forget config.set promise settle (it's detached). */
const tick = () => new Promise(r => setTimeout(r, 0))
describe('client command catalog (registration)', () => {
test('all five utility commands (and the /detail alias) are registered', () => {
const names = clientCommandNames()
for (const name of ['compact', 'details', 'detail', 'replay', 'heapdump', 'mem']) {
expect(names).toContain(name)
}
})
})
describe('/compact', () => {
test('bare /compact toggles on, persists via config.set, reports', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/compact', p.ctx)
expect(p.compactFlag.value).toBe(true)
expect(p.system).toEqual(['compact on'])
expect(p.calls).toEqual([{ method: 'config.set', params: { key: 'compact', value: 'on' } }])
})
test('/compact on|off|toggle set explicitly', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/compact on', p.ctx)
expect(p.compactFlag.value).toBe(true)
await dispatchSlash('/compact off', p.ctx)
expect(p.compactFlag.value).toBe(false)
expect(p.calls.at(-1)).toEqual({ method: 'config.set', params: { key: 'compact', value: 'off' } })
await dispatchSlash('/compact toggle', p.ctx)
expect(p.compactFlag.value).toBe(true)
expect(p.system).toEqual(['compact on', 'compact off', 'compact on'])
})
test('/compact garbage → usage line, no flag change, no RPC', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/compact sideways', p.ctx)
expect(p.system).toEqual(['usage: /compact [on|off|toggle]'])
expect(p.compactFlag.value).toBe(false)
expect(p.calls).toHaveLength(0)
})
test('a failing config.set never breaks the local toggle', async () => {
const p = makeCtx(async () => {
throw new Error('gateway down')
})
await dispatchSlash('/compact on', p.ctx)
await tick()
expect(p.compactFlag.value).toBe(true)
expect(p.system).toEqual(['compact on'])
})
})
describe('/details', () => {
test('sets each explicit mode + persists via config.set details_mode', async () => {
const p = makeCtx(async () => ({}))
for (const mode of ['expanded', 'hidden', 'collapsed'] as const) {
await dispatchSlash(`/details ${mode}`, p.ctx)
expect(p.detailsFlag.value).toBe(mode)
expect(p.calls.at(-1)).toEqual({ method: 'config.set', params: { key: 'details_mode', value: mode } })
}
expect(p.system).toEqual(['details: expanded', 'details: hidden', 'details: collapsed'])
})
test('/details cycle advances hidden → collapsed → expanded → hidden', async () => {
const p = makeCtx(async () => ({}))
expect(p.detailsFlag.value).toBe('collapsed')
await dispatchSlash('/details cycle', p.ctx)
expect(p.detailsFlag.value).toBe('expanded')
await dispatchSlash('/details cycle', p.ctx)
expect(p.detailsFlag.value).toBe('hidden')
await dispatchSlash('/details cycle', p.ctx)
expect(p.detailsFlag.value).toBe('collapsed')
})
test('/details garbage → usage line, nothing set', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/details loud', p.ctx)
expect(p.system).toEqual(['usage: /details [hidden|collapsed|expanded|cycle]'])
expect(p.detailsFlag.value).toBe('collapsed')
expect(p.calls).toHaveLength(0)
})
test('a gateway-suggested SECTION arg gets the honest deferred notice', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/details thinking expanded', p.ctx)
expect(p.system[0]).toContain('per-section detail overrides are not supported')
expect(p.detailsFlag.value).toBe('collapsed')
expect(p.calls).toHaveLength(0)
})
test('bare /details reads config.get and syncs the local flag', async () => {
const p = makeCtx(async method => (method === 'config.get' ? { value: 'expanded' } : {}))
await dispatchSlash('/details', p.ctx)
expect(p.calls[0]).toEqual({ method: 'config.get', params: { key: 'details_mode' } })
expect(p.detailsFlag.value).toBe('expanded')
expect(p.system).toEqual(['details: expanded'])
})
test('bare /details with config.get failing falls back to the live flag', async () => {
const p = makeCtx(async () => {
throw new Error('no config.get')
})
p.detailsFlag.value = 'hidden'
await dispatchSlash('/details', p.ctx)
expect(p.system).toEqual(['details: hidden'])
})
test('/detail (Ink alias) dispatches the same handler', async () => {
const p = makeCtx(async () => ({}))
await dispatchSlash('/detail expanded', p.ctx)
expect(p.detailsFlag.value).toBe('expanded')
})
})
const TREE_ENTRIES = {
entries: [
{ count: 3, finished_at: 1_760_000_000, label: 'fanout A', path: '/trees/sid-1/a.json', session_id: 'sid-1' },
{ count: 1, finished_at: 1_760_000_100, label: '', path: '/trees/sid-1/b.json', session_id: 'sid-1' }
]
}
const TREE_PAYLOAD = {
finished_at: 1_760_000_000,
label: 'fanout A',
session_id: 'sid-1',
subagents: [
{
depth: 0,
durationSeconds: 12.4,
goal: 'crunch the data',
inputTokens: 1200,
model: 'hermes-4-405b',
outputTokens: 300,
status: 'completed',
summary: 'crunched it',
toolCount: 3
},
{ depth: 1, goal: 'child probe', status: 'failed' }
]
}
describe('/replay (spawn-tree inspector)', () => {
test('bare /replay lists via spawn_tree.list and pages indexed rows', async () => {
const p = makeCtx(async method => (method === 'spawn_tree.list' ? TREE_ENTRIES : {}))
await dispatchSlash('/replay', p.ctx)
expect(p.calls).toEqual([{ method: 'spawn_tree.list', params: { limit: 30, session_id: 'sid-1' } }])
expect(p.paged).toHaveLength(1)
expect(p.paged[0]!.title).toBe('Spawn trees')
expect(p.paged[0]!.text).toContain('1. ')
expect(p.paged[0]!.text).toContain('fanout A')
expect(p.paged[0]!.text).toContain('/trees/sid-1/a.json')
// label-less rows fall back to the subagent count
expect(p.paged[0]!.text).toContain('1 subagent')
})
test('/replay <n> lists then loads the n-th entry by path and pages the tree', async () => {
const p = makeCtx(async method =>
method === 'spawn_tree.list' ? TREE_ENTRIES : method === 'spawn_tree.load' ? TREE_PAYLOAD : {}
)
await dispatchSlash('/replay 1', p.ctx)
expect(p.calls.map(c => c.method)).toEqual(['spawn_tree.list', 'spawn_tree.load'])
expect(p.calls[1]!.params).toEqual({ path: '/trees/sid-1/a.json' })
expect(p.paged[0]!.title).toBe('Replay 1')
expect(p.paged[0]!.text).toContain('✓ [1] crunch the data')
expect(p.paged[0]!.text).toContain('completed · hermes-4-405b · 12s · 3 tools · 1200 in / 300 out tok')
expect(p.paged[0]!.text).toContain('crunched it')
// depth-1 child is indented under its parent and flags the failure
expect(p.paged[0]!.text).toContain(' ✗ [2] child probe')
})
test('/replay with an out-of-range index reports the valid range', async () => {
const p = makeCtx(async method => (method === 'spawn_tree.list' ? TREE_ENTRIES : {}))
await dispatchSlash('/replay 99', p.ctx)
expect(p.system[0]).toContain('index out of range 1..2')
expect(p.calls.map(c => c.method)).toEqual(['spawn_tree.list'])
})
test('/replay <path> loads straight from disk (no list RPC)', async () => {
const p = makeCtx(async method => (method === 'spawn_tree.load' ? TREE_PAYLOAD : {}))
await dispatchSlash('/replay /trees/sid-1/a.json', p.ctx)
expect(p.calls).toEqual([{ method: 'spawn_tree.load', params: { path: '/trees/sid-1/a.json' } }])
expect(p.paged[0]!.title).toBe('Replay')
expect(p.paged[0]!.text).toContain('fanout A')
})
test('empty archive and RPC failures land as system notices', async () => {
const p = makeCtx(async () => ({ entries: [] }))
await dispatchSlash('/replay', p.ctx)
expect(p.system[0]).toContain('no archived spawn trees')
const p2 = makeCtx(async () => {
throw new Error('boom')
})
await dispatchSlash('/replay', p2.ctx)
expect(p2.system).toEqual(['/replay: boom'])
})
})
describe('/mem', () => {
afterEach(() => vi.restoreAllMocks())
test('prints heap/external/rss/uptime + the renderable count, no gateway RPC', async () => {
vi.spyOn(process, 'memoryUsage').mockReturnValue({
arrayBuffers: 1024,
external: 2 * 1024 * 1024,
heapTotal: 200 * 1024 * 1024,
heapUsed: 123_456_789,
rss: 456 * 1024 * 1024
} as NodeJS.MemoryUsage)
vi.spyOn(process, 'uptime').mockReturnValue(42.4)
const p = makeCtx(async () => ({}))
p.renderables.value = 321
await dispatchSlash('/mem', p.ctx)
expect(p.calls).toHaveLength(0)
const out = p.system[0]!
expect(out).toContain('heap used')
expect(out).toContain('117.7 MB')
expect(out).toContain('heap total')
expect(out).toContain('200.0 MB')
expect(out).toContain('external')
expect(out).toContain('array buffers')
expect(out).toContain('rss')
expect(out).toContain('456.0 MB')
expect(out).toContain('uptime')
expect(out).toContain('42s')
expect(out).toContain('renderables')
expect(out).toContain('321')
})
test('omits the renderables row when no renderer is reachable', async () => {
vi.spyOn(process, 'uptime').mockReturnValue(5)
const p = makeCtx(async () => ({}))
await dispatchSlash('/mem', p.ctx)
expect(p.system[0]).not.toContain('renderables')
})
})
describe('/heapdump', () => {
let home: string
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'hermes-heap-'))
process.env.HERMES_HOME = home
})
afterEach(() => {
delete process.env.HERMES_HOME
rmSync(home, { force: true, recursive: true })
vi.restoreAllMocks()
})
test('writes the snapshot under $HERMES_HOME/logs and reports before/after', async () => {
const v8 = await import('node:v8')
const p = makeCtx(async () => ({}))
await dispatchSlash('/heapdump', p.ctx)
expect(p.calls).toHaveLength(0)
expect(p.system).toHaveLength(2)
expect(p.system[0]).toContain('writing heap dump (heap ')
expect(p.system[1]).toContain(`heapdump: ${join(home, 'logs')}`)
expect(p.system[1]).toContain('.heapsnapshot')
expect(p.system[1]).toMatch(/heap .+ → .+ · rss .+ → .+/)
expect(vi.mocked(v8.writeHeapSnapshot)).toHaveBeenCalledTimes(1)
const arg = vi.mocked(v8.writeHeapSnapshot).mock.calls[0]![0] as string
expect(arg.startsWith(join(home, 'logs', 'opentui-heap-'))).toBe(true)
})
test('a write failure lands as a system error, not a crash', async () => {
const v8 = await import('node:v8')
vi.mocked(v8.writeHeapSnapshot).mockImplementationOnce(() => {
throw new Error('disk full')
})
const p = makeCtx(async () => ({}))
await dispatchSlash('/heapdump', p.ctx)
expect(p.system[1]).toBe('heapdump failed: disk full')
})
})
describe('details logic (pure)', () => {
test('parseDetailsMode + nextDetailsMode', () => {
expect(parseDetailsMode(' Expanded ')).toBe('expanded')
expect(parseDetailsMode('nope')).toBeNull()
expect(nextDetailsMode('hidden')).toBe('collapsed')
expect(nextDetailsMode('collapsed')).toBe('expanded')
expect(nextDetailsMode('expanded')).toBe('hidden')
})
test('collapseHiddenParts folds consecutive tool/reasoning runs; text passes through by reference', () => {
const parts: Part[] = [
{ id: 'p1', text: 'intro', type: 'text' },
{ id: 'p2', name: 'bash', state: 'complete', type: 'tool' },
{ id: 'p3', text: 'mull', type: 'reasoning' },
{ id: 'p4', name: 'read', state: 'complete', type: 'tool' },
{ id: 'p5', text: 'middle', type: 'text' },
{ id: 'p6', name: 'grep', state: 'running', type: 'tool' }
]
const out = collapseHiddenParts(parts)
expect(out.map(p => p.type)).toEqual(['text', 'hiddenRun', 'text', 'hiddenRun'])
expect(out[0]).toBe(parts[0]) // identity preserved → no remount of text parts
expect(out[1]).toMatchObject({ id: 'hidden-p2', thoughts: 1, tools: 2 })
expect(out[3]).toMatchObject({ thoughts: 0, tools: 1 })
})
test('hiddenRunLabel pluralizes honestly and points back to /details', () => {
expect(hiddenRunLabel({ id: 'h', thoughts: 0, tools: 3, type: 'hiddenRun' })).toBe(
'3 tools hidden — /details collapsed to show'
)
expect(hiddenRunLabel({ id: 'h', thoughts: 1, tools: 1, type: 'hiddenRun' })).toBe(
'1 tool · 1 thought hidden — /details collapsed to show'
)
})
})
describe('diagnostics + replay formatters (pure)', () => {
test('formatBytes', () => {
expect(formatBytes(512)).toBe('512 B')
expect(formatBytes(2048)).toBe('2.0 KB')
expect(formatBytes(123_456_789)).toBe('117.7 MB')
expect(formatBytes(-1)).toBe('0 B')
})
test('heapSnapshotPath prefers HERMES_HOME', () => {
process.env.HERMES_HOME = '/custom/home'
try {
const p = heapSnapshotPath(new Date('2026-06-10T12:00:00Z'))
expect(p).toBe('/custom/home/logs/opentui-heap-2026-06-10T12-00-00-000Z.heapsnapshot')
} finally {
delete process.env.HERMES_HOME
}
})
test('memReport without a renderable count omits the row', () => {
const text = memReport({ arrayBuffers: 0, external: 0, heapTotal: 1024, heapUsed: 512, rss: 2048 }, 9.6)
expect(text.split('\n')[0]).toBe('memory')
expect(text).toContain('uptime')
expect(text).toContain('10s')
expect(text).not.toContain('renderables')
})
test('readSpawnTreeEntries tolerates garbage', () => {
expect(readSpawnTreeEntries(null)).toEqual([])
expect(readSpawnTreeEntries({ entries: 'nope' })).toEqual([])
expect(readSpawnTreeEntries({ entries: [{ label: 'no path' }, 7, null] })).toEqual([])
expect(readSpawnTreeEntries({ entries: [{ count: 2, path: '/a.json' }] })).toEqual([
{ count: 2, label: '', path: '/a.json' }
])
})
test('formatSpawnTreeList indexes rows; formatSpawnTree handles an empty snapshot', () => {
const list = formatSpawnTreeList([{ count: 2, label: 'x', path: '/a.json' }])
expect(list).toContain(' 1. ')
expect(list).toContain('/a.json')
expect(formatSpawnTree({ subagents: [] })).toContain('(snapshot empty or unreadable)')
expect(formatSpawnTree('garbage')).toContain('(snapshot empty or unreadable)')
})
})

View file

@ -0,0 +1,31 @@
/**
* DisplayProvider exposes the transcript display flags (store `compact` +
* `details`, set by the /compact and /details utility commands) to deep view
* nodes without threading the store through every layer (same pattern as
* sessionInfo.tsx). Consumers: messageLine (compact spacing + hidden-mode part
* folding), toolPart + reasoningPart (expanded-mode default-open). The fallback
* accessor (no provider, e.g. a bare component test) is today's defaults:
* compact off, details collapsed.
*/
import { type Accessor, createContext, type JSX, useContext } from 'solid-js'
import type { DetailsMode } from '../logic/details.ts'
export interface DisplayFlags {
compact: boolean
details: DetailsMode
}
const DEFAULTS: DisplayFlags = { compact: false, details: 'collapsed' }
const DEFAULT_FLAGS: Accessor<DisplayFlags> = () => DEFAULTS
const Ctx = createContext<Accessor<DisplayFlags>>()
export function DisplayProvider(props: { flags: Accessor<DisplayFlags>; children: JSX.Element }) {
return <Ctx.Provider value={props.flags}>{props.children}</Ctx.Provider>
}
/** The live display flags accessor (compact off + collapsed when no provider). */
export function useDisplay(): Accessor<DisplayFlags> {
return useContext(Ctx) ?? DEFAULT_FLAGS
}

View file

@ -10,7 +10,9 @@
*/
import { For, Match, Show, Switch } from 'solid-js'
import { collapseHiddenParts, hiddenRunLabel } from '../logic/details.ts'
import type { Message } from '../logic/store.ts'
import { useDisplay } from './display.tsx'
import { Markdown } from './markdown.tsx'
import { ReasoningPart } from './reasoningPart.tsx'
import { useTheme } from './theme.tsx'
@ -20,6 +22,7 @@ const GUTTER = 2
export function MessageLine(props: { message: Message }) {
const theme = useTheme()
const display = useDisplay()
const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
// Role-distinct color IS the hierarchy (Ink model): the human's turn is tinted
@ -29,11 +32,15 @@ export function MessageLine(props: { message: Message }) {
const bodyFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text
const hasParts = () => (m().parts?.length ?? 0) > 0
// /details hidden: fold each run of tool/reasoning parts into ONE muted line
// (the parts stay in the store — flipping the mode back restores them).
const displayParts = () => (display().details === 'hidden' ? collapseHiddenParts(m().parts ?? []) : (m().parts ?? []))
return (
// One blank line above every turn so user / assistant / tool blocks read as
// distinct turns (item: spacing). The gold-vs-bright color split does the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}>
// distinct turns (item: spacing); /compact collapses it so long sessions
// read denser. The gold-vs-bright color split does the rest.
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: display().compact ? 0 : 1 }}>
<box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative exclude it from mouse selection (item 4).
Bold so the user `` / assistant `` turn boundaries pop (item 8). */}
@ -45,8 +52,9 @@ export function MessageLine(props: { message: Message }) {
</box>
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
reasoning / tool regardless of order or stream timing, so blank lines
don't pop in and out as parts are created/merged mid-stream. */}
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: 1 }}>
don't pop in and out as parts are created/merged mid-stream. /compact
drops the gap along with the per-turn margin above. */}
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: display().compact ? 0 : 1 }}>
<Show
when={m().role === 'assistant' && hasParts()}
fallback={
@ -71,13 +79,22 @@ export function MessageLine(props: { message: Message }) {
</Show>
}
>
<For each={m().parts ?? []}>
<For each={displayParts()}>
{part => (
<Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}>
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
</Match>
<Match when={part.type === 'hiddenRun' && part}>
{/* /details hidden the honest minimal render for a folded
tool/reasoning run; chrome, not copyable content. */}
{run => (
<text selectable={false}>
<span style={{ fg: theme().color.muted }}>{`${hiddenRunLabel(run())}`}</span>
</text>
)}
</Match>
<Match when={part.type === 'text' && part}>
{/* ONE stable native <markdown> fed the growing text in place (no
per-delta remount no scrollbar flicker, #2); it renders GFM

View file

@ -14,6 +14,7 @@
import { createMemo, createSignal, Show } from 'solid-js'
import type { ThemeColors } from '../logic/theme.ts'
import { useDisplay } from './display.tsx'
import { Markdown } from './markdown.tsx'
import { useScrollAnchor } from './scrollAnchor.tsx'
import { useTheme } from './theme.tsx'
@ -44,10 +45,12 @@ function reasoningSummary(text: string): { title?: string; body: string } {
export function ReasoningPart(props: { text: string; streaming?: boolean }) {
const theme = useTheme()
const anchor = useScrollAnchor()
const display = useDisplay()
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
// live → expanded so you see it think; settled → collapsed. Click overrides.
const expanded = () => override() ?? !!props.streaming
const toggle = () => anchor(() => setOverride(e => !(e ?? !!props.streaming)))
// live → expanded so you see it think; settled → collapsed, unless the global
// /details mode is `expanded` (previews default-open). Click overrides.
const expanded = () => override() ?? (!!props.streaming || display().details === 'expanded')
const toggle = () => anchor(() => setOverride(!expanded()))
const summary = createMemo(() => reasoningSummary(props.text))
const label = () => (props.streaming ? 'Thinking' : 'Thought')

View file

@ -22,6 +22,7 @@
import { type ToolPartState } from '../logic/store.ts'
import type { ThemeColors } from '../logic/theme.ts'
import { useDimensions } from './dimensions.tsx'
import { useDisplay } from './display.tsx'
import { createSignal, Show } from 'solid-js'
import { truncate } from '../logic/toolOutput.ts'
@ -90,8 +91,12 @@ export function ToolPart(props: { part: ToolPartState }) {
const dims = useDimensions()
const info = useSessionInfo() // session cwd for path-relativizing renderers
const anchor = useScrollAnchor()
const [expanded, setExpanded] = createSignal(false)
const toggle = () => anchor(() => setExpanded(e => !e))
const display = useDisplay()
// /details expanded → settled bodies default-OPEN; a manual click overrides
// either way (and a later global flip applies again to un-overridden parts).
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
const expanded = () => override() ?? display().details === 'expanded'
const toggle = () => anchor(() => setOverride(!expanded()))
// Per-tool renderer (re-dispatches if the name settles on tool.complete).
const renderer = () => rendererFor(props.part.name)

View file

@ -18,6 +18,7 @@ import type { ScrollBoxRenderable } from '@opentui/core'
import { createSignal, For, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts'
import { DisplayProvider } from './display.tsx'
import { HomeHint } from './homeHint.tsx'
import { MessageLine } from './messageLine.tsx'
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
@ -32,20 +33,23 @@ export function Transcript(props: { store: SessionStore }) {
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
<ScrollAnchorProvider scroll={scroll}>
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
<Show when={props.store.state.messages.length === 0}>
<HomeHint store={props.store} />
</Show>
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the
{/* display flags (/compact, /details — Epic 3) for the rows below */}
<DisplayProvider flags={() => ({ compact: props.store.state.compact, details: props.store.state.details })}>
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
<Show when={props.store.state.messages.length === 0}>
<HomeHint store={props.store} />
</Show>
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the
DISPLAY (never the model's context that lives on the gateway). Point to
the dashboard for the full transcript. selectable=false it's chrome,
excluded from copy/selection. */}
<Show when={dropped() > 0}>
<text selectable={false} style={{ fg: theme().color.muted }}>
{`${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
</text>
</Show>
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
<Show when={dropped() > 0}>
<text selectable={false} style={{ fg: theme().color.muted }}>
{`${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
</text>
</Show>
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
</DisplayProvider>
</ScrollAnchorProvider>
</scrollbox>
</box>