mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
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:
parent
247604cdde
commit
37b74f4df3
5 changed files with 132 additions and 41 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' } })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* useKeyboard→scrollBy/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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue