mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): port utility commands — compact, details, replay, heapdump, mem
This commit is contained in:
parent
73b261b94f
commit
c007d08419
14 changed files with 1157 additions and 22 deletions
|
|
@ -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)
|
||||
|
|
|
|||
84
ui-opentui/src/logic/details.ts
Normal file
84
ui-opentui/src/logic/details.ts
Normal 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`
|
||||
}
|
||||
82
ui-opentui/src/logic/diagnostics.ts
Normal file
82
ui-opentui/src/logic/diagnostics.ts
Normal 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')
|
||||
}
|
||||
126
ui-opentui/src/logic/replay.ts
Normal file
126
ui-opentui/src/logic/replay.ts
Normal 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')
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@
|
|||
* (exec/plugin → system · alias → re-dispatch · skill/send → submit a turn ·
|
||||
* prefill → notice). Long output routes to the pager (Phase 5a).
|
||||
*/
|
||||
import { DETAILS_SECTIONS, DETAILS_USAGE, type DetailsMode, nextDetailsMode, parseDetailsMode } from './details.ts'
|
||||
import { formatBytes, memReport, performHeapdump } from './diagnostics.ts'
|
||||
import { formatSpawnTree, formatSpawnTreeList, readSpawnTreeEntries } from './replay.ts'
|
||||
import type { CompletionItem, PickerItem, PickerState, SessionItem } from './store.ts'
|
||||
|
||||
export interface ParsedSlash {
|
||||
|
|
@ -57,6 +60,15 @@ export interface SlashContext {
|
|||
readonly modelItems: () => PickerItem[] | undefined
|
||||
/** Update the cached `/model` picker rows. */
|
||||
readonly setModelItems: (items: PickerItem[]) => void
|
||||
/** Read / set the compact-transcript display flag (/compact — Epic 3). */
|
||||
readonly compact: () => boolean
|
||||
readonly setCompact: (on: boolean) => void
|
||||
/** Read / set the global tool/reasoning detail mode (/details — Epic 3). */
|
||||
readonly details: () => DetailsMode
|
||||
readonly setDetails: (mode: DetailsMode) => void
|
||||
/** Mounted-renderable count under the live renderer root (a /mem diagnostic);
|
||||
* undefined when no renderer is reachable (tests). */
|
||||
readonly renderableCount: () => number | undefined
|
||||
}
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
|
|
@ -143,6 +155,11 @@ const CLIENT_HELP = [
|
|||
'/skills — browse skills',
|
||||
'/sessions, /resume — switch/resume a session',
|
||||
'/clear, /new — clear the transcript (confirm)',
|
||||
'/compact [on|off|toggle] — compact transcript spacing',
|
||||
'/details [hidden|collapsed|expanded|cycle] — tool/reasoning detail',
|
||||
'/replay [n|path] — inspect an archived spawn tree',
|
||||
'/mem — live memory stats',
|
||||
'/heapdump — write a V8 heap snapshot',
|
||||
'/logs — recent engine log lines',
|
||||
'/quit, /exit — quit',
|
||||
'(other /commands run on the gateway)'
|
||||
|
|
@ -337,6 +354,137 @@ const skillsCmd: ClientHandler = async (_arg, ctx) => {
|
|||
})
|
||||
}
|
||||
|
||||
/** `on`/`off`/`toggle`/bare → the next flag value; null on garbage (Ink flagFromArg). */
|
||||
function flagFromArg(arg: string, current: boolean): boolean | null {
|
||||
const mode = arg.trim().toLowerCase()
|
||||
if (!mode || mode === 'toggle') return !current
|
||||
if (mode === 'on') return true
|
||||
if (mode === 'off') return false
|
||||
return null
|
||||
}
|
||||
|
||||
/** `/compact [on|off|toggle]` — compact transcript spacing. The flag flips locally
|
||||
* (the store drives the render); persistence mirrors Ink: a fire-and-forget
|
||||
* `config.set {key:'compact'}` so the Ink TUI + future launches share the pref
|
||||
* (the gateway does NOT send the persisted value to this TUI, so each launch
|
||||
* starts off — see store.ts `compact`). */
|
||||
const compactCmd: ClientHandler = (arg, ctx) => {
|
||||
const next = flagFromArg(arg, ctx.compact())
|
||||
if (next === null) {
|
||||
ctx.pushSystem('usage: /compact [on|off|toggle]')
|
||||
return
|
||||
}
|
||||
ctx.setCompact(next)
|
||||
void ctx.request('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {})
|
||||
ctx.pushSystem(`compact ${next ? 'on' : 'off'}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* `/details [hidden|collapsed|expanded|cycle]` — GLOBAL detail mode (per-section
|
||||
* overrides deferred; the gateway's arg completion also suggests section names,
|
||||
* so those get an honest "not supported yet" notice). Bare `/details` reports the
|
||||
* persisted mode (`config.get details_mode`) and syncs the local flag to it; a
|
||||
* mode set persists via `config.set` (fire-and-forget, Ink parity).
|
||||
*/
|
||||
const detailsCmd: ClientHandler = async (arg, ctx) => {
|
||||
const first = arg.trim().toLowerCase().split(/\s+/)[0] ?? ''
|
||||
if (!first) {
|
||||
try {
|
||||
const r = await ctx.request('config.get', { key: 'details_mode' })
|
||||
const mode = parseDetailsMode(readStr(r, 'value')) ?? ctx.details()
|
||||
ctx.setDetails(mode)
|
||||
ctx.pushSystem(`details: ${mode}`)
|
||||
} catch {
|
||||
ctx.pushSystem(`details: ${ctx.details()}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if ((DETAILS_SECTIONS as readonly string[]).includes(first)) {
|
||||
ctx.pushSystem(`per-section detail overrides are not supported in the native engine yet — ${DETAILS_USAGE}`)
|
||||
return
|
||||
}
|
||||
const next = first === 'cycle' || first === 'toggle' ? nextDetailsMode(ctx.details()) : parseDetailsMode(first)
|
||||
if (!next) {
|
||||
ctx.pushSystem(DETAILS_USAGE)
|
||||
return
|
||||
}
|
||||
ctx.setDetails(next)
|
||||
void ctx.request('config.set', { key: 'details_mode', value: next }).catch(() => {})
|
||||
ctx.pushSystem(`details: ${next}`)
|
||||
}
|
||||
|
||||
/** Fetch + map the session's archived spawn trees (`spawn_tree.list`). */
|
||||
async function listSpawnTrees(ctx: SlashContext) {
|
||||
const r = await ctx.request('spawn_tree.list', { limit: 30, session_id: ctx.sessionId() ?? 'default' })
|
||||
return readSpawnTreeEntries(r)
|
||||
}
|
||||
|
||||
/**
|
||||
* `/replay [n|path]` — spawn-tree inspector through the pager (Ink renders these
|
||||
* in its agents overlay; the flow + RPCs are the same): bare lists the archived
|
||||
* trees with indices, `<n>` loads the n-th listed tree, anything else is treated
|
||||
* as a snapshot path on disk (`load <path>` accepted for Ink muscle memory).
|
||||
*/
|
||||
const replayCmd: ClientHandler = async (arg, ctx) => {
|
||||
const raw = arg.trim()
|
||||
const lower = raw.toLowerCase()
|
||||
try {
|
||||
if (!raw || lower === 'list' || lower === 'ls') {
|
||||
const entries = await listSpawnTrees(ctx)
|
||||
if (!entries.length) {
|
||||
ctx.pushSystem('no archived spawn trees for this session — completed delegations are archived automatically')
|
||||
return
|
||||
}
|
||||
ctx.openPager('Spawn trees', formatSpawnTreeList(entries))
|
||||
return
|
||||
}
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const n = Number.parseInt(raw, 10)
|
||||
const entries = await listSpawnTrees(ctx)
|
||||
const entry = entries[n - 1]
|
||||
if (!entry) {
|
||||
ctx.pushSystem(
|
||||
entries.length
|
||||
? `replay: index out of range 1..${entries.length} — /replay to list`
|
||||
: 'no archived spawn trees for this session'
|
||||
)
|
||||
return
|
||||
}
|
||||
const tree = await ctx.request('spawn_tree.load', { path: entry.path })
|
||||
ctx.openPager(`Replay ${n}`, formatSpawnTree(tree))
|
||||
return
|
||||
}
|
||||
const path = lower.startsWith('load ') ? raw.slice(5).trim() : raw
|
||||
const tree = await ctx.request('spawn_tree.load', { path })
|
||||
ctx.openPager('Replay', formatSpawnTree(tree))
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`/replay: ${error instanceof Error ? error.message : 'failed'}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/heapdump` — write a V8 heap snapshot to `$HERMES_HOME|~/.hermes/logs/` and
|
||||
* report the path + heap/rss before vs after (Ink ref debug.ts /heapdump). */
|
||||
const heapdumpCmd: ClientHandler = (_arg, ctx) => {
|
||||
const pre = process.memoryUsage()
|
||||
ctx.pushSystem(`writing heap dump (heap ${formatBytes(pre.heapUsed)} · rss ${formatBytes(pre.rss)})…`)
|
||||
try {
|
||||
const { after, before, path } = performHeapdump()
|
||||
ctx.pushSystem(
|
||||
`heapdump: ${path}\n` +
|
||||
`heap ${formatBytes(before.heapUsed)} → ${formatBytes(after.heapUsed)} · ` +
|
||||
`rss ${formatBytes(before.rss)} → ${formatBytes(after.rss)}`
|
||||
)
|
||||
} catch (error) {
|
||||
ctx.pushSystem(`heapdump failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `/mem` — live V8 heap/rss numbers + uptime + the mounted-renderable count
|
||||
* (the store-cap diagnostic) as one system block (Ink ref debug.ts /mem). */
|
||||
const memCmd: ClientHandler = (_arg, ctx) => {
|
||||
ctx.pushSystem(memReport(process.memoryUsage(), process.uptime(), ctx.renderableCount()))
|
||||
}
|
||||
|
||||
/** `/tools` — fetch the tool roster from the gateway and show it in the pager (navigable). */
|
||||
const toolsCmd: ClientHandler = async (arg, ctx) => {
|
||||
const command = arg.trim() ? `tools ${arg.trim()}` : 'tools'
|
||||
|
|
@ -352,12 +500,18 @@ const toolsCmd: ClientHandler = async (arg, ctx) => {
|
|||
const CLIENT: Record<string, ClientHandler> = {
|
||||
agents: (_arg, ctx) => ctx.openDashboard(),
|
||||
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
||||
compact: compactCmd,
|
||||
copy: (arg, ctx) => {
|
||||
const n = Math.max(1, Number.parseInt(arg, 10) || 1)
|
||||
if (!ctx.copyResponse(n)) ctx.pushSystem('Nothing to copy yet.')
|
||||
},
|
||||
detail: detailsCmd,
|
||||
details: detailsCmd,
|
||||
exit: (_arg, ctx) => ctx.quit(),
|
||||
heapdump: heapdumpCmd,
|
||||
mem: memCmd,
|
||||
model: modelCmd,
|
||||
replay: replayCmd,
|
||||
resume: openSwitcher,
|
||||
session: openSwitcher,
|
||||
sessions: openSwitcher,
|
||||
|
|
@ -379,6 +533,11 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||
quit: (_arg, ctx) => ctx.quit()
|
||||
}
|
||||
|
||||
/** The registered client-command names (catalog introspection — tests/menus). */
|
||||
export function clientCommandNames(): string[] {
|
||||
return Object.keys(CLIENT).sort()
|
||||
}
|
||||
|
||||
/** Render the gateway `commands.catalog` into a help block (loose-typed read).
|
||||
* The TUI catalog shape is `{ pairs: [["/name","desc"], …], canon, categories }`
|
||||
* (tui_gateway/server.py `commands.catalog`). */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
137
ui-opentui/src/test/displayModes.test.tsx
Normal file
137
ui-opentui/src/test/displayModes.test.tsx
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
import { afterEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import type { DetailsMode } from '../logic/details.ts'
|
||||
import {
|
||||
dispatchSlash,
|
||||
mapCompletions,
|
||||
|
|
@ -137,6 +138,9 @@ interface Probe {
|
|||
copyN: { value: (n: number) => boolean }
|
||||
/** The cached /model rows (Epic 7) — seed to simulate a prefetched catalog. */
|
||||
modelCache: { value: PickerItem[] | undefined }
|
||||
/** Display flags (/compact, /details — Epic 3). */
|
||||
compactFlag: { value: boolean }
|
||||
detailsFlag: { value: DetailsMode }
|
||||
}
|
||||
|
||||
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
|
||||
|
|
@ -153,8 +157,15 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
const copied: number[] = []
|
||||
const copyN: Probe['copyN'] = { value: () => false }
|
||||
const modelCache: Probe['modelCache'] = { value: undefined }
|
||||
const compactFlag: Probe['compactFlag'] = { value: false }
|
||||
const detailsFlag: Probe['detailsFlag'] = { value: 'collapsed' }
|
||||
const ctx: SlashContext = {
|
||||
clearTranscript: () => (cleared.value = true),
|
||||
compact: () => compactFlag.value,
|
||||
setCompact: on => (compactFlag.value = on),
|
||||
details: () => detailsFlag.value,
|
||||
setDetails: mode => (detailsFlag.value = mode),
|
||||
renderableCount: () => undefined,
|
||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||
copyResponse: n => {
|
||||
copied.push(n)
|
||||
|
|
@ -180,11 +191,13 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
return {
|
||||
calls,
|
||||
cleared,
|
||||
compactFlag,
|
||||
confirmed,
|
||||
copied,
|
||||
copyN,
|
||||
ctx,
|
||||
dashboard,
|
||||
detailsFlag,
|
||||
modelCache,
|
||||
paged,
|
||||
pickers,
|
||||
|
|
|
|||
427
ui-opentui/src/test/utilityCommands.test.ts
Normal file
427
ui-opentui/src/test/utilityCommands.test.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
31
ui-opentui/src/view/display.tsx
Normal file
31
ui-opentui/src/view/display.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -10,7 +10,9 @@
|
|||
*/
|
||||
import { For, Match, Show, Switch } from 'solid-js'
|
||||
|
||||
import { collapseHiddenParts, hiddenRunLabel } from '../logic/details.ts'
|
||||
import type { Message } from '../logic/store.ts'
|
||||
import { useDisplay } from './display.tsx'
|
||||
import { Markdown } from './markdown.tsx'
|
||||
import { ReasoningPart } from './reasoningPart.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
|
@ -20,6 +22,7 @@ const GUTTER = 2
|
|||
|
||||
export function MessageLine(props: { message: Message }) {
|
||||
const theme = useTheme()
|
||||
const display = useDisplay()
|
||||
const m = () => props.message
|
||||
const glyph = () => (m().role === 'assistant' ? theme().brand.icon : m().role === 'user' ? theme().brand.prompt : '·')
|
||||
// Role-distinct color IS the hierarchy (Ink model): the human's turn is tinted
|
||||
|
|
@ -29,11 +32,15 @@ export function MessageLine(props: { message: Message }) {
|
|||
const bodyFg = () =>
|
||||
m().role === 'user' ? theme().color.label : m().role === 'system' ? theme().color.muted : theme().color.text
|
||||
const hasParts = () => (m().parts?.length ?? 0) > 0
|
||||
// /details hidden: fold each run of tool/reasoning parts into ONE muted line
|
||||
// (the parts stay in the store — flipping the mode back restores them).
|
||||
const displayParts = () => (display().details === 'hidden' ? collapseHiddenParts(m().parts ?? []) : (m().parts ?? []))
|
||||
|
||||
return (
|
||||
// One blank line above every turn so user / assistant / tool blocks read as
|
||||
// distinct turns (item: spacing). The gold-vs-bright color split does the rest.
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: 1 }}>
|
||||
// distinct turns (item: spacing); /compact collapses it so long sessions
|
||||
// read denser. The gold-vs-bright color split does the rest.
|
||||
<box style={{ flexDirection: 'row', flexShrink: 0, marginTop: display().compact ? 0 : 1 }}>
|
||||
<box style={{ flexShrink: 0, width: GUTTER }}>
|
||||
{/* the role glyph is decorative — exclude it from mouse selection (item 4).
|
||||
Bold so the user `❯` / assistant `⚕` turn boundaries pop (item 8). */}
|
||||
|
|
@ -45,8 +52,9 @@ export function MessageLine(props: { message: Message }) {
|
|||
</box>
|
||||
{/* gap owns ALL inter-part spacing (item 5) — uniform 1 line between text /
|
||||
reasoning / tool regardless of order or stream timing, so blank lines
|
||||
don't pop in and out as parts are created/merged mid-stream. */}
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: 1 }}>
|
||||
don't pop in and out as parts are created/merged mid-stream. /compact
|
||||
drops the gap along with the per-turn margin above. */}
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, minWidth: 0, gap: display().compact ? 0 : 1 }}>
|
||||
<Show
|
||||
when={m().role === 'assistant' && hasParts()}
|
||||
fallback={
|
||||
|
|
@ -71,13 +79,22 @@ export function MessageLine(props: { message: Message }) {
|
|||
</Show>
|
||||
}
|
||||
>
|
||||
<For each={m().parts ?? []}>
|
||||
<For each={displayParts()}>
|
||||
{part => (
|
||||
<Switch>
|
||||
<Match when={part.type === 'tool' && part}>{tool => <ToolPart part={tool()} />}</Match>
|
||||
<Match when={part.type === 'reasoning' && part}>
|
||||
{r => <ReasoningPart text={r().text} streaming={m().streaming ?? false} />}
|
||||
</Match>
|
||||
<Match when={part.type === 'hiddenRun' && part}>
|
||||
{/* /details hidden — the honest minimal render for a folded
|
||||
tool/reasoning run; chrome, not copyable content. */}
|
||||
{run => (
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{`⚡ ${hiddenRunLabel(run())}`}</span>
|
||||
</text>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === 'text' && part}>
|
||||
{/* ONE stable native <markdown> fed the growing text in place (no
|
||||
per-delta remount → no scrollbar flicker, #2); it renders GFM
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import { createMemo, createSignal, Show } from 'solid-js'
|
||||
|
||||
import type { ThemeColors } from '../logic/theme.ts'
|
||||
import { useDisplay } from './display.tsx'
|
||||
import { Markdown } from './markdown.tsx'
|
||||
import { useScrollAnchor } from './scrollAnchor.tsx'
|
||||
import { useTheme } from './theme.tsx'
|
||||
|
|
@ -44,10 +45,12 @@ function reasoningSummary(text: string): { title?: string; body: string } {
|
|||
export function ReasoningPart(props: { text: string; streaming?: boolean }) {
|
||||
const theme = useTheme()
|
||||
const anchor = useScrollAnchor()
|
||||
const display = useDisplay()
|
||||
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
|
||||
// live → expanded so you see it think; settled → collapsed. Click overrides.
|
||||
const expanded = () => override() ?? !!props.streaming
|
||||
const toggle = () => anchor(() => setOverride(e => !(e ?? !!props.streaming)))
|
||||
// live → expanded so you see it think; settled → collapsed, unless the global
|
||||
// /details mode is `expanded` (previews default-open). Click overrides.
|
||||
const expanded = () => override() ?? (!!props.streaming || display().details === 'expanded')
|
||||
const toggle = () => anchor(() => setOverride(!expanded()))
|
||||
const summary = createMemo(() => reasoningSummary(props.text))
|
||||
const label = () => (props.streaming ? 'Thinking' : 'Thought')
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
import { type ToolPartState } from '../logic/store.ts'
|
||||
import type { ThemeColors } from '../logic/theme.ts'
|
||||
import { useDimensions } from './dimensions.tsx'
|
||||
import { useDisplay } from './display.tsx'
|
||||
import { createSignal, Show } from 'solid-js'
|
||||
|
||||
import { truncate } from '../logic/toolOutput.ts'
|
||||
|
|
@ -90,8 +91,12 @@ export function ToolPart(props: { part: ToolPartState }) {
|
|||
const dims = useDimensions()
|
||||
const info = useSessionInfo() // session cwd for path-relativizing renderers
|
||||
const anchor = useScrollAnchor()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const toggle = () => anchor(() => setExpanded(e => !e))
|
||||
const display = useDisplay()
|
||||
// /details expanded → settled bodies default-OPEN; a manual click overrides
|
||||
// either way (and a later global flip applies again to un-overridden parts).
|
||||
const [override, setOverride] = createSignal<boolean | undefined>(undefined)
|
||||
const expanded = () => override() ?? display().details === 'expanded'
|
||||
const toggle = () => anchor(() => setOverride(!expanded()))
|
||||
|
||||
// Per-tool renderer (re-dispatches if the name settles on tool.complete).
|
||||
const renderer = () => rendererFor(props.part.name)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type { ScrollBoxRenderable } from '@opentui/core'
|
|||
import { createSignal, For, Show } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { DisplayProvider } from './display.tsx'
|
||||
import { HomeHint } from './homeHint.tsx'
|
||||
import { MessageLine } from './messageLine.tsx'
|
||||
import { ScrollAnchorProvider } from './scrollAnchor.tsx'
|
||||
|
|
@ -32,20 +33,23 @@ export function Transcript(props: { store: SessionStore }) {
|
|||
<box style={{ flexGrow: 1, minHeight: 0 }}>
|
||||
<scrollbox ref={setScroll} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
|
||||
<ScrollAnchorProvider scroll={scroll}>
|
||||
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
|
||||
<Show when={props.store.state.messages.length === 0}>
|
||||
<HomeHint store={props.store} />
|
||||
</Show>
|
||||
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the
|
||||
{/* display flags (/compact, /details — Epic 3) for the rows below */}
|
||||
<DisplayProvider flags={() => ({ compact: props.store.state.compact, details: props.store.state.details })}>
|
||||
{/* empty-transcript home screen (item 12); replaced by messages on the first turn */}
|
||||
<Show when={props.store.state.messages.length === 0}>
|
||||
<HomeHint store={props.store} />
|
||||
</Show>
|
||||
{/* Honest truncation notice: the rolling cap hides the OLDEST rows from the
|
||||
DISPLAY (never the model's context — that lives on the gateway). Point to
|
||||
the dashboard for the full transcript. selectable=false → it's chrome,
|
||||
excluded from copy/selection. */}
|
||||
<Show when={dropped() > 0}>
|
||||
<text selectable={false} style={{ fg: theme().color.muted }}>
|
||||
{`⤒ ${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
|
||||
<Show when={dropped() > 0}>
|
||||
<text selectable={false} style={{ fg: theme().color.muted }}>
|
||||
{`⤒ ${dropped()} earlier message${dropped() === 1 ? '' : 's'} — scroll-back capped; full transcript on the dashboard${sid() ? ` · session ${sid()}` : ''}`}
|
||||
</text>
|
||||
</Show>
|
||||
<For each={props.store.state.messages}>{message => <MessageLine message={message} />}</For>
|
||||
</DisplayProvider>
|
||||
</ScrollAnchorProvider>
|
||||
</scrollbox>
|
||||
</box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue