diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index 1ad2e4f18d0..6b9d709dc4d 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -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) diff --git a/ui-opentui/src/logic/details.ts b/ui-opentui/src/logic/details.ts new file mode 100644 index 00000000000..8b96e07dd99 --- /dev/null +++ b/ui-opentui/src/logic/details.ts @@ -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` +} diff --git a/ui-opentui/src/logic/diagnostics.ts b/ui-opentui/src/logic/diagnostics.ts new file mode 100644 index 00000000000..65af87d66cf --- /dev/null +++ b/ui-opentui/src/logic/diagnostics.ts @@ -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-.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') +} diff --git a/ui-opentui/src/logic/replay.ts b/ui-opentui/src/logic/replay.ts new file mode 100644 index 00000000000..af04e91c008 --- /dev/null +++ b/ui-opentui/src/logic/replay.ts @@ -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 to view, /replay 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') +} diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index 66c4f3bc07e..3e78c5d0126 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -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, `` loads the n-th listed tree, anything else is treated + * as a snapshot path on disk (`load ` 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 = { 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 = { 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`). */ diff --git a/ui-opentui/src/logic/store.ts b/ui-opentui/src/logic/store.ts index f76a9214b1b..5d031abd696 100644 --- a/ui-opentui/src/logic/store.ts +++ b/ui-opentui/src/logic/store.ts @@ -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, diff --git a/ui-opentui/src/test/displayModes.test.tsx b/ui-opentui/src/test/displayModes.test.tsx new file mode 100644 index 00000000000..f4fbf051092 --- /dev/null +++ b/ui-opentui/src/test/displayModes.test.tsx @@ -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 + +async function mountApp(store: Store, width = 80, height = 30): Promise { + return renderProbe( + () => ( + store.state.theme}> + + + ), + { 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() + } + }) +}) diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index 8212a800508..ad9d1c0ab7f 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -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) => Promise): Probe { @@ -153,8 +157,15 @@ function makeCtx(request: (method: string, params: Record) => 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) => P return { calls, cleared, + compactFlag, confirmed, copied, copyN, ctx, dashboard, + detailsFlag, modelCache, paged, pickers, diff --git a/ui-opentui/src/test/utilityCommands.test.ts b/ui-opentui/src/test/utilityCommands.test.ts new file mode 100644 index 00000000000..ce0d3fab370 --- /dev/null +++ b/ui-opentui/src/test/utilityCommands.test.ts @@ -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 }> + 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) => Promise): 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 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 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)') + }) +}) diff --git a/ui-opentui/src/view/display.tsx b/ui-opentui/src/view/display.tsx new file mode 100644 index 00000000000..3933d7942f5 --- /dev/null +++ b/ui-opentui/src/view/display.tsx @@ -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 = () => DEFAULTS + +const Ctx = createContext>() + +export function DisplayProvider(props: { flags: Accessor; children: JSX.Element }) { + return {props.children} +} + +/** The live display flags accessor (compact off + collapsed when no provider). */ +export function useDisplay(): Accessor { + return useContext(Ctx) ?? DEFAULT_FLAGS +} diff --git a/ui-opentui/src/view/messageLine.tsx b/ui-opentui/src/view/messageLine.tsx index 612f2b92706..1034b63184a 100644 --- a/ui-opentui/src/view/messageLine.tsx +++ b/ui-opentui/src/view/messageLine.tsx @@ -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. - + // distinct turns (item: spacing); /compact collapses it so long sessions + // read denser. The gold-vs-bright color split does the rest. + {/* 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 }) { {/* 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. */} - + don't pop in and out as parts are created/merged mid-stream. /compact + drops the gap along with the per-turn margin above. */} + } > - + {part => ( {tool => } {r => } + + {/* /details hidden — the honest minimal render for a folded + tool/reasoning run; chrome, not copyable content. */} + {run => ( + + {`⚡ ${hiddenRunLabel(run())}`} + + )} + {/* ONE stable native fed the growing text in place (no per-delta remount → no scrollbar flicker, #2); it renders GFM diff --git a/ui-opentui/src/view/reasoningPart.tsx b/ui-opentui/src/view/reasoningPart.tsx index 790e0d4b1d3..c4f91d3de45 100644 --- a/ui-opentui/src/view/reasoningPart.tsx +++ b/ui-opentui/src/view/reasoningPart.tsx @@ -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(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') diff --git a/ui-opentui/src/view/toolPart.tsx b/ui-opentui/src/view/toolPart.tsx index f830438c725..2e088c996cd 100644 --- a/ui-opentui/src/view/toolPart.tsx +++ b/ui-opentui/src/view/toolPart.tsx @@ -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(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) diff --git a/ui-opentui/src/view/transcript.tsx b/ui-opentui/src/view/transcript.tsx index 6a48ed6bc1b..9e8c526368a 100644 --- a/ui-opentui/src/view/transcript.tsx +++ b/ui-opentui/src/view/transcript.tsx @@ -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 }) { - {/* empty-transcript home screen (item 12); replaced by messages on the first turn */} - - - - {/* Honest truncation notice: the rolling cap hides the OLDEST rows from the + {/* display flags (/compact, /details — Epic 3) for the rows below */} + ({ compact: props.store.state.compact, details: props.store.state.details })}> + {/* empty-transcript home screen (item 12); replaced by messages on the first turn */} + + + + {/* 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. */} - 0}> - - {`⤒ ${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`} - - - {message => } + 0}> + + {`⤒ ${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`} + + + {message => } +