opentui(v6): background-activity P2 — de-crowd the agents dashboard + typed trace

The agents dashboard (rplj pain) dumped each subagent's full multi-line prompt
into the master list, wrapping it into a wall of text and squeezing the trace.
Now each master row is ONE line: status + a width-budgeted truncated goal + model.
The detail pane still shows the full goal (the inspect half) and renders the
activity as a TYPED transcript instead of flat lines: SubagentInfo.trace is now
TraceEntry[] ({kind:'start'|'tool'|'progress'|'summary'}), drawn with per-kind
glyph+color (▶ start /  tool accent / · progress muted / ✓ summary green).

No foregrounding (kept subagent UX unchanged — inspection only, per the brainstorm).
TUI-only. Gate green; new agentsDashboard.test.tsx asserts one-line truncation +
typed-trace render; store.test trace assertion updated to the typed shape.
This commit is contained in:
alt-glitch 2026-06-13 21:56:02 +05:30
parent 5988e21ed7
commit 74cb03423e
5 changed files with 123 additions and 23 deletions

View file

@ -153,6 +153,13 @@ export interface CompletionItem {
meta: string
}
/** One typed entry in a subagent's activity trace `kind` drives glyph + color
* in the dashboard so the trace reads like a transcript, not flat dumped lines. */
export interface TraceEntry {
kind: 'start' | 'tool' | 'progress' | 'summary'
text: string
}
/** A delegated subagent, tracked from the `subagent.*` event stream (agents dashboard). */
export interface SubagentInfo {
id: string
@ -163,8 +170,8 @@ export interface SubagentInfo {
parentId?: string
summary?: string
lastTool?: string
/** Live activity trace (item 15) — tool/progress/summary lines, newest last. */
trace?: string[]
/** Live activity trace (item 15) — typed entries, newest last; rendered by kind. */
trace?: TraceEntry[]
/** Latest thinking text (transient; not appended to the trace to avoid flooding). */
thought?: string
}
@ -971,10 +978,11 @@ export function createSessionStore(options?: SessionStoreOptions) {
// deltas update a transient `thought` (not appended — they'd flood).
const text = readStr(event.payload, 'text')
const trace = (sa.trace ??= [])
if (event.type === 'subagent.start') trace.push(`${goal ?? sa.goal ?? 'started'}`)
else if (event.type === 'subagent.tool' && tool) trace.push(`${tool}${text ? `${text}` : ''}`)
else if (event.type === 'subagent.progress' && text) trace.push(text)
else if (event.type === 'subagent.complete') trace.push(`${summary ?? 'done'}`)
if (event.type === 'subagent.start') trace.push({ kind: 'start', text: goal ?? sa.goal ?? 'started' })
else if (event.type === 'subagent.tool' && tool)
trace.push({ kind: 'tool', text: text ? `${tool}${text}` : tool })
else if (event.type === 'subagent.progress' && text) trace.push({ kind: 'progress', text })
else if (event.type === 'subagent.complete') trace.push({ kind: 'summary', text: summary ?? 'done' })
else if (event.type === 'subagent.thinking' && text) sa.thought = text
if (trace.length > SUBAGENT_TRACE_LIMIT) trace.splice(0, trace.length - SUBAGENT_TRACE_LIMIT)
})

View file

@ -0,0 +1,51 @@
/**
* Agents dashboard (P2 de-crowd) the master list is ONE line per subagent
* (long goals truncate, no multi-line prompt dump) and the detail pane renders
* the TYPED trace by kind ( tool / · progress / summary).
*/
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 { captureFrame } from './lib/render.ts'
const LONG_GOAL =
'Poll the current UTC time 10 times with a 3-second sleep between each poll, run date -u and record each result, then report all ten timestamps as a timing exercise'
function dash() {
const store = createSessionStore()
store.apply({ type: 'gateway.ready' })
store.apply({
type: 'subagent.start',
payload: { subagent_id: 'a1', goal: LONG_GOAL, model: 'anthropic/claude-opus-4-8', depth: 0 }
})
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'terminal', text: 'date -u' } })
store.apply({ type: 'subagent.progress', payload: { subagent_id: 'a1', text: 'poll 4 of 10 recorded' } })
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'all ten timestamps collected' } })
store.openDashboard()
return () => (
<ThemeProvider theme={() => store.state.theme}>
<App store={store} />
</ThemeProvider>
)
}
describe('agents dashboard de-crowd (P2)', () => {
test('a long goal is truncated to one line in the master list (no full-prompt wall)', async () => {
const frame = await captureFrame(dash(), { until: 'Agents', width: 116, height: 30 })
// The master row truncates to one line — the head shows with an ellipsis.
// (The detail pane below still shows the full goal; that's the inspect half.)
expect(frame).toContain('Poll the current UTC time')
expect(frame).toContain('…') // ellipsis proves the master row is one-line, not a wrapped wall
})
test('the detail pane renders the typed trace by kind (tool ⚡, summary ✓)', async () => {
const frame = await captureFrame(dash(), { until: 'Agents', width: 116, height: 30 })
expect(frame).toContain('⚡') // tool entry glyph
expect(frame).toContain('terminal — date -u') // tool entry text
expect(frame).toContain('✓') // summary entry glyph
expect(frame).toContain('all ten timestamps collected') // summary text (detail, not master)
expect(frame).toContain('poll 4 of 10 recorded') // progress entry
})
})

View file

