mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-10 13:31:38 +00:00
feat(opentui-v2): Phase 5e — agents dashboard (7th first-class surface; ALL done)
The agents dashboard (spec §2b; Ink agentsOverlay) — the last first-class
interactive surface. Subagent delegations are tracked from the `subagent.*`
event stream and shown in a full-height overlay.
- store: subagents[] built from subagent.{spawn_requested,start,thinking,tool,
progress,complete} by subagent_id (status·goal·model·depth·lastTool·summary);
clearTranscript clears them. dashboard flag + openDashboard/closeDashboard.
- view/overlays/agentsDashboard.tsx: full-height overlay (replaces transcript+
composer), depth-indented subagent rows colored by status, scroll via
scrollBy/scrollTo, Esc/q close. Empty state prompts to delegate.
- view/App.tsx: content zone is now a <Switch> — pager / agents dashboard /
(transcript + input zone).
- logic/slash.ts: /agents, /tasks → openDashboard (SlashContext.openDashboard).
Verified: bun run check green (53 tests / 7 files) — subagent reducer + a
dashboard frame test (seeded tree renders, transcript replaced) + /agents
dispatch. LIVE tmux: /agents opened empty; then a REAL delegation spawned a
subagent → /agents showed "⛓ Agents · 1 subagent · ● completed <goal>
(model) ⚡terminal". ALL 7 first-class surfaces are now ✅+tested+smoked
(blocking prompts, pager, session switcher, model picker, skills hub,
completions, agents dashboard). Smoke P5e + matrix updated. Remaining: chrome
(5b), agent-feature polish (5d), launcher (8).
This commit is contained in:
parent
99b24f6747
commit
c019a9d2d5
8 changed files with 238 additions and 10 deletions
|
|
@ -167,6 +167,7 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
getLog()
|
||||
.tail(200)
|
||||
.map(e => `${e.scope}: ${e.msg}`),
|
||||
openDashboard: () => store.openDashboard(),
|
||||
openPager: (title, text) => store.openPager(title, text),
|
||||
openPicker: picker => store.openPicker(picker),
|
||||
openSwitcher: sessions => store.openSwitcher(sessions),
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export interface SlashContext {
|
|||
readonly openSwitcher: (sessions: SessionItem[]) => void
|
||||
/** Open a generic picker (model picker, skills hub). */
|
||||
readonly openPicker: (picker: PickerState) => void
|
||||
/** Open the agents dashboard (/agents, /tasks). */
|
||||
readonly openDashboard: () => void
|
||||
}
|
||||
|
||||
function readStr(value: unknown, key: string): string | undefined {
|
||||
|
|
@ -176,6 +178,7 @@ const skillsCmd: ClientHandler = async (_arg, ctx) => {
|
|||
|
||||
/** The TUI-only client commands (run in-process, never hit the gateway). */
|
||||
const CLIENT: Record<string, ClientHandler> = {
|
||||
agents: (_arg, ctx) => ctx.openDashboard(),
|
||||
clear: (_arg, ctx) => ctx.confirm('Clear the transcript?', ctx.clearTranscript),
|
||||
exit: (_arg, ctx) => ctx.quit(),
|
||||
model: modelCmd,
|
||||
|
|
@ -184,6 +187,7 @@ const CLIENT: Record<string, ClientHandler> = {
|
|||
sessions: openSwitcher,
|
||||
skills: skillsCmd,
|
||||
switch: openSwitcher,
|
||||
tasks: (_arg, ctx) => ctx.openDashboard(),
|
||||
help: async (_arg, ctx) => {
|
||||
// Prefer the live catalog; fall back to the client list if it's unavailable.
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -93,6 +93,18 @@ export interface CompletionItem {
|
|||
meta: string
|
||||
}
|
||||
|
||||
/** A delegated subagent, tracked from the `subagent.*` event stream (agents dashboard). */
|
||||
export interface SubagentInfo {
|
||||
id: string
|
||||
goal: string
|
||||
status: string
|
||||
depth: number
|
||||
model?: string
|
||||
parentId?: string
|
||||
summary?: string
|
||||
lastTool?: string
|
||||
}
|
||||
|
||||
export interface StoreState {
|
||||
ready: boolean
|
||||
messages: Message[]
|
||||
|
|
@ -107,6 +119,10 @@ export interface StoreState {
|
|||
picker: PickerState | undefined
|
||||
/** Live slash-completion candidates shown above the composer; undefined/empty when none. */
|
||||
completions: CompletionItem[] | undefined
|
||||
/** Delegated subagents (from `subagent.*`), shown in the agents dashboard. */
|
||||
subagents: SubagentInfo[]
|
||||
/** Whether the agents dashboard overlay is open (/agents). */
|
||||
dashboard: boolean
|
||||
}
|
||||
|
||||
const LRU_LIMIT = 1000
|
||||
|
|
@ -117,6 +133,21 @@ function readStr(payload: { readonly [k: string]: unknown }, key: string): strin
|
|||
return typeof v === 'string' ? v : undefined
|
||||
}
|
||||
|
||||
/** Read a number field off an unknown payload record. */
|
||||
function readNum(payload: { readonly [k: string]: unknown }, key: string): number {
|
||||
const v = payload[key]
|
||||
return typeof v === 'number' ? v : 0
|
||||
}
|
||||
|
||||
/** The subagent status implied by an event type (an explicit payload `status` wins). */
|
||||
function subagentStatusFor(type: string): string {
|
||||
if (type === 'subagent.complete') return 'complete'
|
||||
if (type === 'subagent.thinking') return 'thinking'
|
||||
if (type === 'subagent.tool') return 'tool'
|
||||
if (type === 'subagent.progress') return 'working'
|
||||
return 'running'
|
||||
}
|
||||
|
||||
export function createSessionStore() {
|
||||
const [state, setState] = createStore<StoreState>({
|
||||
ready: false,
|
||||
|
|
@ -126,7 +157,9 @@ export function createSessionStore() {
|
|||
pager: undefined,
|
||||
switcher: undefined,
|
||||
picker: undefined,
|
||||
completions: undefined
|
||||
completions: undefined,
|
||||
subagents: [],
|
||||
dashboard: false
|
||||
})
|
||||
|
||||
// Monotonic part id (stable `key` per part so a new tool part below a streaming
|
||||
|
|
@ -210,9 +243,18 @@ export function createSessionStore() {
|
|||
)
|
||||
}
|
||||
|
||||
/** Clear the transcript (e.g. /clear, /new). */
|
||||
/** Clear the transcript (e.g. /clear, /new) and any tracked subagents. */
|
||||
function clearTranscript() {
|
||||
setState('messages', [])
|
||||
setState('subagents', [])
|
||||
}
|
||||
|
||||
/** Open / close the agents dashboard overlay (/agents). */
|
||||
function openDashboard() {
|
||||
setState('dashboard', true)
|
||||
}
|
||||
function closeDashboard() {
|
||||
setState('dashboard', false)
|
||||
}
|
||||
|
||||
/** Open a local Y/N confirm dialog (non-gateway; e.g. /clear). */
|
||||
|
|
@ -381,8 +423,39 @@ export function createSessionStore() {
|
|||
requestId: event.payload.request_id
|
||||
})
|
||||
break
|
||||
// Other event types (chrome, subagents) are reduced in later phases;
|
||||
// unhandled members are intentionally ignored here.
|
||||
// ── subagents (agents dashboard) — track the delegation tree by id ──
|
||||
case 'subagent.spawn_requested':
|
||||
case 'subagent.start':
|
||||
case 'subagent.thinking':
|
||||
case 'subagent.tool':
|
||||
case 'subagent.progress':
|
||||
case 'subagent.complete': {
|
||||
const id = readStr(event.payload, 'subagent_id')
|
||||
if (!id) break
|
||||
setState(
|
||||
produce(draft => {
|
||||
let sa = draft.subagents.find(s => s.id === id)
|
||||
if (!sa) {
|
||||
sa = { depth: readNum(event.payload, 'depth'), goal: '', id, status: 'running' }
|
||||
draft.subagents.push(sa)
|
||||
}
|
||||
const goal = readStr(event.payload, 'goal')
|
||||
if (goal) sa.goal = goal
|
||||
const model = readStr(event.payload, 'model')
|
||||
if (model) sa.model = model
|
||||
const parent = readStr(event.payload, 'parent_id')
|
||||
if (parent) sa.parentId = parent
|
||||
const summary = readStr(event.payload, 'summary')
|
||||
if (summary) sa.summary = summary
|
||||
const tool = readStr(event.payload, 'tool_name')
|
||||
if (tool) sa.lastTool = tool
|
||||
sa.status = readStr(event.payload, 'status') ?? subagentStatusFor(event.type)
|
||||
})
|
||||
)
|
||||
break
|
||||
}
|
||||
// Other event types (chrome) are reduced in later phases; unhandled members
|
||||
// are intentionally ignored here.
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -428,6 +501,8 @@ export function createSessionStore() {
|
|||
closePicker,
|
||||
setCompletions,
|
||||
clearCompletions,
|
||||
openDashboard,
|
||||
closeDashboard,
|
||||
hydrate,
|
||||
beginBuffer,
|
||||
commitSnapshot,
|
||||
|
|
|
|||
|
|
@ -170,4 +170,30 @@ describe('App render (Phase 1, themed)', () => {
|
|||
expect(frame).toContain('compress context') // its meta
|
||||
expect(frame).toContain('Tab complete') // dropdown hint
|
||||
})
|
||||
|
||||
test('the agents dashboard renders the subagent tree and replaces the transcript', async () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'gateway.ready' })
|
||||
store.pushUser('parent turn')
|
||||
store.apply({
|
||||
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.openDashboard()
|
||||
|
||||
const frame = await captureFrame(
|
||||
() => (
|
||||
<ThemeProvider theme={() => store.state.theme}>
|
||||
<App store={store} />
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ until: 'Agents', width: 72, height: 18 }
|
||||
)
|
||||
|
||||
expect(frame).toContain('Agents') // dashboard header
|
||||
expect(frame).toContain('research the topic') // subagent goal
|
||||
expect(frame).toContain('web_search') // its last tool
|
||||
expect(frame).not.toContain('parent turn') // transcript replaced by the dashboard
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ interface Probe {
|
|||
pickers: Array<{ title: string; items: PickerItem[]; onPick: (value: string) => void }>
|
||||
quit: { value: boolean }
|
||||
cleared: { value: boolean }
|
||||
dashboard: { value: boolean }
|
||||
}
|
||||
|
||||
function makeCtx(request: (method: string, params: Record<string, unknown>) => Promise<unknown>): Probe {
|
||||
|
|
@ -54,11 +55,13 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
const pickers: Probe['pickers'] = []
|
||||
const quit = { value: false }
|
||||
const cleared = { value: false }
|
||||
const dashboard = { value: false }
|
||||
const ctx: SlashContext = {
|
||||
clearTranscript: () => (cleared.value = true),
|
||||
confirm: (message, onConfirm) => confirmed.push({ message, onConfirm }),
|
||||
listSessions: () => Promise.resolve(FAKE_SESSIONS),
|
||||
logTail: () => ['gateway: spawned', 'bootstrap: session created'],
|
||||
openDashboard: () => (dashboard.value = true),
|
||||
openPager: (title, text) => paged.push({ text, title }),
|
||||
openPicker: p => pickers.push(p),
|
||||
openSwitcher: sessions => switched.push(sessions),
|
||||
|
|
@ -71,7 +74,7 @@ function makeCtx(request: (method: string, params: Record<string, unknown>) => P
|
|||
sessionId: () => 'sid-1',
|
||||
submit: text => submitted.push(text)
|
||||
}
|
||||
return { calls, cleared, confirmed, ctx, paged, pickers, quit, submitted, switched, system }
|
||||
return { calls, cleared, confirmed, ctx, dashboard, paged, pickers, quit, submitted, switched, system }
|
||||
}
|
||||
|
||||
describe('dispatchSlash — client commands', () => {
|
||||
|
|
@ -147,6 +150,15 @@ describe('dispatchSlash — client commands', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test('/agents (and /tasks) open the agents dashboard', async () => {
|
||||
const p = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/agents', p.ctx)
|
||||
expect(p.dashboard.value).toBe(true)
|
||||
const p2 = makeCtx(async () => ({}))
|
||||
await dispatchSlash('/tasks', p2.ctx)
|
||||
expect(p2.dashboard.value).toBe(true)
|
||||
})
|
||||
|
||||
test('/skills opens a picker flattened from skills.manage list', async () => {
|
||||
const p = makeCtx(async method =>
|
||||
method === 'skills.manage' ? { skills: { media: ['ffmpeg', 'whisper'], web: ['firecrawl'] } } : {}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,32 @@ describe('session store — blocking prompts (Phase 3)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('session store — subagents (Phase 5e agents dashboard)', () => {
|
||||
test('subagent.* events build + update a subagent by id', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({
|
||||
type: 'subagent.start',
|
||||
payload: { subagent_id: 'a1', goal: 'research X', model: 'haiku', depth: 1 }
|
||||
})
|
||||
expect(store.state.subagents).toHaveLength(1)
|
||||
expect(store.state.subagents[0]).toMatchObject({ id: 'a1', goal: 'research X', status: 'running', depth: 1 })
|
||||
|
||||
store.apply({ type: 'subagent.tool', payload: { subagent_id: 'a1', tool_name: 'web_search' } })
|
||||
expect(store.state.subagents[0]).toMatchObject({ status: 'tool', lastTool: 'web_search' })
|
||||
|
||||
store.apply({ type: 'subagent.complete', payload: { subagent_id: 'a1', summary: 'found it' } })
|
||||
expect(store.state.subagents).toHaveLength(1) // updated in place
|
||||
expect(store.state.subagents[0]).toMatchObject({ status: 'complete', summary: 'found it' })
|
||||
})
|
||||
|
||||
test('clearTranscript also clears subagents', () => {
|
||||
const store = createSessionStore()
|
||||
store.apply({ type: 'subagent.start', payload: { subagent_id: 'a1', goal: 'g' } })
|
||||
store.clearTranscript()
|
||||
expect(store.state.subagents).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session store — resume hydrate (Phase 4b)', () => {
|
||||
test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => {
|
||||
const store = createSessionStore()
|
||||
|
|
|
|||
|
|
@ -13,11 +13,12 @@
|
|||
* refocuses when an overlay closes; the key that closed an overlay can't leak
|
||||
* into it because the close is deferred a tick.
|
||||
*/
|
||||
import { Match, Show, Switch } from 'solid-js'
|
||||
import { Match, Switch } from 'solid-js'
|
||||
|
||||
import type { SessionStore } from '../logic/store.ts'
|
||||
import { Composer } from './composer.tsx'
|
||||
import { Header } from './header.tsx'
|
||||
import { AgentsDashboard } from './overlays/agentsDashboard.tsx'
|
||||
import { Pager } from './overlays/pager.tsx'
|
||||
import { Picker } from './overlays/picker.tsx'
|
||||
import { SessionSwitcher } from './overlays/sessionSwitcher.tsx'
|
||||
|
|
@ -41,11 +42,13 @@ const NO_SESSION = () => undefined
|
|||
export function App(props: AppProps) {
|
||||
const blocked = () => props.store.state.prompt !== undefined
|
||||
const pager = () => props.store.state.pager
|
||||
const dashboard = () => props.store.state.dashboard
|
||||
const switcher = () => props.store.state.switcher
|
||||
const picker = () => props.store.state.picker
|
||||
// Defer the close so the key that closed an overlay (Esc/q/Enter) can't land in
|
||||
// the freshly-remounted composer.
|
||||
const closePager = () => setTimeout(() => props.store.closePager(), 0)
|
||||
const closeDashboard = () => setTimeout(() => props.store.closeDashboard(), 0)
|
||||
const closeSwitcher = () => setTimeout(() => props.store.closeSwitcher(), 0)
|
||||
const closePicker = () => setTimeout(() => props.store.closePicker(), 0)
|
||||
const resume = (id: string) => {
|
||||
|
|
@ -56,8 +59,8 @@ export function App(props: AppProps) {
|
|||
return (
|
||||
<box style={{ flexDirection: 'column', flexGrow: 1, padding: 1 }}>
|
||||
<Header store={props.store} />
|
||||
<Show
|
||||
when={pager()}
|
||||
{/* content zone: a full-screen overlay (pager / agents dashboard) OR the transcript + input zone */}
|
||||
<Switch
|
||||
fallback={
|
||||
<>
|
||||
<Transcript store={props.store} />
|
||||
|
|
@ -98,8 +101,11 @@ export function App(props: AppProps) {
|
|||
</>
|
||||
}
|
||||
>
|
||||
{p => <Pager title={p().title} text={p().text} onClose={closePager} />}
|
||||
</Show>
|
||||
<Match when={pager()}>{p => <Pager title={p().title} text={p().text} onClose={closePager} />}</Match>
|
||||
<Match when={dashboard()}>
|
||||
<AgentsDashboard subagents={props.store.state.subagents} onClose={closeDashboard} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
78
ui-tui-opentui-v2/src/view/overlays/agentsDashboard.tsx
Normal file
78
ui-tui-opentui-v2/src/view/overlays/agentsDashboard.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { type ScrollBoxRenderable } from '@opentui/core'
|
||||
import { useKeyboard } from '@opentui/solid'
|
||||
import { For, Show } from 'solid-js'
|
||||
|
||||
import type { SubagentInfo } from '../../logic/store.ts'
|
||||
import { useTheme } from '../theme.tsx'
|
||||
|
||||
const PAGE = 10
|
||||
|
||||
function statusColor(status: string, theme: ReturnType<typeof useTheme>): string {
|
||||
const c = theme().color
|
||||
if (status === 'complete') return c.ok
|
||||
if (status === 'tool' || status === 'working') return c.accent
|
||||
if (status.includes('error') || status === 'failed') return c.error
|
||||
return c.warn
|
||||
}
|
||||
|
||||
export function AgentsDashboard(props: { subagents: SubagentInfo[]; onClose: () => void }) {
|
||||
const theme = useTheme()
|
||||
let box: ScrollBoxRenderable | undefined
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
return (
|
||||
<box style={{ borderColor: theme().color.accent, flexDirection: 'column', flexGrow: 1, minHeight: 0 }} border>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.accent}>
|
||||
<b>
|
||||
⛓ Agents · {props.subagents.length} subagent{props.subagents.length === 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>
|
||||
</box>
|
||||
<box style={{ flexShrink: 0, paddingLeft: 1 }}>
|
||||
<text fg={theme().color.muted}>Esc/q close · ↑↓/PgUp/PgDn scroll</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue