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). */ /** Window after a Ctrl+C in which a second Ctrl+C quits the TUI (item 11). */
const QUIT_WINDOW_MS = 3_000 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` * 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 * 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). // Promise-returning `request` + the host capabilities it needs).
const slashCtx: SlashContext = { const slashCtx: SlashContext = {
clearTranscript: () => store.clearTranscript(), 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), confirm: (message, onConfirm) => store.setConfirm(message, onConfirm),
copyResponse: n => { copyResponse: n => {
const text = nthAssistantResponse(store.state.messages, 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 · * (exec/plugin system · alias re-dispatch · skill/send submit a turn ·
* prefill notice). Long output routes to the pager (Phase 5a). * 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' import type { CompletionItem, PickerItem, PickerState, SessionItem } from './store.ts'
export interface ParsedSlash { export interface ParsedSlash {
@ -57,6 +60,15 @@ export interface SlashContext {
readonly modelItems: () => PickerItem[] | undefined readonly modelItems: () => PickerItem[] | undefined
/** Update the cached `/model` picker rows. */ /** Update the cached `/model` picker rows. */
readonly setModelItems: (items: PickerItem[]) => void 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 { function readStr(value: unknown, key: string): string | undefined {
@ -143,6 +155,11 @@ const CLIENT_HELP = [
'/skills — browse skills', '/skills — browse skills',
'/sessions, /resume — switch/resume a session', '/sessions, /resume — switch/resume a session',
'/clear, /new — clear the transcript (confirm)', '/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', '/logs — recent engine log lines',
'/quit, /exit — quit', '/quit, /exit — quit',
'(other /commands run on the gateway)' '(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). */ /** `/tools` — fetch the tool roster from the gateway and show it in the pager (navigable). */
const toolsCmd: ClientHandler = async (arg, ctx) => { const toolsCmd: ClientHandler = async (arg, ctx) => {
const command = arg.trim() ? `tools ${arg.trim()}` : 'tools' const command = arg.trim() ? `tools ${arg.trim()}` : 'tools'
@ -352,12 +500,18 @@ const toolsCmd: ClientHandler = async (arg, ctx) => {
const CLIENT: Record<string, ClientHandler> = { const CLIENT: Record<string, ClientHandler> = {
agents: (_arg, ctx) => ctx.openDashboard(), agents: (_arg, ctx) => ctx.openDashboard(),
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript), clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
compact: compactCmd,
copy: (arg, ctx) => { copy: (arg, ctx) => {
const n = Math.max(1, Number.parseInt(arg, 10) || 1) const n = Math.max(1, Number.parseInt(arg, 10) || 1)
if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.') if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.')
}, },
detail: detailsCmd,
details: detailsCmd,
exit: (_arg, ctx) => ctx.quit(), exit: (_arg, ctx) => ctx.quit(),
heapdump: heapdumpCmd,
mem: memCmd,
model: modelCmd, model: modelCmd,
replay: replayCmd,
resume: openSwitcher, resume: openSwitcher,
session: openSwitcher, session: openSwitcher,
sessions: openSwitcher, sessions: openSwitcher,
@ -379,6 +533,11 @@ const CLIENT: Record<string, ClientHandler> = {
quit: (_arg, ctx) => ctx.quit() 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). /** Render the gateway `commands.catalog` into a help block (loose-typed read).
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }` * The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
* (tui_gateway/server.py `commands.catalog`). */ * (tui_gateway/server.py `commands.catalog`). */

View File