@ -335,9 +335,14 @@ describe('session store — subagents (Phase 5e agents dashboard)', () => {
store.apply({ type: 'subagent.progress', payload: { subagent_id: 'a1', text: 'found 3 hits' } })
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'done crunching' } })
const sa = store.state.subagents[0]!
// thinking text is transient (not in the trace), the rest is a concise log
// thinking text is transient (not in the trace), the rest is a concise TYPED log
expect(sa.thought).toBe('considering options')
expect(sa.trace).toEqual(['▶ crunch data', '⚡ web_search — opentui', 'found 3 hits', '✓ done crunching'])
expect(sa.trace).toEqual([
{ kind: 'start', text: 'crunch data' },
{ kind: 'tool', text: 'web_search — opentui' },
{ kind: 'progress', text: 'found 3 hits' },
{ kind: 'summary', text: 'done crunching' }
])
})
test('clearTranscript also clears subagents', () => {

View file

@ -175,7 +175,7 @@ function TrayRows(props: { agents: SubagentInfo[]; selected: number; firstSeen:
<For each={props.agents}>
{(sa, i) => {
const active = () => i() === props.selected
const last = () => sa.trace?.at(-1) ?? sa.thought
const last = () => sa.trace?.at(-1)?.text ?? sa.thought
const secs = () => (tick(), elapsedSeconds(props.firstSeen.get(sa.id) ?? Date.now()))
return (
<box

View file

@ -12,7 +12,8 @@ import { type BoxRenderable, type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { createSignal, For, onMount, Show } from 'solid-js'
import type { SubagentInfo } from '../../logic/store.ts'
import type { SubagentInfo, TraceEntry } from '../../logic/store.ts'
import { useDimensions } from '../dimensions.tsx'
import { useCloseLayer } from '../keymap.tsx'
import { useTheme } from '../theme.tsx'
@ -26,6 +27,22 @@ function statusColor(status: string, theme: ReturnType<typeof useTheme>): string
return c.warn
}
/** Keep the head of a string, ellipsizing when it must clip (one-line master rows). */
function truncRight(s: string, max: number): string {
if (max <= 1) return s.length > max ? '…' : s
return s.length <= max ? s : s.slice(0, max - 1) + '…'
}
/** Per-kind glyph + color for a trace entry makes the detail pane read like a
* transcript (tool calls pop, progress is quiet, the summary is the payoff). */
function traceGlyph(kind: TraceEntry['kind']): string {
return kind === 'tool' ? '⚡' : kind === 'summary' ? '✓' : kind === 'start' ? '▶' : '·'
}
function traceColor(kind: TraceEntry['kind'], theme: ReturnType<typeof useTheme>): string {
const c = theme().color
return kind === 'tool' ? c.accent : kind === 'summary' ? c.ok : kind === 'start' ? c.label : c.muted
}
export function AgentsDashboard(props: {
subagents: SubagentInfo[]
onClose: () => void
@ -33,6 +50,7 @@ export function AgentsDashboard(props: {
preselect?: string | undefined
}) {
const theme = useTheme()
const dims = useDimensions()
const [sel, setSel] = createSignal(0)
let rootRef: BoxRenderable | undefined
let traceBox: ScrollBoxRenderable | undefined
@ -86,18 +104,33 @@ export function AgentsDashboard(props: {
when={count() > 0}
fallback={<text fg={theme().color.muted}>No subagents yet delegate a task to spawn one.</text>}
>
{/* ONE line per row (de-crowd, glitch 2026-06-13): indent + select caret
+ status + a TRUNCATED goal + model the full prompt no longer wraps
the master list into a wall of text. The detail pane shows the rest. */}
<For each={props.subagents}>
{(sa, i) => (
<text onMouseDown={() => setSel(i())}>
<span style={{ fg: theme().color.muted }}>{' '.repeat(Math.max(0, sa.depth))}</span>
<span style={{ fg: i() === selected() ? theme().color.accent : theme().color.muted }}>
{i() === selected() ? '▸ ' : ' '}
</span>
<span style={{ fg: statusColor(sa.status, theme) }}>{`${sa.status}`}</span>
<span style={{ fg: theme().color.label }}>{` ${sa.goal || sa.id}`}</span>
<span style={{ fg: theme().color.muted }}>{sa.lastTool ? `${sa.lastTool}` : ''}</span>
</text>
)}
{(sa, i) => {
const indent = () => ' '.repeat(Math.max(0, sa.depth))
// budget the goal into the leftover row width so it never wraps:
// total border/pad indent caret(2) `● status `(status+3) model tail.
const goalMax = () => {
const modelTail = sa.model ? sa.model.length + 3 : 0
const used = 4 + indent().length + 2 + (sa.status.length + 3) + modelTail
return Math.max(8, dims().width - used)
}
return (
<text onMouseDown={() => setSel(i())}>
<span style={{ fg: theme().color.muted }}>{indent()}</span>
<span style={{ fg: i() === selected() ? theme().color.accent : theme().color.muted }}>
{i() === selected() ? '▸ ' : ' '}
</span>
<span style={{ fg: statusColor(sa.status, theme) }}>{`${sa.status} `}</span>
<span style={{ fg: i() === selected() ? theme().color.label : theme().color.text }}>
{truncRight(sa.goal || sa.id, goalMax())}
</span>
<span style={{ fg: theme().color.muted }}>{sa.model ? ` · ${sa.model}` : ''}</span>
</text>
)
}}
</For>
</Show>
</box>
@ -133,9 +166,12 @@ export function AgentsDashboard(props: {
fallback={<text fg={theme().color.muted}>(no activity yet)</text>}
>
<For each={sa().trace ?? []}>
{line => (
{entry => (
<text>
<span style={{ fg: theme().color.muted }}>{line}</span>
<span style={{ fg: traceColor(entry.kind, theme) }}>{`${traceGlyph(entry.kind)} `}</span>
<span style={{ fg: entry.kind === 'summary' ? theme().color.text : theme().color.muted }}>
{entry.text}
</span>
</text>
)}
</For>