mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge pull request #14045 from NousResearch/bb/subagent-observability
feat(tui): subagent spawn observability overlay
This commit is contained in:
commit
bc5da42b2c
20 changed files with 4550 additions and 473 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
410
ui-tui/src/__tests__/subagentTree.test.ts
Normal file
410
ui-tui/src/__tests__/subagentTree.test.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildSubagentTree,
|
||||||
|
descendantIds,
|
||||||
|
flattenTree,
|
||||||
|
fmtCost,
|
||||||
|
fmtDuration,
|
||||||
|
fmtTokens,
|
||||||
|
formatSummary,
|
||||||
|
hotnessBucket,
|
||||||
|
peakHotness,
|
||||||
|
sparkline,
|
||||||
|
topLevelSubagents,
|
||||||
|
treeTotals,
|
||||||
|
widthByDepth
|
||||||
|
} from '../lib/subagentTree.js'
|
||||||
|
import type { SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
|
||||||
|
depth: 0,
|
||||||
|
goal: overrides.id,
|
||||||
|
notes: [],
|
||||||
|
parentId: null,
|
||||||
|
status: 'running',
|
||||||
|
taskCount: 1,
|
||||||
|
thinking: [],
|
||||||
|
toolCount: 0,
|
||||||
|
tools: [],
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aggregate: tokens, cost, files, hotness', () => {
|
||||||
|
it('sums tokens and cost across subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
|
||||||
|
makeItem({
|
||||||
|
costUsd: 0.005,
|
||||||
|
depth: 1,
|
||||||
|
id: 'c1',
|
||||||
|
index: 0,
|
||||||
|
inputTokens: 500,
|
||||||
|
outputTokens: 100,
|
||||||
|
parentId: 'p'
|
||||||
|
}),
|
||||||
|
makeItem({
|
||||||
|
costUsd: 0.008,
|
||||||
|
depth: 1,
|
||||||
|
id: 'c2',
|
||||||
|
index: 1,
|
||||||
|
inputTokens: 300,
|
||||||
|
outputTokens: 200,
|
||||||
|
parentId: 'p'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate).toMatchObject({
|
||||||
|
costUsd: 0.023,
|
||||||
|
inputTokens: 1800,
|
||||||
|
outputTokens: 800
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts files read + written across subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.filesTouched).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hotness = totalTools / totalDuration', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({
|
||||||
|
durationSeconds: 10,
|
||||||
|
id: 'p',
|
||||||
|
index: 0,
|
||||||
|
status: 'completed',
|
||||||
|
toolCount: 20
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hotness is zero when duration is zero', () => {
|
||||||
|
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.hotness).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('hotnessBucket + peakHotness', () => {
|
||||||
|
it('peakHotness walks subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
|
||||||
|
makeItem({
|
||||||
|
depth: 1,
|
||||||
|
durationSeconds: 1,
|
||||||
|
id: 'c',
|
||||||
|
index: 0,
|
||||||
|
parentId: 'p',
|
||||||
|
status: 'completed',
|
||||||
|
toolCount: 5
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(peakHotness(tree)).toBeGreaterThan(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hotnessBucket clamps and normalizes', () => {
|
||||||
|
expect(hotnessBucket(0, 10, 4)).toBe(0)
|
||||||
|
expect(hotnessBucket(10, 10, 4)).toBe(3)
|
||||||
|
expect(hotnessBucket(5, 10, 4)).toBe(2)
|
||||||
|
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
|
||||||
|
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fmtCost + fmtTokens', () => {
|
||||||
|
it('fmtCost handles ranges', () => {
|
||||||
|
expect(fmtCost(0)).toBe('')
|
||||||
|
expect(fmtCost(0.001)).toBe('<$0.01')
|
||||||
|
expect(fmtCost(0.42)).toBe('$0.42')
|
||||||
|
expect(fmtCost(1.23)).toBe('$1.23')
|
||||||
|
expect(fmtCost(12.5)).toBe('$12.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fmtTokens handles ranges', () => {
|
||||||
|
expect(fmtTokens(0)).toBe('0')
|
||||||
|
expect(fmtTokens(542)).toBe('542')
|
||||||
|
expect(fmtTokens(1234)).toBe('1.2k')
|
||||||
|
expect(fmtTokens(45678)).toBe('46k')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatSummary with tokens + cost', () => {
|
||||||
|
it('includes token + cost when present', () => {
|
||||||
|
expect(
|
||||||
|
formatSummary({
|
||||||
|
activeCount: 0,
|
||||||
|
costUsd: 0.42,
|
||||||
|
descendantCount: 3,
|
||||||
|
filesTouched: 0,
|
||||||
|
hotness: 0,
|
||||||
|
inputTokens: 8000,
|
||||||
|
maxDepthFromHere: 2,
|
||||||
|
outputTokens: 2000,
|
||||||
|
totalDuration: 30,
|
||||||
|
totalTools: 14
|
||||||
|
})
|
||||||
|
).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok · $0.42')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildSubagentTree', () => {
|
||||||
|
it('returns empty list for empty input', () => {
|
||||||
|
expect(buildSubagentTree([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats flat list as top-level when no parentId is given', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree).toHaveLength(3)
|
||||||
|
expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c'])
|
||||||
|
expect(tree.every(n => n.children.length === 0)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nests children under their parent by subagent_id', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'parent', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }),
|
||||||
|
makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree).toHaveLength(1)
|
||||||
|
expect(tree[0]!.children).toHaveLength(2)
|
||||||
|
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds multi-level nesting', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc')
|
||||||
|
expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2)
|
||||||
|
expect(tree[0]!.aggregate.descendantCount).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promotes orphaned children (missing parent) to top level', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree).toHaveLength(2)
|
||||||
|
expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stable sort: children ordered by (depth, index) not insert order', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('aggregate', () => {
|
||||||
|
it('sums tool counts and durations across subtree', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }),
|
||||||
|
makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }),
|
||||||
|
makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate).toMatchObject({
|
||||||
|
activeCount: 0,
|
||||||
|
descendantCount: 2,
|
||||||
|
totalDuration: 16,
|
||||||
|
totalTools: 9
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts queued + running as active', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0, status: 'running' }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' })
|
||||||
|
]
|
||||||
|
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(tree[0]!.aggregate.activeCount).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('widthByDepth', () => {
|
||||||
|
it('returns empty array for empty tree', () => {
|
||||||
|
expect(widthByDepth([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tallies nodes at each depth', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p1', index: 0 }),
|
||||||
|
makeItem({ id: 'p2', index: 1 }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }),
|
||||||
|
makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }),
|
||||||
|
makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' })
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('treeTotals', () => {
|
||||||
|
it('folds a full tree into a single rollup', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p1', index: 0, toolCount: 5 }),
|
||||||
|
makeItem({ id: 'p2', index: 1, toolCount: 2 }),
|
||||||
|
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 })
|
||||||
|
]
|
||||||
|
|
||||||
|
const totals = treeTotals(buildSubagentTree(items))
|
||||||
|
expect(totals.descendantCount).toBe(3)
|
||||||
|
expect(totals.totalTools).toBe(10)
|
||||||
|
expect(totals.maxDepthFromHere).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns zeros for empty tree', () => {
|
||||||
|
expect(treeTotals([])).toEqual({
|
||||||
|
activeCount: 0,
|
||||||
|
costUsd: 0,
|
||||||
|
descendantCount: 0,
|
||||||
|
filesTouched: 0,
|
||||||
|
hotness: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
maxDepthFromHere: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalTools: 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('flattenTree + descendantIds', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||||
|
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }),
|
||||||
|
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
it('flattens in visit order (depth-first, pre-order)', () => {
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collects descendant ids excluding the node itself', () => {
|
||||||
|
const tree = buildSubagentTree(items)
|
||||||
|
expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sparkline', () => {
|
||||||
|
it('returns empty string for empty input', () => {
|
||||||
|
expect(sparkline([])).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders zeroes as spaces (not bottom glyph)', () => {
|
||||||
|
expect(sparkline([0, 0])).toBe(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scales to the max value', () => {
|
||||||
|
const out = sparkline([1, 8])
|
||||||
|
expect(out).toHaveLength(2)
|
||||||
|
expect(out[1]).toBe('█')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sparse widths render as expected', () => {
|
||||||
|
const out = sparkline([2, 3, 7, 4])
|
||||||
|
expect(out).toHaveLength(4)
|
||||||
|
expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatSummary', () => {
|
||||||
|
const emptyTotals = {
|
||||||
|
activeCount: 0,
|
||||||
|
costUsd: 0,
|
||||||
|
descendantCount: 0,
|
||||||
|
filesTouched: 0,
|
||||||
|
hotness: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
maxDepthFromHere: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
totalDuration: 0,
|
||||||
|
totalTools: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
it('collapses zero-valued components', () => {
|
||||||
|
expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits rich summary with all pieces', () => {
|
||||||
|
expect(
|
||||||
|
formatSummary({
|
||||||
|
...emptyTotals,
|
||||||
|
activeCount: 2,
|
||||||
|
descendantCount: 7,
|
||||||
|
maxDepthFromHere: 3,
|
||||||
|
totalDuration: 134,
|
||||||
|
totalTools: 124
|
||||||
|
})
|
||||||
|
).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('fmtDuration', () => {
|
||||||
|
it('formats under a minute as plain seconds', () => {
|
||||||
|
expect(fmtDuration(0)).toBe('0s')
|
||||||
|
expect(fmtDuration(42)).toBe('42s')
|
||||||
|
expect(fmtDuration(59.4)).toBe('59s')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats whole minutes without trailing seconds', () => {
|
||||||
|
expect(fmtDuration(60)).toBe('1m')
|
||||||
|
expect(fmtDuration(180)).toBe('3m')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mixes minutes and seconds', () => {
|
||||||
|
expect(fmtDuration(134)).toBe('2m 14s')
|
||||||
|
expect(fmtDuration(605)).toBe('10m 5s')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('topLevelSubagents', () => {
|
||||||
|
it('returns items with no parent', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 })]
|
||||||
|
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'b'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes children whose parent is present', () => {
|
||||||
|
const items = [
|
||||||
|
makeItem({ id: 'p', index: 0 }),
|
||||||
|
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('promotes orphans whose parent is missing', () => {
|
||||||
|
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||||
|
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { STREAM_BATCH_MS } from '../config/timing.js'
|
import { STREAM_BATCH_MS } from '../config/timing.js'
|
||||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||||
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
import type { CommandsCatalogResponse, DelegationStatusResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
||||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||||
|
import { topLevelSubagents } from '../lib/subagentTree.js'
|
||||||
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
import { formatToolCall, stripAnsi } from '../lib/text.js'
|
||||||
import { fromSkin } from '../theme.js'
|
import { fromSkin } from '../theme.js'
|
||||||
import type { Msg, SubagentProgress } from '../types.js'
|
import type { Msg, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
|
||||||
import type { GatewayEventHandlerContext } from './interfaces.js'
|
import type { GatewayEventHandlerContext } from './interfaces.js'
|
||||||
import { patchOverlayState } from './overlayStore.js'
|
import { patchOverlayState } from './overlayStore.js'
|
||||||
import { turnController } from './turnController.js'
|
import { turnController } from './turnController.js'
|
||||||
|
|
@ -53,6 +55,55 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
let pendingThinkingStatus = ''
|
let pendingThinkingStatus = ''
|
||||||
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
||||||
|
|
||||||
|
// Inject the disk-save callback into turnController so recordMessageComplete
|
||||||
|
// can fire-and-forget a persist without having to plumb a gateway ref around.
|
||||||
|
turnController.persistSpawnTree = async (subagents, sessionId) => {
|
||||||
|
try {
|
||||||
|
const startedAt = subagents.reduce<number>((min, s) => {
|
||||||
|
if (!s.startedAt) {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
|
||||||
|
return min === 0 ? s.startedAt : Math.min(min, s.startedAt)
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const top = topLevelSubagents(subagents)
|
||||||
|
.map(s => s.goal)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
|
||||||
|
const label = top.length ? top.join(' · ') : `${subagents.length} subagents`
|
||||||
|
|
||||||
|
await rpc('spawn_tree.save', {
|
||||||
|
finished_at: Date.now() / 1000,
|
||||||
|
label: label.slice(0, 120),
|
||||||
|
session_id: sessionId ?? 'default',
|
||||||
|
started_at: startedAt ? startedAt / 1000 : null,
|
||||||
|
subagents
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Persistence is best-effort; in-memory history is the authoritative
|
||||||
|
// same-session source. A write failure doesn't block the turn.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh delegation caps at most every 5s so the status bar HUD can
|
||||||
|
// render a /warning close to the configured cap without spamming the RPC.
|
||||||
|
let lastDelegationFetchAt = 0
|
||||||
|
|
||||||
|
const refreshDelegationStatus = (force = false) => {
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
if (!force && now - lastDelegationFetchAt < 5000) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDelegationFetchAt = now
|
||||||
|
rpc<DelegationStatusResponse>('delegation.status', {})
|
||||||
|
.then(r => applyDelegationStatus(r))
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const setStatus = (status: string) => {
|
const setStatus = (status: string) => {
|
||||||
pendingThinkingStatus = ''
|
pendingThinkingStatus = ''
|
||||||
|
|
||||||
|
|
@ -85,7 +136,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
}, ms)
|
}, ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
const keepCompletedElseRunning = (s: SubagentProgress['status']) => (s === 'completed' ? s : 'running')
|
// Terminal statuses are never overwritten by late-arriving live events —
|
||||||
|
// otherwise a stale `subagent.start` / `spawn_requested` can clobber a
|
||||||
|
// `failed` or `interrupted` terminal state (Copilot review #14045).
|
||||||
|
const isTerminalStatus = (s: SubagentProgress['status']) => s === 'completed' || s === 'failed' || s === 'interrupted'
|
||||||
|
|
||||||
|
const keepTerminalElseRunning = (s: SubagentProgress['status']) => (isTerminalStatus(s) ? s : 'running')
|
||||||
|
|
||||||
const handleReady = (skin?: GatewaySkin) => {
|
const handleReady = (skin?: GatewaySkin) => {
|
||||||
if (skin) {
|
if (skin) {
|
||||||
|
|
@ -260,32 +316,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
|
turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '')
|
||||||
|
|
||||||
return
|
return
|
||||||
|
case 'tool.complete': {
|
||||||
|
const inlineDiffText =
|
||||||
|
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
|
||||||
|
|
||||||
case 'tool.complete':
|
turnController.recordToolComplete(
|
||||||
{
|
ev.payload.tool_id,
|
||||||
const inlineDiffText =
|
ev.payload.name,
|
||||||
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
|
ev.payload.error,
|
||||||
|
inlineDiffText ? '' : ev.payload.summary
|
||||||
turnController.recordToolComplete(
|
)
|
||||||
ev.payload.tool_id,
|
|
||||||
ev.payload.name,
|
|
||||||
ev.payload.error,
|
|
||||||
inlineDiffText ? '' : ev.payload.summary
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!inlineDiffText) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep inline diffs attached to the assistant completion body so
|
|
||||||
// they render in the same message flow, not as a standalone system
|
|
||||||
// artifact that can look out-of-place around tool rows.
|
|
||||||
turnController.queueInlineDiff(inlineDiffText)
|
|
||||||
|
|
||||||
|
if (!inlineDiffText) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep inline diffs attached to the assistant completion body so
|
||||||
|
// they render in the same message flow, not as a standalone system
|
||||||
|
// artifact that can look out-of-place around tool rows.
|
||||||
|
turnController.queueInlineDiff(inlineDiffText)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case 'clarify.request':
|
case 'clarify.request':
|
||||||
patchOverlayState({
|
patchOverlayState({
|
||||||
|
|
@ -329,8 +381,23 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
case 'subagent.spawn_requested':
|
||||||
|
// Child built but not yet running (waiting on ThreadPoolExecutor slot).
|
||||||
|
// Preserve completed state if a later event races in before this one.
|
||||||
|
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'queued' }))
|
||||||
|
|
||||||
|
// Prime the status-bar HUD: fetch caps (once every 5s) so we can
|
||||||
|
// warn as depth/concurrency approaches the configured ceiling.
|
||||||
|
if (getDelegationState().maxSpawnDepth === null) {
|
||||||
|
refreshDelegationStatus(true)
|
||||||
|
} else {
|
||||||
|
refreshDelegationStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
case 'subagent.start':
|
case 'subagent.start':
|
||||||
turnController.upsertSubagent(ev.payload, () => ({ status: 'running' }))
|
turnController.upsertSubagent(ev.payload, c => (isTerminalStatus(c.status) ? {} : { status: 'running' }))
|
||||||
|
|
||||||
return
|
return
|
||||||
case 'subagent.thinking': {
|
case 'subagent.thinking': {
|
||||||
|
|
@ -340,10 +407,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
// Update-only: never resurrect subagents whose spawn_requested/start
|
||||||
status: keepCompletedElseRunning(c.status),
|
// we missed or that already flushed via message.complete.
|
||||||
thinking: pushThinking(c.thinking, text)
|
turnController.upsertSubagent(
|
||||||
}))
|
ev.payload,
|
||||||
|
c => ({
|
||||||
|
status: keepTerminalElseRunning(c.status),
|
||||||
|
thinking: pushThinking(c.thinking, text)
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -354,10 +427,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
ev.payload.tool_preview ?? ev.payload.text ?? ''
|
ev.payload.tool_preview ?? ev.payload.text ?? ''
|
||||||
)
|
)
|
||||||
|
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
turnController.upsertSubagent(
|
||||||
status: keepCompletedElseRunning(c.status),
|
ev.payload,
|
||||||
tools: pushTool(c.tools, line)
|
c => ({
|
||||||
}))
|
status: keepTerminalElseRunning(c.status),
|
||||||
|
tools: pushTool(c.tools, line)
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -369,20 +446,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
turnController.upsertSubagent(
|
||||||
notes: pushNote(c.notes, text),
|
ev.payload,
|
||||||
status: keepCompletedElseRunning(c.status)
|
c => ({
|
||||||
}))
|
notes: pushNote(c.notes, text),
|
||||||
|
status: keepTerminalElseRunning(c.status)
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'subagent.complete':
|
case 'subagent.complete':
|
||||||
turnController.upsertSubagent(ev.payload, c => ({
|
turnController.upsertSubagent(
|
||||||
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
|
ev.payload,
|
||||||
status: ev.payload.status ?? 'completed',
|
c => ({
|
||||||
summary: ev.payload.summary || ev.payload.text || c.summary
|
durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds,
|
||||||
}))
|
status: ev.payload.status ?? 'completed',
|
||||||
|
summary: ev.payload.summary || ev.payload.text || c.summary
|
||||||
|
}),
|
||||||
|
{ createIfMissing: false }
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
77
ui-tui/src/app/delegationStore.ts
Normal file
77
ui-tui/src/app/delegationStore.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
|
import type { DelegationStatusResponse } from '../gatewayTypes.js'
|
||||||
|
|
||||||
|
export interface DelegationState {
|
||||||
|
// Last known caps from `delegation.status` RPC. null until fetched.
|
||||||
|
maxConcurrentChildren: null | number
|
||||||
|
maxSpawnDepth: null | number
|
||||||
|
// True when spawning is globally paused (see tools/delegate_tool.py).
|
||||||
|
paused: boolean
|
||||||
|
// Monotonic clock of the last successful status fetch.
|
||||||
|
updatedAt: null | number
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildState = (): DelegationState => ({
|
||||||
|
maxConcurrentChildren: null,
|
||||||
|
maxSpawnDepth: null,
|
||||||
|
paused: false,
|
||||||
|
updatedAt: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export const $delegationState = atom<DelegationState>(buildState())
|
||||||
|
|
||||||
|
export const getDelegationState = () => $delegationState.get()
|
||||||
|
|
||||||
|
export const patchDelegationState = (next: Partial<DelegationState>) =>
|
||||||
|
$delegationState.set({ ...$delegationState.get(), ...next })
|
||||||
|
|
||||||
|
export const resetDelegationState = () => $delegationState.set(buildState())
|
||||||
|
|
||||||
|
// ── Overlay accordion open-state ──────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Lifted out of OverlaySection's local useState so collapse choices
|
||||||
|
// survive:
|
||||||
|
// - navigating to a different subagent (Detail remounts)
|
||||||
|
// - switching list ↔ detail mode (Detail unmounts in list mode)
|
||||||
|
// - walking history (←/→)
|
||||||
|
// Keyed by section title; missing entries fall back to the section's
|
||||||
|
// `defaultOpen` prop.
|
||||||
|
|
||||||
|
export const $overlaySectionsOpen = atom<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
export const toggleOverlaySection = (title: string, defaultOpen: boolean) => {
|
||||||
|
const state = $overlaySectionsOpen.get()
|
||||||
|
const current = title in state ? state[title]! : defaultOpen
|
||||||
|
|
||||||
|
$overlaySectionsOpen.set({ ...state, [title]: !current })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOverlaySectionOpen = (title: string, defaultOpen: boolean): boolean => {
|
||||||
|
const state = $overlaySectionsOpen.get()
|
||||||
|
|
||||||
|
return title in state ? state[title]! : defaultOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge a raw RPC response into the store. Tolerant of partial/omitted fields. */
|
||||||
|
export const applyDelegationStatus = (r: DelegationStatusResponse | null | undefined) => {
|
||||||
|
if (!r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Partial<DelegationState> = { updatedAt: Date.now() }
|
||||||
|
|
||||||
|
if (typeof r.max_spawn_depth === 'number') {
|
||||||
|
patch.maxSpawnDepth = r.max_spawn_depth
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof r.max_concurrent_children === 'number') {
|
||||||
|
patch.maxConcurrentChildren = r.max_concurrent_children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof r.paused === 'boolean') {
|
||||||
|
patch.paused = r.paused
|
||||||
|
}
|
||||||
|
|
||||||
|
patchDelegationState(patch)
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,8 @@ export interface GatewayProviderProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayState {
|
export interface OverlayState {
|
||||||
|
agents: boolean
|
||||||
|
agentsInitialHistoryIndex: number
|
||||||
approval: ApprovalReq | null
|
approval: ApprovalReq | null
|
||||||
clarify: ClarifyReq | null
|
clarify: ClarifyReq | null
|
||||||
confirm: ConfirmReq | null
|
confirm: ConfirmReq | null
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { atom, computed } from 'nanostores'
|
||||||
import type { OverlayState } from './interfaces.js'
|
import type { OverlayState } from './interfaces.js'
|
||||||
|
|
||||||
const buildOverlayState = (): OverlayState => ({
|
const buildOverlayState = (): OverlayState => ({
|
||||||
|
agents: false,
|
||||||
|
agentsInitialHistoryIndex: 0,
|
||||||
approval: null,
|
approval: null,
|
||||||
clarify: null,
|
clarify: null,
|
||||||
confirm: null,
|
confirm: null,
|
||||||
|
|
@ -18,8 +20,8 @@ export const $overlayState = atom<OverlayState>(buildOverlayState())
|
||||||
|
|
||||||
export const $isBlocked = computed(
|
export const $isBlocked = computed(
|
||||||
$overlayState,
|
$overlayState,
|
||||||
({ approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
({ agents, approval, clarify, confirm, modelPicker, pager, picker, secret, skillsHub, sudo }) =>
|
||||||
Boolean(approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
Boolean(agents || approval || clarify || confirm || modelPicker || pager || picker || secret || skillsHub || sudo)
|
||||||
)
|
)
|
||||||
|
|
||||||
export const getOverlayState = () => $overlayState.get()
|
export const getOverlayState = () => $overlayState.get()
|
||||||
|
|
@ -27,4 +29,23 @@ export const getOverlayState = () => $overlayState.get()
|
||||||
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
|
export const patchOverlayState = (next: Partial<OverlayState> | ((state: OverlayState) => OverlayState)) =>
|
||||||
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
|
$overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next })
|
||||||
|
|
||||||
|
/** Full reset — used by session/turn teardown and tests. */
|
||||||
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
|
export const resetOverlayState = () => $overlayState.set(buildOverlayState())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft reset: drop FLOW-scoped overlays (approval / clarify / confirm / sudo
|
||||||
|
* / secret / pager) but PRESERVE user-toggled ones — agents dashboard, model
|
||||||
|
* picker, skills hub, session picker. Those are opened deliberately and
|
||||||
|
* shouldn't vanish when a turn ends. Called from turnController.idle() on
|
||||||
|
* every turn completion / interrupt; the old "reset everything" behaviour
|
||||||
|
* silently closed /agents the moment delegation finished.
|
||||||
|
*/
|
||||||
|
export const resetFlowOverlays = () =>
|
||||||
|
$overlayState.set({
|
||||||
|
...buildOverlayState(),
|
||||||
|
agents: $overlayState.get().agents,
|
||||||
|
agentsInitialHistoryIndex: $overlayState.get().agentsInitialHistoryIndex,
|
||||||
|
modelPicker: $overlayState.get().modelPicker,
|
||||||
|
picker: $overlayState.get().picker,
|
||||||
|
skillsHub: $overlayState.get().skillsHub
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
import type { SlashExecResponse, ToolsConfigureResponse } from '../../../gatewayTypes.js'
|
import type {
|
||||||
|
DelegationPauseResponse,
|
||||||
|
SlashExecResponse,
|
||||||
|
SpawnTreeListResponse,
|
||||||
|
SpawnTreeLoadResponse,
|
||||||
|
ToolsConfigureResponse
|
||||||
|
} from '../../../gatewayTypes.js'
|
||||||
import type { PanelSection } from '../../../types.js'
|
import type { PanelSection } from '../../../types.js'
|
||||||
|
import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js'
|
||||||
import { patchOverlayState } from '../../overlayStore.js'
|
import { patchOverlayState } from '../../overlayStore.js'
|
||||||
|
import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js'
|
||||||
import type { SlashCommand } from '../types.js'
|
import type { SlashCommand } from '../types.js'
|
||||||
|
|
||||||
interface SkillInfo {
|
interface SkillInfo {
|
||||||
|
|
@ -42,6 +50,163 @@ interface SkillsBrowseResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const opsCommands: SlashCommand[] = [
|
export const opsCommands: SlashCommand[] = [
|
||||||
|
{
|
||||||
|
aliases: ['tasks'],
|
||||||
|
help: 'open the spawn-tree dashboard (live audit + kill/pause controls)',
|
||||||
|
name: 'agents',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const sub = arg.trim().toLowerCase()
|
||||||
|
|
||||||
|
// Stay compatible with the gateway `/agents [pause|resume|status]` CLI —
|
||||||
|
// explicit subcommands skip the overlay and act directly so scripts and
|
||||||
|
// multi-step flows can drive it without entering interactive mode.
|
||||||
|
if (sub === 'pause' || sub === 'resume' || sub === 'unpause') {
|
||||||
|
const paused = sub === 'pause'
|
||||||
|
ctx.gateway.gw
|
||||||
|
.request<DelegationPauseResponse>('delegation.pause', { paused })
|
||||||
|
.then(r => {
|
||||||
|
applyDelegationStatus({ paused: r?.paused })
|
||||||
|
ctx.transcript.sys(`delegation · ${r?.paused ? 'paused' : 'resumed'}`)
|
||||||
|
})
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'status') {
|
||||||
|
const d = getDelegationState()
|
||||||
|
ctx.transcript.sys(
|
||||||
|
`delegation · ${d.paused ? 'paused' : 'active'} · caps d${d.maxSpawnDepth ?? '?'}/${d.maxConcurrentChildren ?? '?'}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
help: 'replay a completed spawn tree · `/replay [N|last|list|load <path>]`',
|
||||||
|
name: 'replay',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const history = getSpawnHistory()
|
||||||
|
const raw = arg.trim()
|
||||||
|
const lower = raw.toLowerCase()
|
||||||
|
|
||||||
|
// ── Disk-backed listing ─────────────────────────────────────
|
||||||
|
if (lower === 'list' || lower === 'ls') {
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<SpawnTreeListResponse>('spawn_tree.list', {
|
||||||
|
limit: 30,
|
||||||
|
session_id: ctx.sid ?? 'default'
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
ctx.guarded<SpawnTreeListResponse>(r => {
|
||||||
|
const entries = r.entries ?? []
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
return ctx.transcript.sys('no archived spawn trees on disk for this session')
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: [string, string][] = entries.map(e => {
|
||||||
|
const ts = e.finished_at ? new Date(e.finished_at * 1000).toLocaleString() : '?'
|
||||||
|
const label = e.label || `${e.count} subagents`
|
||||||
|
|
||||||
|
return [`${ts} · ${e.count}×`, `${label}\n ${e.path}`]
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.transcript.panel('Archived spawn trees', [{ rows }])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Disk-backed load by path ─────────────────────────────────
|
||||||
|
if (lower.startsWith('load ')) {
|
||||||
|
const path = raw.slice(5).trim()
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return ctx.transcript.sys('usage: /replay load <path>')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.gateway
|
||||||
|
.rpc<SpawnTreeLoadResponse>('spawn_tree.load', { path })
|
||||||
|
.then(
|
||||||
|
ctx.guarded<SpawnTreeLoadResponse>(r => {
|
||||||
|
if (!r.subagents?.length) {
|
||||||
|
return ctx.transcript.sys('snapshot empty or unreadable')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push onto the in-memory history so the overlay picks it up
|
||||||
|
// by index 1 just like any other snapshot.
|
||||||
|
pushDiskSnapshot(r, path)
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 1 })
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(ctx.guardedErr)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── In-memory nav (same-session) ─────────────────────────────
|
||||||
|
if (!history.length) {
|
||||||
|
return ctx.transcript.sys('no completed spawn trees this session · try /replay list')
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = 1
|
||||||
|
|
||||||
|
if (raw && lower !== 'last') {
|
||||||
|
const parsed = parseInt(raw, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(parsed) || parsed < 1 || parsed > history.length) {
|
||||||
|
return ctx.transcript.sys(`replay: index out of range 1..${history.length} · use /replay list for disk`)
|
||||||
|
}
|
||||||
|
|
||||||
|
index = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: index })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
help: 'diff two completed spawn trees · `/replay-diff <baseline> <candidate>` (indexes from /replay list or history N)',
|
||||||
|
name: 'replay-diff',
|
||||||
|
run: (arg, ctx) => {
|
||||||
|
const parts = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return ctx.transcript.sys('usage: /replay-diff <a> <b> (e.g. /replay-diff 1 2 for last two)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [a, b] = parts
|
||||||
|
const history = getSpawnHistory()
|
||||||
|
|
||||||
|
const resolve = (token: string): null | SpawnSnapshot => {
|
||||||
|
const n = parseInt(token!, 10)
|
||||||
|
|
||||||
|
if (Number.isFinite(n) && n >= 1 && n <= history.length) {
|
||||||
|
return history[n - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseline = resolve(a!)
|
||||||
|
const candidate = resolve(b!)
|
||||||
|
|
||||||
|
if (!baseline || !candidate) {
|
||||||
|
return ctx.transcript.sys(`replay-diff: could not resolve indices · history has ${history.length} entries`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setDiffPair({ baseline, candidate })
|
||||||
|
patchOverlayState({ agents: true, agentsInitialHistoryIndex: 0 })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
help: 'browse, inspect, install skills',
|
help: 'browse, inspect, install skills',
|
||||||
name: 'skills',
|
name: 'skills',
|
||||||
|
|
|
||||||
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
139
ui-tui/src/app/spawnHistoryStore.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { atom } from 'nanostores'
|
||||||
|
|
||||||
|
import type { SpawnTreeLoadResponse } from '../gatewayTypes.js'
|
||||||
|
import type { SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
export interface SpawnSnapshot {
|
||||||
|
finishedAt: number
|
||||||
|
fromDisk?: boolean
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
path?: string
|
||||||
|
sessionId: null | string
|
||||||
|
startedAt: number
|
||||||
|
subagents: SubagentProgress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnDiffPair {
|
||||||
|
baseline: SpawnSnapshot
|
||||||
|
candidate: SpawnSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_LIMIT = 10
|
||||||
|
|
||||||
|
export const $spawnHistory = atom<SpawnSnapshot[]>([])
|
||||||
|
export const $spawnDiff = atom<null | SpawnDiffPair>(null)
|
||||||
|
|
||||||
|
export const getSpawnHistory = () => $spawnHistory.get()
|
||||||
|
export const getSpawnDiff = () => $spawnDiff.get()
|
||||||
|
|
||||||
|
export const clearSpawnHistory = () => $spawnHistory.set([])
|
||||||
|
export const clearDiffPair = () => $spawnDiff.set(null)
|
||||||
|
export const setDiffPair = (pair: SpawnDiffPair) => $spawnDiff.set(pair)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit a finished turn's spawn tree to history. Keeps the last 10
|
||||||
|
* non-empty snapshots — empty turns (no subagents) are dropped.
|
||||||
|
*
|
||||||
|
* Why in-memory? The primary investigation loop is "I just ran a fan-out,
|
||||||
|
* it misbehaved, let me look at what happened" — same-session debugging.
|
||||||
|
* Disk persistence across process restarts is a natural extension but
|
||||||
|
* adds RPC surface for a less-common path.
|
||||||
|
*/
|
||||||
|
export const pushSnapshot = (
|
||||||
|
subagents: readonly SubagentProgress[],
|
||||||
|
meta: { sessionId?: null | string; startedAt?: null | number }
|
||||||
|
) => {
|
||||||
|
if (!subagents.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const started = meta.startedAt ?? Math.min(...subagents.map(s => s.startedAt ?? now))
|
||||||
|
|
||||||
|
const snap: SpawnSnapshot = {
|
||||||
|
finishedAt: now,
|
||||||
|
id: `snap-${now.toString(36)}`,
|
||||||
|
label: summarizeLabel(subagents),
|
||||||
|
sessionId: meta.sessionId ?? null,
|
||||||
|
startedAt: Number.isFinite(started) ? started : now,
|
||||||
|
subagents: subagents.map(item => ({ ...item }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||||
|
$spawnHistory.set(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeLabel(subagents: readonly SubagentProgress[]): string {
|
||||||
|
const top = subagents
|
||||||
|
.filter(s => s.parentId == null || subagents.every(o => o.id !== s.parentId))
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(s => s.goal || 'subagent')
|
||||||
|
.join(' · ')
|
||||||
|
|
||||||
|
return top || `${subagents.length} agent${subagents.length === 1 ? '' : 's'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push a disk-loaded snapshot onto the front of the history stack so the
|
||||||
|
* overlay can pick it up at index 1 via /replay load. Normalises the
|
||||||
|
* server payload (arbitrary list) into the same SubagentProgress shape
|
||||||
|
* used for live data — defensive against cross-version reads.
|
||||||
|
*/
|
||||||
|
export const pushDiskSnapshot = (r: SpawnTreeLoadResponse, path: string) => {
|
||||||
|
const raw = Array.isArray(r.subagents) ? r.subagents : []
|
||||||
|
const normalised = raw.map(normaliseSubagent)
|
||||||
|
|
||||||
|
if (!normalised.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const snap: SpawnSnapshot = {
|
||||||
|
finishedAt: (r.finished_at ?? Date.now() / 1000) * 1000,
|
||||||
|
fromDisk: true,
|
||||||
|
id: `disk-${path}`,
|
||||||
|
label: r.label || `${normalised.length} subagents`,
|
||||||
|
path,
|
||||||
|
sessionId: r.session_id ?? null,
|
||||||
|
startedAt: (r.started_at ?? r.finished_at ?? Date.now() / 1000) * 1000,
|
||||||
|
subagents: normalised
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [snap, ...$spawnHistory.get()].slice(0, HISTORY_LIMIT)
|
||||||
|
$spawnHistory.set(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseSubagent(raw: unknown): SubagentProgress {
|
||||||
|
const o = raw as Record<string, unknown>
|
||||||
|
const s = (v: unknown) => (typeof v === 'string' ? v : undefined)
|
||||||
|
const n = (v: unknown) => (typeof v === 'number' ? v : undefined)
|
||||||
|
const arr = <T>(v: unknown): T[] | undefined => (Array.isArray(v) ? (v as T[]) : undefined)
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiCalls: n(o.apiCalls),
|
||||||
|
costUsd: n(o.costUsd),
|
||||||
|
depth: typeof o.depth === 'number' ? o.depth : 0,
|
||||||
|
durationSeconds: n(o.durationSeconds),
|
||||||
|
filesRead: arr<string>(o.filesRead),
|
||||||
|
filesWritten: arr<string>(o.filesWritten),
|
||||||
|
goal: s(o.goal) ?? 'subagent',
|
||||||
|
id: s(o.id) ?? `sa-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
index: typeof o.index === 'number' ? o.index : 0,
|
||||||
|
inputTokens: n(o.inputTokens),
|
||||||
|
iteration: n(o.iteration),
|
||||||
|
model: s(o.model),
|
||||||
|
notes: (arr<string>(o.notes) ?? []).filter(x => typeof x === 'string'),
|
||||||
|
outputTail: arr(o.outputTail) as SubagentProgress['outputTail'],
|
||||||
|
outputTokens: n(o.outputTokens),
|
||||||
|
parentId: s(o.parentId) ?? null,
|
||||||
|
reasoningTokens: n(o.reasoningTokens),
|
||||||
|
startedAt: n(o.startedAt),
|
||||||
|
status: (s(o.status) as SubagentProgress['status']) ?? 'completed',
|
||||||
|
summary: s(o.summary),
|
||||||
|
taskCount: typeof o.taskCount === 'number' ? o.taskCount : 1,
|
||||||
|
thinking: (arr<string>(o.thinking) ?? []).filter(x => typeof x === 'string'),
|
||||||
|
toolCount: typeof o.toolCount === 'number' ? o.toolCount : 0,
|
||||||
|
tools: (arr<string>(o.tools) ?? []).filter(x => typeof x === 'string'),
|
||||||
|
toolsets: arr<string>(o.toolsets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,9 @@ import {
|
||||||
} from '../lib/text.js'
|
} from '../lib/text.js'
|
||||||
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
import { resetOverlayState } from './overlayStore.js'
|
import { resetFlowOverlays } from './overlayStore.js'
|
||||||
import { patchTurnState, resetTurnState } from './turnStore.js'
|
import { pushSnapshot } from './spawnHistoryStore.js'
|
||||||
|
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
|
||||||
import { getUiState, patchUiState } from './uiStore.js'
|
import { getUiState, patchUiState } from './uiStore.js'
|
||||||
|
|
||||||
const INTERRUPT_COOLDOWN_MS = 1500
|
const INTERRUPT_COOLDOWN_MS = 1500
|
||||||
|
|
@ -41,6 +42,7 @@ class TurnController {
|
||||||
lastStatusNote = ''
|
lastStatusNote = ''
|
||||||
pendingInlineDiffs: string[] = []
|
pendingInlineDiffs: string[] = []
|
||||||
persistedToolLabels = new Set<string>()
|
persistedToolLabels = new Set<string>()
|
||||||
|
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
|
||||||
protocolWarned = false
|
protocolWarned = false
|
||||||
reasoningText = ''
|
reasoningText = ''
|
||||||
segmentMessages: Msg[] = []
|
segmentMessages: Msg[] = []
|
||||||
|
|
@ -90,7 +92,7 @@ class TurnController {
|
||||||
turnTrail: []
|
turnTrail: []
|
||||||
})
|
})
|
||||||
patchUiState({ busy: false })
|
patchUiState({ busy: false })
|
||||||
resetOverlayState()
|
resetFlowOverlays()
|
||||||
}
|
}
|
||||||
|
|
||||||
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
|
interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) {
|
||||||
|
|
@ -189,9 +191,7 @@ class TurnController {
|
||||||
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
// leading "┊ review diff" header written by `_emit_inline_diff` for the
|
||||||
// terminal printer). That header only makes sense as stdout dressing,
|
// terminal printer). That header only makes sense as stdout dressing,
|
||||||
// not inside a markdown ```diff block.
|
// not inside a markdown ```diff block.
|
||||||
const text = diffText
|
const text = diffText.replace(/^\s*┊[^\n]*\n?/, '').trim()
|
||||||
.replace(/^\s*┊[^\n]*\n?/, '')
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
if (!text || this.pendingInlineDiffs.includes(text)) {
|
if (!text || this.pendingInlineDiffs.includes(text)) {
|
||||||
return
|
return
|
||||||
|
|
@ -249,12 +249,15 @@ class TurnController {
|
||||||
// markdown fence of its own — otherwise we render two stacked diff
|
// markdown fence of its own — otherwise we render two stacked diff
|
||||||
// blocks for the same edit.
|
// blocks for the same edit.
|
||||||
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
|
const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText)
|
||||||
|
|
||||||
const remainingInlineDiffs = assistantAlreadyHasDiff
|
const remainingInlineDiffs = assistantAlreadyHasDiff
|
||||||
? []
|
? []
|
||||||
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
|
: this.pendingInlineDiffs.filter(diff => !finalText.includes(diff))
|
||||||
|
|
||||||
const inlineDiffBlock = remainingInlineDiffs.length
|
const inlineDiffBlock = remainingInlineDiffs.length
|
||||||
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
|
? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\``
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
|
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
|
||||||
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
|
||||||
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
|
||||||
|
|
@ -276,6 +279,20 @@ class TurnController {
|
||||||
|
|
||||||
const wasInterrupted = this.interrupted
|
const wasInterrupted = this.interrupted
|
||||||
|
|
||||||
|
// Archive the turn's spawn tree to history BEFORE idle() drops subagents
|
||||||
|
// from turnState. Lets /replay and the overlay's history nav pull up
|
||||||
|
// finished fan-outs without a round-trip to disk.
|
||||||
|
const finishedSubagents = getTurnState().subagents
|
||||||
|
const sessionId = getUiState().sid
|
||||||
|
|
||||||
|
if (finishedSubagents.length > 0) {
|
||||||
|
pushSnapshot(finishedSubagents, { sessionId, startedAt: null })
|
||||||
|
// Fire-and-forget disk persistence so /replay survives process restarts.
|
||||||
|
// The same snapshot lives in memory via spawnHistoryStore for immediate
|
||||||
|
// recall — disk is the long-term archive.
|
||||||
|
void this.persistSpawnTree?.(finishedSubagents, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
this.idle()
|
this.idle()
|
||||||
this.clearReasoning()
|
this.clearReasoning()
|
||||||
this.turnTools = []
|
this.turnTools = []
|
||||||
|
|
@ -443,33 +460,82 @@ class TurnController {
|
||||||
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] })
|
||||||
}
|
}
|
||||||
|
|
||||||
upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial<SubagentProgress>) {
|
upsertSubagent(
|
||||||
const id = `sa:${p.task_index}:${p.goal || 'subagent'}`
|
p: SubagentEventPayload,
|
||||||
|
patch: (current: SubagentProgress) => Partial<SubagentProgress>,
|
||||||
|
opts: { createIfMissing?: boolean } = { createIfMissing: true }
|
||||||
|
) {
|
||||||
|
// Stable id: prefer the server-issued subagent_id (survives nested
|
||||||
|
// grandchildren + cross-tree joins). Fall back to the composite key
|
||||||
|
// for older gateways that omit the field — those produce a flat list.
|
||||||
|
const id = p.subagent_id || `sa:${p.task_index}:${p.goal || 'subagent'}`
|
||||||
|
|
||||||
patchTurnState(state => {
|
patchTurnState(state => {
|
||||||
const existing = state.subagents.find(item => item.id === id)
|
const existing = state.subagents.find(item => item.id === id)
|
||||||
|
|
||||||
|
// Late events (subagent.complete/tool/progress arriving after message.complete
|
||||||
|
// has already fired idle()) would otherwise resurrect a finished
|
||||||
|
// subagent into turn.subagents and block the "finished" title on the
|
||||||
|
// /agents overlay. When `createIfMissing` is false we drop silently.
|
||||||
|
if (!existing && !opts.createIfMissing) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
const base: SubagentProgress = existing ?? {
|
const base: SubagentProgress = existing ?? {
|
||||||
|
depth: p.depth ?? 0,
|
||||||
goal: p.goal,
|
goal: p.goal,
|
||||||
id,
|
id,
|
||||||
index: p.task_index,
|
index: p.task_index,
|
||||||
|
model: p.model,
|
||||||
notes: [],
|
notes: [],
|
||||||
|
parentId: p.parent_id ?? null,
|
||||||
|
startedAt: Date.now(),
|
||||||
status: 'running',
|
status: 'running',
|
||||||
taskCount: p.task_count ?? 1,
|
taskCount: p.task_count ?? 1,
|
||||||
thinking: [],
|
thinking: [],
|
||||||
tools: []
|
toolCount: p.tool_count ?? 0,
|
||||||
|
tools: [],
|
||||||
|
toolsets: p.toolsets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map snake_case payload keys onto camelCase state. Only overwrite
|
||||||
|
// when the event actually carries the field; `??` preserves prior
|
||||||
|
// values across streaming events that emit partial payloads.
|
||||||
|
const outputTail = p.output_tail
|
||||||
|
? p.output_tail.map(e => ({
|
||||||
|
isError: Boolean(e.is_error),
|
||||||
|
preview: String(e.preview ?? ''),
|
||||||
|
tool: String(e.tool ?? 'tool')
|
||||||
|
}))
|
||||||
|
: base.outputTail
|
||||||
|
|
||||||
const next: SubagentProgress = {
|
const next: SubagentProgress = {
|
||||||
...base,
|
...base,
|
||||||
|
apiCalls: p.api_calls ?? base.apiCalls,
|
||||||
|
costUsd: p.cost_usd ?? base.costUsd,
|
||||||
|
depth: p.depth ?? base.depth,
|
||||||
|
filesRead: p.files_read ?? base.filesRead,
|
||||||
|
filesWritten: p.files_written ?? base.filesWritten,
|
||||||
goal: p.goal || base.goal,
|
goal: p.goal || base.goal,
|
||||||
|
inputTokens: p.input_tokens ?? base.inputTokens,
|
||||||
|
iteration: p.iteration ?? base.iteration,
|
||||||
|
model: p.model ?? base.model,
|
||||||
|
outputTail,
|
||||||
|
outputTokens: p.output_tokens ?? base.outputTokens,
|
||||||
|
parentId: p.parent_id ?? base.parentId,
|
||||||
|
reasoningTokens: p.reasoning_tokens ?? base.reasoningTokens,
|
||||||
taskCount: p.task_count ?? base.taskCount,
|
taskCount: p.task_count ?? base.taskCount,
|
||||||
|
toolCount: p.tool_count ?? base.toolCount,
|
||||||
|
toolsets: p.toolsets ?? base.toolsets,
|
||||||
...patch(base)
|
...patch(base)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stable order: by spawn (depth, parent, index) rather than insert time.
|
||||||
|
// Without it, grandchildren can shuffle relative to siblings when
|
||||||
|
// events arrive out of order under high concurrency.
|
||||||
const subagents = existing
|
const subagents = existing
|
||||||
? state.subagents.map(item => (item.id === id ? next : item))
|
? state.subagents.map(item => (item.id === id ? next : item))
|
||||||
: [...state.subagents, next].sort((a, b) => a.index - b.index)
|
: [...state.subagents, next].sort((a, b) => a.depth - b.depth || a.index - b.index)
|
||||||
|
|
||||||
return { ...state, subagents }
|
return { ...state, subagents }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
if (overlay.picker) {
|
if (overlay.picker) {
|
||||||
return patchOverlayState({ picker: false })
|
return patchOverlayState({ picker: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (overlay.agents) {
|
||||||
|
return patchOverlayState({ agents: false })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleQueue = (dir: 1 | -1) => {
|
const cycleQueue = (dir: 1 | -1) => {
|
||||||
|
|
@ -180,6 +184,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
if (isCtrl(key, ch, 'c')) {
|
if (isCtrl(key, ch, 'c')) {
|
||||||
cancelOverlayFromCtrlC()
|
cancelOverlayFromCtrlC()
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,6 +295,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
if (key.upArrow && !cState.inputBuf.length) {
|
if (key.upArrow && !cState.inputBuf.length) {
|
||||||
const inputSel = getInputSelection()
|
const inputSel = getInputSelection()
|
||||||
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
|
const cursor = inputSel && inputSel.start === inputSel.end ? inputSel.start : null
|
||||||
|
|
||||||
const noLineAbove =
|
const noLineAbove =
|
||||||
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
|
!cState.input || (cursor !== null && cState.input.lastIndexOf('\n', Math.max(0, cursor - 1)) < 0)
|
||||||
|
|
||||||
|
|
|
||||||
1064
ui-tui/src/components/agentsOverlay.tsx
Normal file
1064
ui-tui/src/components/agentsOverlay.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,14 @@
|
||||||
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
import { Box, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||||
import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react'
|
import { useStore } from '@nanostores/react'
|
||||||
|
import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
import { $delegationState } from '../app/delegationStore.js'
|
||||||
|
import { $turnState } from '../app/turnStore.js'
|
||||||
import { FACES } from '../content/faces.js'
|
import { FACES } from '../content/faces.js'
|
||||||
import { VERBS } from '../content/verbs.js'
|
import { VERBS } from '../content/verbs.js'
|
||||||
import { fmtDuration } from '../domain/messages.js'
|
import { fmtDuration } from '../domain/messages.js'
|
||||||
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
import { stickyPromptFromViewport } from '../domain/viewport.js'
|
||||||
|
import { buildSubagentTree, treeTotals, widthByDepth } from '../lib/subagentTree.js'
|
||||||
import { fmtK } from '../lib/text.js'
|
import { fmtK } from '../lib/text.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { Msg, Usage } from '../types.js'
|
import type { Msg, Usage } from '../types.js'
|
||||||
|
|
@ -60,6 +64,67 @@ function ctxBar(pct: number | undefined, w = 10) {
|
||||||
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
return '█'.repeat(filled) + '░'.repeat(w - filled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpawnHud({ t }: { t: Theme }) {
|
||||||
|
// Tight HUD that only appears when the session is actually fanning out.
|
||||||
|
// Colour escalates to warn/error as depth or concurrency approaches the cap.
|
||||||
|
const delegation = useStore($delegationState)
|
||||||
|
const turn = useStore($turnState)
|
||||||
|
|
||||||
|
const tree = useMemo(() => buildSubagentTree(turn.subagents), [turn.subagents])
|
||||||
|
const totals = useMemo(() => treeTotals(tree), [tree])
|
||||||
|
|
||||||
|
if (!totals.descendantCount && !delegation.paused) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxDepth = delegation.maxSpawnDepth
|
||||||
|
const maxConc = delegation.maxConcurrentChildren
|
||||||
|
const depth = Math.max(0, totals.maxDepthFromHere)
|
||||||
|
const active = totals.activeCount
|
||||||
|
|
||||||
|
// `max_concurrent_children` is a per-parent cap, not a global one.
|
||||||
|
// `activeCount` sums every running agent across the tree and would
|
||||||
|
// over-warn for multi-orchestrator runs. The widest level of the tree
|
||||||
|
// is a closer proxy to "most concurrent spawns that could be hitting a
|
||||||
|
// single parent's slot budget".
|
||||||
|
const widestLevel = widthByDepth(tree).reduce((a, b) => Math.max(a, b), 0)
|
||||||
|
const depthRatio = maxDepth ? depth / maxDepth : 0
|
||||||
|
const concRatio = maxConc ? widestLevel / maxConc : 0
|
||||||
|
const ratio = Math.max(depthRatio, concRatio)
|
||||||
|
|
||||||
|
const color = delegation.paused || ratio >= 1 ? t.color.error : ratio >= 0.66 ? t.color.warn : t.color.dim
|
||||||
|
|
||||||
|
const pieces: string[] = []
|
||||||
|
|
||||||
|
if (delegation.paused) {
|
||||||
|
pieces.push('⏸ paused')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.descendantCount > 0) {
|
||||||
|
const depthLabel = maxDepth ? `${depth}/${maxDepth}` : `${depth}`
|
||||||
|
pieces.push(`d${depthLabel}`)
|
||||||
|
|
||||||
|
if (active > 0) {
|
||||||
|
// Label pairs the widest-level count (drives concRatio above) with
|
||||||
|
// the total active count for context. `W/cap` triggers the warn,
|
||||||
|
// `+N` is everything else currently running across the tree.
|
||||||
|
const extra = Math.max(0, active - widestLevel)
|
||||||
|
const widthLabel = maxConc ? `${widestLevel}/${maxConc}` : `${widestLevel}`
|
||||||
|
const suffix = extra > 0 ? `+${extra}` : ''
|
||||||
|
pieces.push(`⚡${widthLabel}${suffix}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const atCap = depthRatio >= 1 || concRatio >= 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text color={color}>
|
||||||
|
{atCap ? ' │ ⚠ ' : ' │ '}
|
||||||
|
{pieces.join(' ')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SessionDuration({ startedAt }: { startedAt: number }) {
|
function SessionDuration({ startedAt }: { startedAt: number }) {
|
||||||
const [now, setNow] = useState(() => Date.now())
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
|
||||||
|
|
@ -145,6 +210,7 @@ export function StatusRule({
|
||||||
<SessionDuration startedAt={sessionStartedAt} />
|
<SessionDuration startedAt={sessionStartedAt} />
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
<SpawnHud t={t} />
|
||||||
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
{voiceLabel ? <Text color={t.color.dim}> │ {voiceLabel}</Text> : null}
|
||||||
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
{bgCount > 0 ? <Text color={t.color.dim}> │ {bgCount} bg</Text> : null}
|
||||||
{showCost && typeof usage.cost_usd === 'number' ? (
|
{showCost && typeof usage.cost_usd === 'number' ? (
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
import { useGateway } from '../app/gatewayContext.js'
|
||||||
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
|
import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js'
|
||||||
import { $isBlocked } from '../app/overlayStore.js'
|
import { $isBlocked, $overlayState, patchOverlayState } from '../app/overlayStore.js'
|
||||||
import { $uiState } from '../app/uiStore.js'
|
import { $uiState } from '../app/uiStore.js'
|
||||||
import { PLACEHOLDER } from '../content/placeholders.js'
|
import { PLACEHOLDER } from '../content/placeholders.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { DetailsMode } from '../types.js'
|
import type { DetailsMode } from '../types.js'
|
||||||
|
|
||||||
|
import { AgentsOverlay } from './agentsOverlay.js'
|
||||||
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
|
||||||
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
import { FloatingOverlays, PromptZone } from './appOverlays.js'
|
||||||
import { Banner, Panel, SessionPanel } from './branding.js'
|
import { Banner, Panel, SessionPanel } from './branding.js'
|
||||||
|
|
@ -256,6 +258,21 @@ const ComposerPane = memo(function ComposerPane({
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const AgentsOverlayPane = memo(function AgentsOverlayPane() {
|
||||||
|
const { gw } = useGateway()
|
||||||
|
const ui = useStore($uiState)
|
||||||
|
const overlay = useStore($overlayState)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentsOverlay
|
||||||
|
gw={gw}
|
||||||
|
initialHistoryIndex={overlay.agentsInitialHistoryIndex}
|
||||||
|
onClose={() => patchOverlayState({ agents: false, agentsInitialHistoryIndex: 0 })}
|
||||||
|
t={ui.theme}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
export const AppLayout = memo(function AppLayout({
|
export const AppLayout = memo(function AppLayout({
|
||||||
actions,
|
actions,
|
||||||
composer,
|
composer,
|
||||||
|
|
@ -264,22 +281,30 @@ export const AppLayout = memo(function AppLayout({
|
||||||
status,
|
status,
|
||||||
transcript
|
transcript
|
||||||
}: AppLayoutProps) {
|
}: AppLayoutProps) {
|
||||||
|
const overlay = useStore($overlayState)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlternateScreen mouseTracking={mouseTracking}>
|
<AlternateScreen mouseTracking={mouseTracking}>
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
<Box flexDirection="row" flexGrow={1}>
|
<Box flexDirection="row" flexGrow={1}>
|
||||||
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
{overlay.agents ? (
|
||||||
|
<AgentsOverlayPane />
|
||||||
|
) : (
|
||||||
|
<TranscriptPane actions={actions} composer={composer} progress={progress} transcript={transcript} />
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<PromptZone
|
{!overlay.agents && (
|
||||||
cols={composer.cols}
|
<PromptZone
|
||||||
onApprovalChoice={actions.answerApproval}
|
cols={composer.cols}
|
||||||
onClarifyAnswer={actions.answerClarify}
|
onApprovalChoice={actions.answerApproval}
|
||||||
onSecretSubmit={actions.answerSecret}
|
onClarifyAnswer={actions.answerClarify}
|
||||||
onSudoSubmit={actions.answerSudo}
|
onSecretSubmit={actions.answerSecret}
|
||||||
/>
|
onSudoSubmit={actions.answerSudo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ComposerPane actions={actions} composer={composer} status={status} />
|
{!overlay.agents && <ComposerPane actions={actions} composer={composer} status={status} />}
|
||||||
</Box>
|
</Box>
|
||||||
</AlternateScreen>
|
</AlternateScreen>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -615,14 +615,7 @@ export function TextInput({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if ((k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) {
|
||||||
(k.ctrl && inp === 'c') ||
|
|
||||||
k.tab ||
|
|
||||||
(k.shift && k.tab) ||
|
|
||||||
k.pageUp ||
|
|
||||||
k.pageDown ||
|
|
||||||
k.escape
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
import { Box, NoSelect, Text } from '@hermes/ink'
|
import { Box, NoSelect, Text } from '@hermes/ink'
|
||||||
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||||
|
|
||||||
import { THINKING_COT_MAX } from '../config/limits.js'
|
import { THINKING_COT_MAX } from '../config/limits.js'
|
||||||
|
import {
|
||||||
|
buildSubagentTree,
|
||||||
|
fmtCost,
|
||||||
|
fmtTokens,
|
||||||
|
formatSummary as formatSpawnSummary,
|
||||||
|
hotnessBucket,
|
||||||
|
peakHotness,
|
||||||
|
sparkline,
|
||||||
|
treeTotals,
|
||||||
|
widthByDepth
|
||||||
|
} from '../lib/subagentTree.js'
|
||||||
import {
|
import {
|
||||||
compactPreview,
|
compactPreview,
|
||||||
estimateTokensRough,
|
estimateTokensRough,
|
||||||
|
|
@ -14,7 +25,7 @@ import {
|
||||||
toolTrailLabel
|
toolTrailLabel
|
||||||
} from '../lib/text.js'
|
} from '../lib/text.js'
|
||||||
import type { Theme } from '../theme.js'
|
import type { Theme } from '../theme.js'
|
||||||
import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js'
|
import type { ActiveTool, ActivityItem, DetailsMode, SubagentNode, SubagentProgress, ThinkingMode } from '../types.js'
|
||||||
|
|
||||||
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||||
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||||
|
|
@ -106,6 +117,8 @@ function TreeNode({
|
||||||
header,
|
header,
|
||||||
open,
|
open,
|
||||||
rails = [],
|
rails = [],
|
||||||
|
stemColor,
|
||||||
|
stemDim,
|
||||||
t
|
t
|
||||||
}: {
|
}: {
|
||||||
branch: TreeBranch
|
branch: TreeBranch
|
||||||
|
|
@ -113,11 +126,13 @@ function TreeNode({
|
||||||
header: ReactNode
|
header: ReactNode
|
||||||
open: boolean
|
open: boolean
|
||||||
rails?: TreeRails
|
rails?: TreeRails
|
||||||
|
stemColor?: string
|
||||||
|
stemDim?: boolean
|
||||||
t: Theme
|
t: Theme
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<TreeRow branch={branch} rails={rails} t={t}>
|
<TreeRow branch={branch} rails={rails} stemColor={stemColor} stemDim={stemDim} t={t}>
|
||||||
{header}
|
{header}
|
||||||
</TreeRow>
|
</TreeRow>
|
||||||
{open ? children?.(nextTreeRails(rails, branch)) : null}
|
{open ? children?.(nextTreeRails(rails, branch)) : null}
|
||||||
|
|
@ -239,16 +254,31 @@ function Chevron({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function heatColor(node: SubagentNode, peak: number, theme: Theme): string | undefined {
|
||||||
|
const palette = [theme.color.bronze, theme.color.amber, theme.color.gold, theme.color.warn, theme.color.error]
|
||||||
|
const idx = hotnessBucket(node.aggregate.hotness, peak, palette.length)
|
||||||
|
|
||||||
|
// Below the median bucket we keep the default dim stem so cool branches
|
||||||
|
// fade into the chrome — only "hot" branches draw the eye.
|
||||||
|
if (idx < 2) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return palette[idx]
|
||||||
|
}
|
||||||
|
|
||||||
function SubagentAccordion({
|
function SubagentAccordion({
|
||||||
branch,
|
branch,
|
||||||
expanded,
|
expanded,
|
||||||
item,
|
node,
|
||||||
|
peak,
|
||||||
rails = [],
|
rails = [],
|
||||||
t
|
t
|
||||||
}: {
|
}: {
|
||||||
branch: TreeBranch
|
branch: TreeBranch
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
item: SubagentProgress
|
node: SubagentNode
|
||||||
|
peak: number
|
||||||
rails?: TreeRails
|
rails?: TreeRails
|
||||||
t: Theme
|
t: Theme
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -257,6 +287,7 @@ function SubagentAccordion({
|
||||||
const [openThinking, setOpenThinking] = useState(expanded)
|
const [openThinking, setOpenThinking] = useState(expanded)
|
||||||
const [openTools, setOpenTools] = useState(expanded)
|
const [openTools, setOpenTools] = useState(expanded)
|
||||||
const [openNotes, setOpenNotes] = useState(expanded)
|
const [openNotes, setOpenNotes] = useState(expanded)
|
||||||
|
const [openKids, setOpenKids] = useState(expanded)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expanded) {
|
if (!expanded) {
|
||||||
|
|
@ -268,6 +299,7 @@ function SubagentAccordion({
|
||||||
setOpenThinking(true)
|
setOpenThinking(true)
|
||||||
setOpenTools(true)
|
setOpenTools(true)
|
||||||
setOpenNotes(true)
|
setOpenNotes(true)
|
||||||
|
setOpenKids(true)
|
||||||
}, [expanded])
|
}, [expanded])
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
|
|
@ -276,8 +308,13 @@ function SubagentAccordion({
|
||||||
setOpenThinking(true)
|
setOpenThinking(true)
|
||||||
setOpenTools(true)
|
setOpenTools(true)
|
||||||
setOpenNotes(true)
|
setOpenNotes(true)
|
||||||
|
setOpenKids(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const item = node.item
|
||||||
|
const children = node.children
|
||||||
|
const aggregate = node.aggregate
|
||||||
|
|
||||||
const statusTone: 'dim' | 'error' | 'warn' =
|
const statusTone: 'dim' | 'error' | 'warn' =
|
||||||
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
|
item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim'
|
||||||
|
|
||||||
|
|
@ -286,10 +323,60 @@ function SubagentAccordion({
|
||||||
const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}`
|
const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}`
|
||||||
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
|
const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72)
|
||||||
|
|
||||||
const suffix =
|
// Suffix packs branch rollup: status · elapsed · per-branch tool/agent/token/cost.
|
||||||
item.status === 'running'
|
// Emphasises the numbers the user can't easily eyeball from a flat list.
|
||||||
? 'running'
|
const statusLabel = item.status === 'queued' ? 'queued' : item.status === 'running' ? 'running' : String(item.status)
|
||||||
: `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}`
|
|
||||||
|
const rollupBits: string[] = [statusLabel]
|
||||||
|
|
||||||
|
if (item.durationSeconds) {
|
||||||
|
rollupBits.push(fmtElapsed(item.durationSeconds * 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
const localTools = item.toolCount ?? 0
|
||||||
|
const subtreeTools = aggregate.totalTools - localTools
|
||||||
|
|
||||||
|
if (localTools > 0) {
|
||||||
|
rollupBits.push(`${localTools} tool${localTools === 1 ? '' : 's'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localTokens = (item.inputTokens ?? 0) + (item.outputTokens ?? 0)
|
||||||
|
|
||||||
|
if (localTokens > 0) {
|
||||||
|
rollupBits.push(`${fmtTokens(localTokens)} tok`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const localCost = item.costUsd ?? 0
|
||||||
|
|
||||||
|
if (localCost > 0) {
|
||||||
|
rollupBits.push(fmtCost(localCost))
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesLocal = (item.filesWritten?.length ?? 0) + (item.filesRead?.length ?? 0)
|
||||||
|
|
||||||
|
if (filesLocal > 0) {
|
||||||
|
rollupBits.push(`⎘${filesLocal}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (children.length > 0) {
|
||||||
|
rollupBits.push(`${aggregate.descendantCount}↓`)
|
||||||
|
|
||||||
|
if (subtreeTools > 0) {
|
||||||
|
rollupBits.push(`+${subtreeTools}t sub`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subCost = aggregate.costUsd - localCost
|
||||||
|
|
||||||
|
if (subCost >= 0.01) {
|
||||||
|
rollupBits.push(`+${fmtCost(subCost)} sub`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aggregate.activeCount > 0 && item.status !== 'running') {
|
||||||
|
rollupBits.push(`⚡${aggregate.activeCount}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = rollupBits.join(' · ')
|
||||||
|
|
||||||
const thinkingText = item.thinking.join('\n')
|
const thinkingText = item.thinking.join('\n')
|
||||||
const hasThinking = Boolean(thinkingText)
|
const hasThinking = Boolean(thinkingText)
|
||||||
|
|
@ -418,6 +505,50 @@ function SubagentAccordion({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (children.length > 0) {
|
||||||
|
// Nested grandchildren — rendered recursively via SubagentAccordion,
|
||||||
|
// sharing the same keybindings / expand semantics as top-level nodes.
|
||||||
|
sections.push({
|
||||||
|
header: (
|
||||||
|
<Chevron
|
||||||
|
count={children.length}
|
||||||
|
onClick={shift => {
|
||||||
|
if (shift) {
|
||||||
|
expandAll()
|
||||||
|
} else {
|
||||||
|
setOpenKids(v => !v)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
open={showChildren || openKids}
|
||||||
|
suffix={`d${item.depth + 1} · ${aggregate.descendantCount} total`}
|
||||||
|
t={t}
|
||||||
|
title="Spawned"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
key: 'subagents',
|
||||||
|
open: showChildren || openKids,
|
||||||
|
render: childRails => (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{children.map((child, i) => (
|
||||||
|
<SubagentAccordion
|
||||||
|
branch={i === children.length - 1 ? 'last' : 'mid'}
|
||||||
|
expanded={expanded || deep}
|
||||||
|
key={child.item.id}
|
||||||
|
node={child}
|
||||||
|
peak={peak}
|
||||||
|
rails={childRails}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heatmap: amber→error gradient on the stem when this branch is "hot"
|
||||||
|
// (high tools/sec) relative to the whole tree's peak.
|
||||||
|
const stem = heatColor(node, peak, t)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TreeNode
|
<TreeNode
|
||||||
branch={branch}
|
branch={branch}
|
||||||
|
|
@ -447,6 +578,8 @@ function SubagentAccordion({
|
||||||
}
|
}
|
||||||
open={open}
|
open={open}
|
||||||
rails={rails}
|
rails={rails}
|
||||||
|
stemColor={stem}
|
||||||
|
stemDim={stem == null}
|
||||||
t={t}
|
t={t}
|
||||||
>
|
>
|
||||||
{childRails => (
|
{childRails => (
|
||||||
|
|
@ -598,6 +731,16 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
|
|
||||||
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning])
|
||||||
|
|
||||||
|
// Spawn-tree derivations must live above any early return so React's
|
||||||
|
// rules-of-hooks sees a stable call order. Cheap O(N) builds memoised
|
||||||
|
// by subagent-list identity.
|
||||||
|
const spawnTree = useMemo(() => buildSubagentTree(subagents), [subagents])
|
||||||
|
const spawnPeak = useMemo(() => peakHotness(spawnTree), [spawnTree])
|
||||||
|
const spawnTotals = useMemo(() => treeTotals(spawnTree), [spawnTree])
|
||||||
|
const spawnWidths = useMemo(() => widthByDepth(spawnTree), [spawnTree])
|
||||||
|
const spawnSpark = useMemo(() => sparkline(spawnWidths), [spawnWidths])
|
||||||
|
const spawnSummaryLabel = useMemo(() => formatSpawnSummary(spawnTotals), [spawnTotals])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!busy &&
|
!busy &&
|
||||||
!trail.length &&
|
!trail.length &&
|
||||||
|
|
@ -753,12 +896,13 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
|
|
||||||
const renderSubagentList = (rails: boolean[]) => (
|
const renderSubagentList = (rails: boolean[]) => (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{subagents.map((item, index) => (
|
{spawnTree.map((node, index) => (
|
||||||
<SubagentAccordion
|
<SubagentAccordion
|
||||||
branch={index === subagents.length - 1 ? 'last' : 'mid'}
|
branch={index === spawnTree.length - 1 ? 'last' : 'mid'}
|
||||||
expanded={detailsMode === 'expanded' || deepSubagents}
|
expanded={detailsMode === 'expanded' || deepSubagents}
|
||||||
item={item}
|
key={node.item.id}
|
||||||
key={item.id}
|
node={node}
|
||||||
|
peak={spawnPeak}
|
||||||
rails={rails}
|
rails={rails}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
@ -881,10 +1025,14 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSubagents && !inlineDelegateKey) {
|
if (hasSubagents && !inlineDelegateKey) {
|
||||||
|
// Spark + summary give a one-line read on the branch shape before
|
||||||
|
// opening the subtree. `/agents` opens the full-screen audit overlay.
|
||||||
|
const suffix = spawnSpark ? `${spawnSummaryLabel} ${spawnSpark} (/agents)` : `${spawnSummaryLabel} (/agents)`
|
||||||
|
|
||||||
sections.push({
|
sections.push({
|
||||||
header: (
|
header: (
|
||||||
<Chevron
|
<Chevron
|
||||||
count={subagents.length}
|
count={spawnTotals.descendantCount}
|
||||||
onClick={shift => {
|
onClick={shift => {
|
||||||
if (shift) {
|
if (shift) {
|
||||||
expandAll()
|
expandAll()
|
||||||
|
|
@ -895,8 +1043,9 @@ export const ToolTrail = memo(function ToolTrail({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open={detailsMode === 'expanded' || openSubagents}
|
open={detailsMode === 'expanded' || openSubagents}
|
||||||
|
suffix={suffix}
|
||||||
t={t}
|
t={t}
|
||||||
title="Subagents"
|
title="Spawn tree"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
key: 'subagents',
|
key: 'subagents',
|
||||||
|
|
|
||||||
|
|
@ -280,15 +280,85 @@ export interface ReloadMcpResponse {
|
||||||
// ── Subagent events ──────────────────────────────────────────────────
|
// ── Subagent events ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SubagentEventPayload {
|
export interface SubagentEventPayload {
|
||||||
|
api_calls?: number
|
||||||
|
cost_usd?: number
|
||||||
|
depth?: number
|
||||||
duration_seconds?: number
|
duration_seconds?: number
|
||||||
|
files_read?: string[]
|
||||||
|
files_written?: string[]
|
||||||
goal: string
|
goal: string
|
||||||
status?: 'completed' | 'failed' | 'interrupted' | 'running'
|
input_tokens?: number
|
||||||
|
iteration?: number
|
||||||
|
model?: string
|
||||||
|
output_tail?: { is_error?: boolean; preview?: string; tool?: string }[]
|
||||||
|
output_tokens?: number
|
||||||
|
parent_id?: null | string
|
||||||
|
reasoning_tokens?: number
|
||||||
|
status?: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
||||||
|
subagent_id?: string
|
||||||
summary?: string
|
summary?: string
|
||||||
task_count?: number
|
task_count?: number
|
||||||
task_index: number
|
task_index: number
|
||||||
text?: string
|
text?: string
|
||||||
|
tool_count?: number
|
||||||
tool_name?: string
|
tool_name?: string
|
||||||
tool_preview?: string
|
tool_preview?: string
|
||||||
|
toolsets?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delegation control RPCs ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DelegationStatusResponse {
|
||||||
|
active?: {
|
||||||
|
depth?: number
|
||||||
|
goal?: string
|
||||||
|
model?: null | string
|
||||||
|
parent_id?: null | string
|
||||||
|
started_at?: number
|
||||||
|
status?: string
|
||||||
|
subagent_id?: string
|
||||||
|
tool_count?: number
|
||||||
|
}[]
|
||||||
|
max_concurrent_children?: number
|
||||||
|
max_spawn_depth?: number
|
||||||
|
paused?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelegationPauseResponse {
|
||||||
|
paused?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentInterruptResponse {
|
||||||
|
found?: boolean
|
||||||
|
subagent_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spawn-tree snapshots ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SpawnTreeListEntry {
|
||||||
|
count: number
|
||||||
|
finished_at?: number
|
||||||
|
label?: string
|
||||||
|
path: string
|
||||||
|
session_id?: string
|
||||||
|
started_at?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnTreeListResponse {
|
||||||
|
entries?: SpawnTreeListEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnTreeLoadResponse {
|
||||||
|
finished_at?: number
|
||||||
|
label?: string
|
||||||
|
session_id?: string
|
||||||
|
started_at?: null | number
|
||||||
|
subagents?: unknown[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnTreeSaveResponse {
|
||||||
|
path?: string
|
||||||
|
session_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GatewayEvent =
|
export type GatewayEvent =
|
||||||
|
|
@ -320,6 +390,7 @@ export type GatewayEvent =
|
||||||
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
||||||
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
||||||
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
|
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
|
||||||
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' }
|
||||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
|
||||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
|
||||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }
|
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }
|
||||||
|
|
|
||||||
355
ui-tui/src/lib/subagentTree.ts
Normal file
355
ui-tui/src/lib/subagentTree.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
import type { SubagentAggregate, SubagentNode, SubagentProgress } from '../types.js'
|
||||||
|
|
||||||
|
const ROOT_KEY = '__root__'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconstruct the subagent spawn tree from a flat event-ordered list.
|
||||||
|
*
|
||||||
|
* Grouping is by `parentId`; a missing `parentId` (or one pointing at an
|
||||||
|
* unknown subagent) is treated as a top-level spawn of the current turn.
|
||||||
|
* Children within a parent are sorted by `depth` then `index` — same key
|
||||||
|
* used in `turnController.upsertSubagent`, so render order matches spawn
|
||||||
|
* order regardless of network reordering of gateway events.
|
||||||
|
*
|
||||||
|
* Older gateways omit `parentId`; every subagent is then a top-level node
|
||||||
|
* and the tree renders flat — matching pre-observability behaviour.
|
||||||
|
*/
|
||||||
|
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
|
||||||
|
if (!items.length) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const byParent = new Map<string, SubagentProgress[]>()
|
||||||
|
const known = new Set<string>()
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
known.add(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const parentKey = item.parentId && known.has(item.parentId) ? item.parentId : ROOT_KEY
|
||||||
|
const bucket = byParent.get(parentKey) ?? []
|
||||||
|
bucket.push(item)
|
||||||
|
byParent.set(parentKey, bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bucket of byParent.values()) {
|
||||||
|
bucket.sort((a, b) => a.depth - b.depth || a.index - b.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = (item: SubagentProgress): SubagentNode => {
|
||||||
|
const kids = byParent.get(item.id) ?? []
|
||||||
|
const children = kids.map(build)
|
||||||
|
|
||||||
|
return { aggregate: aggregate(item, children), children, item }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (byParent.get(ROOT_KEY) ?? []).map(build)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roll up counts for a node's whole subtree. Kept pure so the live view
|
||||||
|
* and the post-hoc replay can share the same renderer unchanged.
|
||||||
|
*
|
||||||
|
* `hotness` = tools per second across the subtree — a crude proxy for
|
||||||
|
* "how much work is happening in this branch". Used to colour tree rails
|
||||||
|
* in the overlay / inline view so the eye spots the expensive branch.
|
||||||
|
*/
|
||||||
|
export function aggregate(item: SubagentProgress, children: readonly SubagentNode[]): SubagentAggregate {
|
||||||
|
let totalTools = item.toolCount ?? 0
|
||||||
|
let totalDuration = item.durationSeconds ?? 0
|
||||||
|
let descendantCount = 0
|
||||||
|
let activeCount = isRunning(item) ? 1 : 0
|
||||||
|
let maxDepthFromHere = 0
|
||||||
|
let inputTokens = item.inputTokens ?? 0
|
||||||
|
let outputTokens = item.outputTokens ?? 0
|
||||||
|
let costUsd = item.costUsd ?? 0
|
||||||
|
let filesTouched = (item.filesRead?.length ?? 0) + (item.filesWritten?.length ?? 0)
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
totalTools += child.aggregate.totalTools
|
||||||
|
totalDuration += child.aggregate.totalDuration
|
||||||
|
descendantCount += child.aggregate.descendantCount + 1
|
||||||
|
activeCount += child.aggregate.activeCount
|
||||||
|
maxDepthFromHere = Math.max(maxDepthFromHere, child.aggregate.maxDepthFromHere + 1)
|
||||||
|
inputTokens += child.aggregate.inputTokens
|
||||||
|
outputTokens += child.aggregate.outputTokens
|
||||||
|
costUsd += child.aggregate.costUsd
|
||||||
|
filesTouched += child.aggregate.filesTouched
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount,
|
||||||
|
costUsd,
|
||||||
|
descendantCount,
|
||||||
|
filesTouched,
|
||||||
|
hotness,
|
||||||
|
inputTokens,
|
||||||
|
maxDepthFromHere,
|
||||||
|
outputTokens,
|
||||||
|
totalDuration,
|
||||||
|
totalTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count of subagents at each depth level, indexed by depth (0 = top level).
|
||||||
|
* Drives the inline sparkline (`▁▃▇▅`) and the status-bar HUD.
|
||||||
|
*/
|
||||||
|
export function widthByDepth(tree: readonly SubagentNode[]): number[] {
|
||||||
|
const widths: number[] = []
|
||||||
|
|
||||||
|
const walk = (nodes: readonly SubagentNode[], depth: number) => {
|
||||||
|
if (!nodes.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widths[depth] = (widths[depth] ?? 0) + nodes.length
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
walk(node.children, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(tree, 0)
|
||||||
|
|
||||||
|
return widths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flat totals across the full tree — feeds the summary chip header.
|
||||||
|
*/
|
||||||
|
export function treeTotals(tree: readonly SubagentNode[]): SubagentAggregate {
|
||||||
|
let totalTools = 0
|
||||||
|
let totalDuration = 0
|
||||||
|
let descendantCount = 0
|
||||||
|
let activeCount = 0
|
||||||
|
let maxDepthFromHere = 0
|
||||||
|
let inputTokens = 0
|
||||||
|
let outputTokens = 0
|
||||||
|
let costUsd = 0
|
||||||
|
let filesTouched = 0
|
||||||
|
|
||||||
|
for (const node of tree) {
|
||||||
|
totalTools += node.aggregate.totalTools
|
||||||
|
totalDuration += node.aggregate.totalDuration
|
||||||
|
descendantCount += node.aggregate.descendantCount + 1
|
||||||
|
activeCount += node.aggregate.activeCount
|
||||||
|
maxDepthFromHere = Math.max(maxDepthFromHere, node.aggregate.maxDepthFromHere + 1)
|
||||||
|
inputTokens += node.aggregate.inputTokens
|
||||||
|
outputTokens += node.aggregate.outputTokens
|
||||||
|
costUsd += node.aggregate.costUsd
|
||||||
|
filesTouched += node.aggregate.filesTouched
|
||||||
|
}
|
||||||
|
|
||||||
|
const hotness = totalDuration > 0 ? totalTools / totalDuration : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCount,
|
||||||
|
costUsd,
|
||||||
|
descendantCount,
|
||||||
|
filesTouched,
|
||||||
|
hotness,
|
||||||
|
inputTokens,
|
||||||
|
maxDepthFromHere,
|
||||||
|
outputTokens,
|
||||||
|
totalDuration,
|
||||||
|
totalTools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten the tree into visit order — useful for keyboard navigation and
|
||||||
|
* for "kill subtree" walks that fire one RPC per descendant.
|
||||||
|
*/
|
||||||
|
export function flattenTree(tree: readonly SubagentNode[]): SubagentNode[] {
|
||||||
|
const out: SubagentNode[] = []
|
||||||
|
|
||||||
|
const walk = (nodes: readonly SubagentNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
out.push(node)
|
||||||
|
walk(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(tree)
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect every descendant's id for a given node (excluding the node itself).
|
||||||
|
*/
|
||||||
|
export function descendantIds(node: SubagentNode): string[] {
|
||||||
|
const ids: string[] = []
|
||||||
|
|
||||||
|
const walk = (children: readonly SubagentNode[]) => {
|
||||||
|
for (const child of children) {
|
||||||
|
ids.push(child.item.id)
|
||||||
|
walk(child.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(node.children)
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRunning(item: Pick<SubagentProgress, 'status'>): boolean {
|
||||||
|
return item.status === 'running' || item.status === 'queued'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPARK_RAMP = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 8-step unicode bar sparkline from a positive-integer array. Zeroes render
|
||||||
|
* as spaces so a sparse tree doesn't read as equal activity at every depth.
|
||||||
|
*/
|
||||||
|
export function sparkline(values: readonly number[]): string {
|
||||||
|
if (!values.length) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = Math.max(...values)
|
||||||
|
|
||||||
|
if (max <= 0) {
|
||||||
|
return ' '.repeat(values.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return values
|
||||||
|
.map(v => {
|
||||||
|
if (v <= 0) {
|
||||||
|
return ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = Math.min(SPARK_RAMP.length - 1, Math.max(0, Math.ceil((v / max) * (SPARK_RAMP.length - 1))))
|
||||||
|
|
||||||
|
return SPARK_RAMP[idx]
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format totals into a compact one-line summary: `d2 · 7 agents · 124 tools · 2m 14s`
|
||||||
|
*/
|
||||||
|
export function formatSummary(totals: SubagentAggregate): string {
|
||||||
|
const pieces = [`d${Math.max(0, totals.maxDepthFromHere)}`]
|
||||||
|
pieces.push(`${totals.descendantCount} agent${totals.descendantCount === 1 ? '' : 's'}`)
|
||||||
|
|
||||||
|
if (totals.totalTools > 0) {
|
||||||
|
pieces.push(`${totals.totalTools} tool${totals.totalTools === 1 ? '' : 's'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.totalDuration > 0) {
|
||||||
|
pieces.push(fmtDuration(totals.totalDuration))
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = totals.inputTokens + totals.outputTokens
|
||||||
|
|
||||||
|
if (tokens > 0) {
|
||||||
|
pieces.push(`${fmtTokens(tokens)} tok`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.costUsd > 0) {
|
||||||
|
pieces.push(fmtCost(totals.costUsd))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totals.activeCount > 0) {
|
||||||
|
pieces.push(`⚡${totals.activeCount}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pieces.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact dollar amount: `$0.02`, `$1.34`, `$12.4` — never > 5 chars beyond the `$`. */
|
||||||
|
export function fmtCost(usd: number): string {
|
||||||
|
if (!Number.isFinite(usd) || usd <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usd < 0.01) {
|
||||||
|
return '<$0.01'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usd < 10) {
|
||||||
|
return `$${usd.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `$${usd.toFixed(1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact token count: `12k`, `1.2k`, `542`. */
|
||||||
|
export function fmtTokens(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n <= 0) {
|
||||||
|
return '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n < 1000) {
|
||||||
|
return String(Math.round(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (n < 10_000) {
|
||||||
|
return `${(n / 1000).toFixed(1)}k`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Math.round(n / 1000)}k`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Ns` / `Nm` / `Nm Ss` formatter for seconds. Shared with the agents
|
||||||
|
* overlay so the timeline + list + summary all speak the same dialect.
|
||||||
|
*/
|
||||||
|
export function fmtDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.max(0, Math.round(seconds))}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.round(seconds - m * 60)
|
||||||
|
|
||||||
|
return s === 0 ? `${m}m` : `${m}m ${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A subagent is top-level if it has no `parentId`, or its parent isn't in
|
||||||
|
* the same snapshot (orphaned by a pruned mid-flight root). Same rule
|
||||||
|
* `buildSubagentTree` uses — keep call sites consistent across the live
|
||||||
|
* view, disk label, and diff pane.
|
||||||
|
*/
|
||||||
|
export function topLevelSubagents(items: readonly SubagentProgress[]): SubagentProgress[] {
|
||||||
|
const ids = new Set(items.map(s => s.id))
|
||||||
|
|
||||||
|
return items.filter(s => !s.parentId || !ids.has(s.parentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a node's hotness into a palette index 0..N-1 where N = buckets.
|
||||||
|
* Higher hotness = "hotter" colour. Normalized against the tree's peak hotness
|
||||||
|
* so a uniformly slow tree still shows gradient across its busiest branches.
|
||||||
|
*/
|
||||||
|
export function hotnessBucket(hotness: number, peakHotness: number, buckets: number): number {
|
||||||
|
if (!Number.isFinite(hotness) || hotness <= 0 || peakHotness <= 0 || buckets <= 1) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = Math.min(1, hotness / peakHotness)
|
||||||
|
|
||||||
|
return Math.min(buckets - 1, Math.max(0, Math.round(ratio * (buckets - 1))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function peakHotness(tree: readonly SubagentNode[]): number {
|
||||||
|
let peak = 0
|
||||||
|
|
||||||
|
const walk = (nodes: readonly SubagentNode[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
peak = Math.max(peak, node.aggregate.hotness)
|
||||||
|
walk(node.children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(tree)
|
||||||
|
|
||||||
|
return peak
|
||||||
|
}
|
||||||
|
|
@ -94,7 +94,12 @@ export const DARK_THEME: Theme = {
|
||||||
amber: '#FFBF00',
|
amber: '#FFBF00',
|
||||||
bronze: '#CD7F32',
|
bronze: '#CD7F32',
|
||||||
cornsilk: '#FFF8DC',
|
cornsilk: '#FFF8DC',
|
||||||
dim: '#B8860B',
|
// Bumped from the old `#B8860B` darkgoldenrod (~53% luminance) which
|
||||||
|
// read as barely-visible on dark terminals for long body text. The
|
||||||
|
// new value sits ~60% luminance — readable without losing the "muted /
|
||||||
|
// secondary" semantic. Field labels still use `label` (65%) which
|
||||||
|
// stays brighter so hierarchy holds.
|
||||||
|
dim: '#CC9B1F',
|
||||||
completionBg: '#FFFFFF',
|
completionBg: '#FFFFFF',
|
||||||
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25),
|
||||||
|
|
||||||
|
|
@ -104,8 +109,11 @@ export const DARK_THEME: Theme = {
|
||||||
warn: '#ffa726',
|
warn: '#ffa726',
|
||||||
|
|
||||||
prompt: '#FFF8DC',
|
prompt: '#FFF8DC',
|
||||||
sessionLabel: '#B8860B',
|
// sessionLabel/sessionBorder intentionally track the `dim` value — they
|
||||||
sessionBorder: '#B8860B',
|
// are "same role, same colour" by design. fromSkin's banner_dim fallback
|
||||||
|
// relies on this pairing (#11300).
|
||||||
|
sessionLabel: '#CC9B1F',
|
||||||
|
sessionBorder: '#CC9B1F',
|
||||||
|
|
||||||
statusBg: '#1a1a2e',
|
statusBg: '#1a1a2e',
|
||||||
statusFg: '#C0C0C0',
|
statusFg: '#C0C0C0',
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,72 @@ export interface ActivityItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubagentProgress {
|
export interface SubagentProgress {
|
||||||
|
apiCalls?: number
|
||||||
|
costUsd?: number
|
||||||
|
depth: number
|
||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
|
filesRead?: string[]
|
||||||
|
filesWritten?: string[]
|
||||||
goal: string
|
goal: string
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: number
|
||||||
|
inputTokens?: number
|
||||||
|
iteration?: number
|
||||||
|
model?: string
|
||||||
notes: string[]
|
notes: string[]
|
||||||
status: 'completed' | 'failed' | 'interrupted' | 'running'
|
outputTail?: SubagentOutputEntry[]
|
||||||
|
outputTokens?: number
|
||||||
|
parentId: null | string
|
||||||
|
reasoningTokens?: number
|
||||||
|
startedAt?: number
|
||||||
|
status: 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
|
||||||
summary?: string
|
summary?: string
|
||||||
taskCount: number
|
taskCount: number
|
||||||
thinking: string[]
|
thinking: string[]
|
||||||
|
toolCount: number
|
||||||
tools: string[]
|
tools: string[]
|
||||||
|
toolsets?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentOutputEntry {
|
||||||
|
isError: boolean
|
||||||
|
preview: string
|
||||||
|
tool: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentNode {
|
||||||
|
aggregate: SubagentAggregate
|
||||||
|
children: SubagentNode[]
|
||||||
|
item: SubagentProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentAggregate {
|
||||||
|
activeCount: number
|
||||||
|
costUsd: number
|
||||||
|
descendantCount: number
|
||||||
|
filesTouched: number
|
||||||
|
hotness: number
|
||||||
|
inputTokens: number
|
||||||
|
maxDepthFromHere: number
|
||||||
|
outputTokens: number
|
||||||
|
totalDuration: number
|
||||||
|
totalTools: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DelegationStatus {
|
||||||
|
active: {
|
||||||
|
depth?: number
|
||||||
|
goal?: string
|
||||||
|
model?: null | string
|
||||||
|
parent_id?: null | string
|
||||||
|
started_at?: number
|
||||||
|
status?: string
|
||||||
|
subagent_id?: string
|
||||||
|
tool_count?: number
|
||||||
|
}[]
|
||||||
|
max_concurrent_children?: number
|
||||||
|
max_spawn_depth?: number
|
||||||
|
paused: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalReq {
|
export interface ApprovalReq {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue