From c019a9d2d58d4eab6757bc99f60101520a141816 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Mon, 8 Jun 2026 16:23:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(opentui-v2):=20Phase=205e=20=E2=80=94=20ag?= =?UTF-8?q?ents=20dashboard=20(7th=20first-class=20surface;=20ALL=20done)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 — 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 (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). --- ui-tui-opentui-v2/src/entry/main.tsx | 1 + ui-tui-opentui-v2/src/logic/slash.ts | 4 + ui-tui-opentui-v2/src/logic/store.ts | 83 ++++++++++++++++++- ui-tui-opentui-v2/src/test/render.test.tsx | 26 ++++++ ui-tui-opentui-v2/src/test/slash.test.ts | 14 +++- ui-tui-opentui-v2/src/test/store.test.ts | 26 ++++++ ui-tui-opentui-v2/src/view/App.tsx | 16 ++-- .../src/view/overlays/agentsDashboard.tsx | 78 +++++++++++++++++ 8 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 ui-tui-opentui-v2/src/view/overlays/agentsDashboard.tsx diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index f7733b910d4..e4f981f8367 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -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), diff --git a/ui-tui-opentui-v2/src/logic/slash.ts b/ui-tui-opentui-v2/src/logic/slash.ts index f7dc52e0d6a..2401888a7f8 100644 --- a/ui-tui-opentui-v2/src/logic/slash.ts +++ b/ui-tui-opentui-v2/src/logic/slash.ts @@ -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 = { + 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 = { 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 { diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index 92a13a2f7f5..1ce8a7ae25d 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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({ 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, diff --git a/ui-tui-opentui-v2/src/test/render.test.tsx b/ui-tui-opentui-v2/src/test/render.test.tsx index 39fcf03bcd2..5ce5f1181e3 100644 --- a/ui-tui-opentui-v2/src/test/render.test.tsx +++ b/ui-tui-opentui-v2/src/test/render.test.tsx @@ -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( + () => ( + store.state.theme}> + + + ), + { 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 + }) }) diff --git a/ui-tui-opentui-v2/src/test/slash.test.ts b/ui-tui-opentui-v2/src/test/slash.test.ts index 737b6908808..f094e65d050 100644 --- a/ui-tui-opentui-v2/src/test/slash.test.ts +++ b/ui-tui-opentui-v2/src/test/slash.test.ts @@ -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) => Promise): Probe { @@ -54,11 +55,13 @@ function makeCtx(request: (method: string, params: Record) => 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) => 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'] } } : {} diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 0452a9ae19d..3249dd2fb6f 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -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() diff --git a/ui-tui-opentui-v2/src/view/App.tsx b/ui-tui-opentui-v2/src/view/App.tsx index af00ac2cbb6..43829510465 100644 --- a/ui-tui-opentui-v2/src/view/App.tsx +++ b/ui-tui-opentui-v2/src/view/App.tsx @@ -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 (
- @@ -98,8 +101,11 @@ export function App(props: AppProps) { } > - {p => } - + {p => } + + + + ) } diff --git a/ui-tui-opentui-v2/src/view/overlays/agentsDashboard.tsx b/ui-tui-opentui-v2/src/view/overlays/agentsDashboard.tsx new file mode 100644 index 00000000000..ad84bbd085c --- /dev/null +++ b/ui-tui-opentui-v2/src/view/overlays/agentsDashboard.tsx @@ -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): 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 ( + + + + + ⛓ Agents · {props.subagents.length} subagent{props.subagents.length === 1 ? '' : 's'} + + + + + (box = el)} style={{ flexGrow: 1, minHeight: 0 }}> + 0} + fallback={No subagents yet — delegate a task to spawn one.} + > + + {sa => ( + + {' '.repeat(Math.max(0, sa.depth))} + {`● ${sa.status}`} + {` ${sa.goal || sa.id}`} + + {sa.model ? ` (${sa.model})` : ''} + {sa.lastTool ? ` ⚡${sa.lastTool}` : ''} + + + )} + + + + + + Esc/q close · ↑↓/PgUp/PgDn scroll + + + ) +}