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:
alt-glitch 2026-06-08 16:23:17 +00:00
parent 99b24f6747
commit c019a9d2d5
8 changed files with 238 additions and 10 deletions

View file

@ -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),

View file

@ -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 {

View file

@ -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,

View file

@ -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
})
})

View file

@ -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'] } } : {}

View file

@ -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()

View file

@ -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>
)
}

View 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
* useKeyboardscrollBy/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>
)
}