opentui(v2): live agent trace + /tools navigable overlay (items 9, 15)

Item 15 — "/agents doesn't let me look into an agent trace live":
- store accumulates a concise per-subagent trace from the subagent.* stream
  (▶ start /  tool — preview / progress text / ✓ summary), capped at 200 lines;
  thinking deltas update a transient `thought` (not appended — they'd flood).
- AgentsDashboard is now master-detail: ↑/↓ select a subagent (▸ + accent), and
  the bottom pane shows the selected agent's goal · status · model, its latest
  thought, and a sticky-bottom (live) trace scrollbox. PgUp/PgDn scroll the trace.

Item 9 — /tools wired to a deliberate navigable overlay (fetch the roster via
slash.exec → pager) instead of incidental fallthrough; /skills already opens the
native picker.

Tests: store trace accumulation + dashboard render (trace line + footer). 71 pass.

Live-smoked: /tools → tool roster pager; /skills → picker; a real delegation
(spawn a subagent → reply PURPLE) → /agents showed the subagent with its goal ·
completed · model, 🧠 PURPLE thought, and ▶/✓ trace lines.
This commit is contained in:
alt-glitch 2026-06-08 18:09:32 +00:00
parent 247604cdde
commit 37b74f4df3
5 changed files with 132 additions and 41 deletions

View file

@ -224,6 +224,17 @@ const skillsCmd: ClientHandler = async (_arg, ctx) => {
})
}
/** `/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'
try {
const r = await ctx.request('slash.exec', { command, session_id: ctx.sessionId() })
ctx.openPager('Tools', readStr(r, 'output') || '(no tool info)')
} catch (error) {
ctx.pushSystem(`/tools: ${error instanceof Error ? error.message : 'failed'}`)
}
}
/** The TUI-only client commands (run in-process, never hit the gateway). */
const CLIENT: Record<string, ClientHandler> = {
agents: (_arg, ctx) => ctx.openDashboard(),
@ -236,6 +247,7 @@ const CLIENT: Record<string, ClientHandler> = {
skills: skillsCmd,
switch: openSwitcher,
tasks: (_arg, ctx) => ctx.openDashboard(),
tools: toolsCmd,
help: async (_arg, ctx) => {
// Prefer the live catalog; fall back to the client list if it's unavailable.
try {

View file

@ -103,8 +103,15 @@ export interface SubagentInfo {
parentId?: string
summary?: string
lastTool?: string
/** Live activity trace (item 15) — tool/progress/summary lines, newest last. */
trace?: string[]
/** Latest thinking text (transient; not appended to the trace to avoid flooding). */
thought?: string
}
/** Cap on a subagent's retained trace lines. */
const SUBAGENT_TRACE_LIMIT = 200
/**
* Live session chrome (the status bar item 14). Sourced from the `session.info`
* event (and the `session.create`/`resume` result's `info`), refreshed whenever
@ -562,6 +569,17 @@ export function createSessionStore() {
const tool = readStr(event.payload, 'tool_name')
if (tool) sa.lastTool = tool
sa.status = readStr(event.payload, 'status') ?? subagentStatusFor(event.type)
// Live trace (item 15): a concise per-subagent activity log. Thinking
// 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'}`)
else if (event.type === 'subagent.thinking' && text) sa.thought = text
if (trace.length > SUBAGENT_TRACE_LIMIT) trace.splice(0, trace.length - SUBAGENT_TRACE_LIMIT)
})
)
break

View file

@ -213,7 +213,7 @@ describe('App render (Phase 1, themed)', () => {
type: 'subagent.start',
payload: { subagent_id: 'a1', goal: 'research the topic', model: 'haiku', depth: 0 }
})
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search' } })
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search', text: 'opentui' } })
store.openDashboard()
const frame = await captureFrame(
@ -222,12 +222,13 @@ describe('App render (Phase 1, themed)', () => {
<App store={store} />
</ThemeProvider>
),
{ until: 'Agents', width: 72, height: 18 }
{ until: 'Agents', width: 72, height: 24 }
)
expect(frame).toContain('Agents') // dashboard header
expect(frame).toContain('research the topic') // subagent goal
expect(frame).toContain('web_search') // its last tool
expect(frame).toContain('research the topic') // subagent goal (list + detail header)
expect(frame).toContain('web_search') // last tool + live trace line (item 15)
expect(frame).toContain('select') // footer hint "↑↓ select"
expect(frame).not.toContain('parent turn') // transcript replaced by the dashboard
})
})

View file

@ -191,6 +191,19 @@ describe('session store — subagents (Phase 5e agents dashboard)', () => {
expect(store.state.subagents[0]).toMatchObject({ status: 'complete', summary: 'found it' })
})
test('accumulates a live trace per subagent (item 15) + transient thought', () => {
const store = createSessionStore()
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'crunch data' } })
store.apply({ type: 'subagent.thinking', payload: { subagent_id: 'a1', text: 'considering options' } })
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search', text: 'opentui' } })
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
expect(sa.thought).toBe('considering options')
expect(sa.trace).toEqual(['▶ crunch data', '⚡ web_search — opentui', 'found 3 hits', '✓ done crunching'])
})
test('clearTranscript also clears subagents', () => {
const store = createSessionStore()
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'g' } })

View file

@ -1,17 +1,21 @@
/**
* AgentsDashboard the delegation/subagents view (spec §2b; Ink `agentsOverlay`).
* Full-height overlay (replaces transcript+composer) listing the subagents tracked
* from the `subagent.*` event stream, indented by `depth` (a flat tree). Scroll via
* useKeyboardscrollBy/scrollTo; Esc/q close. §8 #2 scrollbox gotchas.
* AgentsDashboard the delegation/subagents view (spec §2b; Ink `agentsOverlay`,
* item 15 "look into an agent trace live"). Master-detail:
* - top: the subagents tracked from the `subagent.*` stream, indented by depth;
* / SELECT a row (highlighted).
* - bottom: the SELECTED subagent's live trace (goal · status · model, latest
* thought, and the tool/progress/summary log) sticky-bottom so it follows
* live; PgUp/PgDn scroll it.
* Esc/q close. §8 #2 scrollbox gotchas (minHeight:0, sticky bottom).
*/
import { type ScrollBoxRenderable } from '@opentui/core'
import { useKeyboard } from '@opentui/solid'
import { For, Show } from 'solid-js'
import { createSignal, For, Show } from 'solid-js'
import type { SubagentInfo } from '../../logic/store.ts'
import { useTheme } from '../theme.tsx'
const PAGE = 10
const PAGE = 8
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
const c = theme().color
@ -23,20 +27,22 @@ function statusColor(status: string, theme: ReturnType<typeof useTheme>): string
export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) {
const theme = useTheme()
let box: ScrollBoxRenderable | undefined
const [sel, setSel] = createSignal(0)
let traceBox: ScrollBoxRenderable | undefined
const count = () => props.subagents.length
const selected = () => Math.min(sel(), Math.max(0, count() - 1))
const current = () => props.subagents[selected()]
useKeyboard(key => {
if (key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c')) {
props.onClose()
return
}
if (!box) return
if (key.name === 'up') box.scrollBy(-1)
else if (key.name === 'down') box.scrollBy(1)
else if (key.name === 'pageup') box.scrollBy(-PAGE)
else if (key.name === 'pagedown') box.scrollBy(PAGE)
else if (key.name === 'home') box.scrollTo(0)
else if (key.name === 'end') box.scrollTo({ x: 0, y: box.scrollHeight })
if (key.name === 'up') setSel(s => Math.max(0, s - 1))
else if (key.name === 'down') setSel(s => Math.min(Math.max(0, count() - 1), s + 1))
else if (key.name === 'pageup') traceBox?.scrollBy(-PAGE)
else if (key.name === 'pagedown') traceBox?.scrollBy(PAGE)
})
return (
@ -44,34 +50,75 @@ export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: ()
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.accent}>
<b>
Agents · {props.subagents.length} subagent{props.subagents.length === 1 ? '' : 's'}
Agents · {count()} subagent{count() === 1 ? '' : 's'}
</b>
</text>
</box>
<box style={{ flexGrow: 1, minHeight: 0 }}>
<scrollbox ref={el => (box = el)} style={{ flexGrow: 1, minHeight: 0 }}>
<Show
when={props.subagents.length > 0}
fallback={<text fg={theme().color.muted}>No subagents yet delegate a task to spawn one.</text>}
>
<For each={props.subagents}>
{sa => (
<text>
<span style={{ fg: theme().color.muted }}>{' '.repeat(Math.max(0, sa.depth))}</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.model ? ` (${sa.model})` : ''}
{sa.lastTool ? `${sa.lastTool}` : ''}
</span>
</text>
)}
</For>
</Show>
</scrollbox>
{/* master: the subagent list (↑/↓ select) */}
<box style={{ flexShrink: 0, flexDirection: 'column', maxHeight: 10 }}>
<Show
when={count() > 0}
fallback={<text fg={theme().color.muted}>No subagents yet delegate a task to spawn one.</text>}
>
<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>
)}
</For>
</Show>
</box>
{/* detail: the selected subagent's live trace */}
<box style={{ flexGrow: 1, minHeight: 0, flexDirection: 'column', borderColor: theme().color.border }} border>
<Show when={current()} fallback={<text fg={theme().color.muted}> </text>}>
{sa => (
<>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text>
<span style={{ fg: theme().color.label }}>{sa().goal || sa().id}</span>
<span style={{ fg: statusColor(sa().status, theme) }}>{` · ${sa().status}`}</span>
<span style={{ fg: theme().color.muted }}>{sa().model ? ` · ${sa().model}` : ''}</span>
</text>
</box>
<Show when={sa().thought}>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text>
<span style={{ fg: theme().color.muted }}>{`🧠 ${sa().thought}`}</span>
</text>
</box>
</Show>
<box style={{ flexGrow: 1, minHeight: 0, paddingLeft: 1 }}>
<scrollbox ref={el => (traceBox = el)} style={{ flexGrow: 1, minHeight: 0 }} stickyScroll stickyStart="bottom">
<Show
when={(sa().trace?.length ?? 0) > 0}
fallback={<text fg={theme().color.muted}>(no activity yet)</text>}
>
<For each={sa().trace ?? []}>
{line => (
<text>
<span style={{ fg: theme().color.muted }}>{line}</span>
</text>
)}
</For>
</Show>
</scrollbox>
</box>
</>
)}
</Show>
</box>
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
<text fg={theme().color.muted}>Esc/q close · /PgUp/PgDn scroll</text>
<text fg={theme().color.muted}>Esc/q close · select · PgUp/PgDn scroll trace</text>
</box>
</box>
)