@ -21,6 +21,7 @@ import {
type CatalogDecoded, type CatalogDecoded,
type SessionInfoPatchDecoded type SessionInfoPatchDecoded
} from '../boundary/schema/SessionInfo.ts' } from '../boundary/schema/SessionInfo.ts'
import type { DetailsMode } from './details.ts'
import { diffStats, type DiffStats } from './diff.ts' import { diffStats, type DiffStats } from './diff.ts'
import { envOutputLinesSet } from './env.ts' import { envOutputLinesSet } from './env.ts'
import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts' import { stripAnsi, stripOmittedNote, stripToolEnvelope } from './toolOutput.ts'
@ -242,6 +243,14 @@ export interface StoreState {
modelItems: PickerItem[] | undefined modelItems: PickerItem[] | undefined
/** The current session id (shown in the home panel; updated on create/resume). */ /** The current session id (shown in the home panel; updated on create/resume). */
sessionId: string | undefined 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 const LRU_LIMIT = 1000
@ -399,7 +408,9 @@ export function createSessionStore() {
hint: undefined, hint: undefined,
catalog: undefined, catalog: undefined,
modelItems: 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 // 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) 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). */ /** Merge a session-info patch into the chrome state (status bar — item 14). */
function applyInfo(raw: { readonly [k: string]: unknown }): void { function applyInfo(raw: { readonly [k: string]: unknown }): void {
const patch = readInfoPatch(raw) const patch = readInfoPatch(raw)
@ -974,6 +995,8 @@ export function createSessionStore() {
clearCompletions, clearCompletions,
applyInfo, applyInfo,
setHint, setHint,
setCompact,
setDetails,
openDashboard, openDashboard,
closeDashboard, closeDashboard,
hydrate, 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 { afterEach, describe, expect, test } from 'vitest'
import type { DetailsMode } from '../logic/details.ts'
import { import {
dispatchSlash, dispatchSlash,
mapCompletions, mapCompletions,
@ -137,6 +138,9 @@ interface Probe {
copyN: { value: (n: number) => boolean } copyN: { value: (n: number) => boolean }
/** The cached /model rows (Epic 7) — seed to simulate a prefetched catalog. */ /** The cached /model rows (Epic 7) — seed to simulate a prefetched catalog. */
modelCache: { value: PickerItem[] | undefined } 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 { 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 copied: number[] = []
const copyN: Probe['copyN'] = { value: () => false } const copyN: Probe['copyN'] = { value: () => false }
const modelCache: Probe['modelCache'] = { value: undefined } const modelCache: Probe['modelCache'] = { value: undefined }
const compactFlag: Probe['compactFlag'] = { value: false }
const detailsFlag: Probe['detailsFlag'] = { value: 'collapsed' }
const ctx: SlashContext = { const ctx: SlashContext = {
clearTranscript: () => (cleared.value = true), 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 }), confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
copyResponse: n => { copyResponse: n => {
copied.push(n) copied.push(n)
@ -180,11 +191,13 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
return { return {
calls, calls,
cleared, cleared,
compactFlag,
confirmed, confirmed,
copied, copied,
copyN, copyN,
ctx, ctx,
dashboard, dashboard,
detailsFlag,
modelCache, modelCache,
paged, paged,
pickers, 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 { For, Match, Show, Switch } from 'solid-js'
import { collapseHiddenParts, hiddenRunLabel } from '../logic/details.ts'
import type { Message } from '../logic/store.ts' import type { Message } from '../logic/store.ts'
import { useDisplay } from './display.tsx'
import { Markdown } from './markdown.tsx' import { Markdown } from './markdown.tsx'
import { ReasoningPart } from './reasoningPart.tsx' import { ReasoningPart } from './reasoningPart.tsx'
import { useTheme } from './theme.tsx' import { useTheme } from './theme.tsx'
@ -20,6 +22,7 @@ const GUTTER = 2
export function MessageLine(props: { message: Message }) { export function MessageLine(props: { message: Message }) {
const theme = useTheme() const theme = useTheme()
const display = useDisplay()
const m = () => props.message const m = () => props.message
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·') 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 // 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 = () => const bodyFg = () =>
m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text
const hasParts = () => (m().parts?.length ?? 0) > 0 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 ( return (
// One blank line above every turn so user / assistant / tool blocks read as // 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. // distinct turns (item: spacing); /compact collapses it so long sessions
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}> // 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 }}> <box style={{ flexShrink: 0, width: GUTTER }}>
{/* the role glyph is decorative exclude it from mouse selection (item 4). {/* the role glyph is decorative exclude it from mouse selection (item 4).
Bold so the user `` / assistant `` turn boundaries pop (item 8). */} Bold so the user `` / assistant `` turn boundaries pop (item 8). */}
@ -45,8 +52,9 @@ export function MessageLine(props: { message: Message }) {
</box> </box>
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text / {/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
reasoning / tool regardless of order or stream timing, so blank lines reasoning / tool regardless of order or stream timing, so blank lines
don't pop in and out as parts are created/merged mid-stream. */} don't pop in and out as parts are created/merged mid-stream. /compact
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: 1 }}> drops the gap along with the per-turn margin above. */}
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: display().compact ? 0 : 1 }}>
<Show <Show
when={m().role === 'assistant' && hasParts()} when={m().role === 'assistant' && hasParts()}
fallback={ fallback={
@ -71,13 +79,22 @@ export function MessageLine(props: { message: Message }) {
</Show> </Show>
} }
> >
<For each={m().parts ?? []}> <For each={displayParts()}>
{part => ( {part => (
<Switch> <Switch>
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match> <Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
<Match when={part.type === 'reasoning' && part}> <Match when={part.type === 'reasoning' && part}>
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />} {r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
</Match> </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}> <Match when={part.type === 'text' && part}>
{/* ONE stable native <markdown> fed the growing text in place (no {/* ONE stable native <markdown> fed the growing text in place (no
per-delta remount no scrollbar flicker, #2); it renders GFM per-delta remount no scrollbar flicker, #2); it renders GFM

View File

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

View File

@ -22,6 +22,7 @@
import { type ToolPartState } from '../logic/store.ts' import { type ToolPartState } from '../logic/store.ts'
import type { ThemeColors } from '../logic/theme.ts' import type { ThemeColors } from '../logic/theme.ts'
import { useDimensions } from './dimensions.tsx' import { useDimensions } from './dimensions.tsx'
import { useDisplay } from './display.tsx'
import { createSignal, Show } from 'solid-js' import { createSignal, Show } from 'solid-js'
import { truncate } from '../logic/toolOutput.ts' import { truncate } from '../logic/toolOutput.ts'
@ -90,8 +91,12 @@ export function ToolPart(props: { part: ToolPartState }) {
const dims = useDimensions() const dims = useDimensions()
const info = useSessionInfo() // session cwd for path-relativizing renderers const info = useSessionInfo() // session cwd for path-relativizing renderers
const anchor = useScrollAnchor() const anchor = useScrollAnchor()
const [expanded, setExpanded] = createSignal(false) const display = useDisplay()
const toggle = () => anchor(() => setExpanded(e => !e)) // /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). // Per-tool renderer (re-dispatches if the name settles on tool.complete).
const renderer = () => rendererFor(props.part.name) 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 { createSignal, For, Show } from 'solid-js'
import type { SessionStore } from '../logic/store.ts' import type { SessionStore } from '../logic/store.ts'
import { DisplayProvider } from './display.tsx'
import { HomeHint } from './homeHint.tsx' import { HomeHint } from './homeHint.tsx'
import { MessageLine } from './messageLine.tsx' import { MessageLine } from './messageLine.tsx'
import { ScrollAnchorProvider } from './scrollAnchor.tsx' import { ScrollAnchorProvider } from './scrollAnchor.tsx'
@ -32,20 +33,23 @@ export function Transcript(props: { store: SessionStore }) {
<box style={{ flexGrow: 1, minHeight: 0 }}> <box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom"> <scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
<ScrollAnchorProvider scroll={scroll}> <ScrollAnchorProvider scroll={scroll}>
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */} {/* display flags (/compact, /details — Epic 3) for the rows below */}
<Show when={props.store.state.messages.length === 0}> <DisplayProvider flags={() => ({ compact: props.store.state.compact, details: props.store.state.details })}>
<HomeHint store={props.store} /> {/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
</Show> <Show when={props.store.state.messages.length === 0}>
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the <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 DISPLAY (never the model's context that lives on the gateway). Point to
the dashboard for the full transcript. selectable=false it's chrome, the dashboard for the full transcript. selectable=false it's chrome,
excluded from copy/selection. */} excluded from copy/selection. */}
<Show when={dropped() > 0}> <Show when={dropped() > 0}>
<text selectable={false} style={{ fg: theme().color.muted }}> <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()}` : ''}`} {`${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
</text> </text>
</Show> </Show>
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For> <For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
</DisplayProvider>
</ScrollAnchorProvider> </ScrollAnchorProvider>
</scrollbox> </scrollbox>
</box> </box>