mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
5988e21ed7
commit
74cb03423e
5 changed files with 123 additions and 23 deletions
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
51
ui-opentui/src/test/agentsDashboard.test.tsx
Normal file
51
ui-opentui/src/test/agentsDashboard.test.tsx
Normal 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
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue