Side pane that draws the session's agents as boxes: main session, sub-agents, the way back, advisor and session log.

A terminal side pane for Claude Code that draws a session's agents as a tree of boxes: main session, sub-agents, the way back, the advisor and a session log.

/plugin install agent-tree --marketplace MikeGT4/agent-tree
Tested with 2.1.296 and 2.1.307 to 2.1.310; it needs the mod interface.
/agent-tree opens the pane, /agent-tree stop closes it, /agent-tree clear starts afresh. Click a type for its task, tools and latest answer, and to stop it. Clicking a model switches a running agent; "Model" sets it for new sub-agents. Keys: ctrl+x tab focuses the pane, m model, 0 to 4 pick, x close, s stop in the details.
Settings: width, agents kept, open on start, motion, language (auto, de, en).
Privacy: local only, sends nothing (PRIVACY.md).
MIT, © 2026 Mike Pollow, Digitalroots
hooks/register.tsx 338 lines1// © 2026 Mike Pollow, Digitalroots. MIT-Lizenz.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register, Timer } from 'claude-code'
4
5import type { AgentTreeState } from '../types'
6import {
7 adopted,
8 choicesOf,
9 chosen,
10 closed,
11 completed,
12 counts,
13 createTree,
14 detailLoaded,
15 detailOpened,
16 isStopping,
17 measured,
18 phaseOf,
19 pickerOpened,
20 spawned,
21 stepModelFor,
22 stepped,
23 stopCancelled,
24 stopRequested,
25 stopSent,
26 synced,
27 toolLabel,
28 toolRan,
29 trimmed,
30 turnStarted,
31 wasStopSent,
32 withAdvisorModel,
33} from './model.ts'
34import { drawTree, pressKey, statusText } from './view.tsx'
35import type { TreeActions } from './view.tsx'
36import { resolveLang, wordsFor } from './words.ts'
37import type { Lang } from './words.ts'
38
39const PANE = 'agent-tree'
40const COMMAND = 'agent-tree'
41const TITLE = 'Agent Tree'
42const SHAPE = 'agent-tree-1'
43const INLINE_ROWS = 40
44const FRAME_MS = 250
45const HANDBACK = 'SubagentHandback'
46
47const tree = atom({ plugin: 'agent-tree', key: 'tree' } as const, createTree(0), { shape: SHAPE })
48const frame = atom({ plugin: 'agent-tree', key: 'frame' } as const, 0)
49
50let ticker: Timer | undefined
51let animate = true
52let lang: Lang = 'en'
53let stoppingIds = new Set<string>()
54let stopSentIds = new Set<string>()
55
56function busy(s: AgentTreeState): boolean {
57 return counts(s).running > 0 || phaseOf(s) === 'reviewing'
58}
59
60function keepTicking($: EngineInterface, s: AgentTreeState): void {
61 if (animate && busy(s)) {
62 if (ticker) return
63 try {
64 ticker = $.clock.every(FRAME_MS, () => {
65 void update($, frame, n => (n + 1) % 1_000_000).catch(() => undefined)
66 })
67 } catch {
68 ticker = undefined
69 }
70 return
71 }
72 ticker?.cancel()
73 ticker = undefined
74}
75
76function paneColumns(value: unknown): number {
77 const n = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 90
78 return Math.min(140, Math.max(40, n))
79}
80
81function agentLimit(value: unknown): number {
82 const n = typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 30
83 return Math.min(200, Math.max(5, n))
84}
85
86function handbackId(): string {
87 const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
88 let id = 'toolu_01'
89 for (let i = 0; i < 22; i++) id += letters[Math.floor(Math.random() * letters.length)]
90 return id
91}
92
93async function clock($: EngineInterface): Promise<number> {
94 return $.clock.now().catch(() => Date.now())
95}
96
97async function change($: EngineInterface, apply: (s: AgentTreeState) => AgentTreeState): Promise<AgentTreeState> {
98 const next = await update($, tree, apply)
99 stoppingIds = new Set(next.stopping ?? [])
100 stopSentIds = new Set(next.stopSent ?? [])
101 $.ui.status(statusText(next, lang))
102 keepTicking($, next)
103 return next
104}
105
106async function catchUp($: EngineInterface, now: number, keep: number): Promise<void> {
107 const list = await $.agent.list().catch(() => [])
108 await change($, s => trimmed(synced(adopted(s, list, now), list, now), keep))
109}
110
111type Found = { task?: string; answer?: string; denied?: boolean }
112
113function summarize(rows: unknown): Found {
114 if (!Array.isArray(rows)) return { denied: true }
115 const messages = rows.filter(
116 (m): m is { role: string; text: string } => typeof m === 'object' && m !== null && typeof m.text === 'string',
117 )
118 const task = messages.find(m => m.role === 'user' && m.text.trim() !== '')?.text
119 const answer = [...messages].reverse().find(m => m.role === 'assistant' && m.text.trim() !== '')?.text
120 return { task, answer }
121}
122
123async function openDetail($: EngineInterface, agentId: string): Promise<void> {
124 const s = await change($, t => detailOpened(t, agentId))
125 if (s.detail?.agentId !== agentId || s.detail.state !== 'loading') return
126 const rows = await $.session.messages({ agentId }).catch(() => undefined)
127 await change($, t => detailLoaded(t, agentId, summarize(rows)))
128}
129
130function actionsFor($: EngineInterface): TreeActions {
131 const ignore = () => undefined
132 return {
133 pick: target => void change($, t => pickerOpened(t, target)).catch(ignore),
134 choose: (target, family) => void clock($).then(now => change($, t => chosen(t, target, family, now))).catch(ignore),
135 close: () => void change($, t => closed(t)).catch(ignore),
136 detail: agentId => void openDetail($, agentId).catch(ignore),
137 stop: agentId => void clock($).then(now => change($, t => stopRequested(t, agentId, now))).catch(ignore),
138 }
139}
140
141export const register: Register = (on, options) => {
142 const columns = paneColumns(options.columns)
143 const keep = agentLimit(options.maxAgents)
144 const openOnStart = options.openOnStart === true
145 animate = options.animate !== false
146 lang = resolveLang(options.language, undefined)
147
148 on('session.start', async ($, e, next) => {
149 const result = await next(e)
150 if (options.language !== 'de' && options.language !== 'en') {
151 lang = resolveLang(options.language, await $.settings.read({ key: 'language' }).catch(() => undefined))
152 }
153 await $.command
154 .register({ name: COMMAND, description: wordsFor(lang).commandDescription, argumentHint: '[stop|clear]', immediate: true })
155 .catch(() => undefined)
156 const advisorModel = await $.settings.read({ key: 'advisorModel' }).catch(() => undefined)
157 await change($, s => withAdvisorModel(s, typeof advisorModel === 'string' ? advisorModel : undefined)).catch(() => undefined)
158 await catchUp($, await clock($), keep).catch(() => undefined)
159 if (openOnStart) await $.ui.open({ id: PANE, title: TITLE, columns, rows: INLINE_ROWS }).catch(() => undefined)
160 return result
161 })
162
163 on('command.run', { command: COMMAND }, async ($, e) => {
164 const w = wordsFor(lang)
165 const arg = e.args.trim().toLowerCase()
166 if (arg === 'stop' || arg === 'close') {
167 await $.ui.close({ id: PANE }).catch(() => undefined)
168 return { text: w.closedText }
169 }
170 const now = await clock($)
171 if (arg === 'clear') {
172 await change($, s => withAdvisorModel(createTree(now), s.advisor.model))
173 return { text: w.cleared }
174 }
175 await catchUp($, now, keep)
176 try {
177 await $.ui.open({ id: PANE, title: TITLE, focus: true, columns, rows: INLINE_ROWS })
178 } catch (err) {
179 return { text: w.notOpened(String(err)) }
180 }
181 const s = await read($, tree)
182 return { text: w.opened(s.agents.length) }
183 }).catch(() => ({ text: wordsFor(lang).commandFailed }))
184
185 on('turn.start', async ($, e, next) => {
186 const result = await next(e)
187 const now = await clock($)
188 await change($, s => turnStarted(s, { text: e.text, agentId: e.agentId }, now)).catch(() => undefined)
189 return result
190 })
191
192 on('turn.step', async function* ($, e, next) {
193 const agentId = e.agentId
194 const before = agentId ? await read($, tree) : undefined
195 if (agentId && before && isStopping(before, agentId)) {
196 const report = wordsFor(lang).stopAnswer(before.agents.find(n => n.id === agentId)?.toolCount ?? 0)
197 const first = !wasStopSent(before, agentId)
198 if (first) await change($, s => stopSent(s, agentId)).catch(() => undefined)
199 const stopReason = first ? ('tool_use' as const) : ('end_turn' as const)
200 yield { kind: 'text' as const, index: 0, text: report }
201 if (first) {
202 yield { kind: 'tool' as const, index: 1, id: handbackId(), name: HANDBACK }
203 yield { kind: 'input' as const, index: 1, json: JSON.stringify({ message: report }) }
204 }
205 yield { kind: 'stop' as const, stopReason, usage: null }
206 return {
207 turnId: e.turnId,
208 index: e.index,
209 answer: report,
210 toolUses: first ? [{ name: HANDBACK, input: { message: report } }] : [],
211 stopReason,
212 usage: null,
213 }
214 }
215 const family = before && e.agentId ? choicesOf(before).agents[e.agentId] : undefined
216 const model = before && family ? stepModelFor(before, family) : undefined
217 const result = yield* next(model ? { ...e, model } : e)
218 const usage = result.usage
219 const contextTokens = usage
220 ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
221 : undefined
222 const advisorUses = (result.serverToolUses ?? [])
223 .filter(use => use.name === 'advisor')
224 .map(use => ({ id: use.id, startedAt: use.startedAt, endedAt: use.endedAt }))
225 const now = await clock($)
226 await change($, s =>
227 stepped(
228 s,
229 { agentId: e.agentId, answer: result.answer, model: usage?.model ?? model ?? e.model, effort: e.effort, contextTokens, advisorUses },
230 now,
231 ),
232 ).catch(() => undefined)
233 return result
234 })
235
236 on('agent.spawn', async ($, e, next) => {
237 const family = choicesOf(await read($, tree)).newAgents
238 const input = family && !e.model && !e.fork && !e.resume ? { ...e, model: family } : e
239 const result = await next(input)
240 const agentId = result.agentId
241 if (agentId) {
242 const now = await clock($)
243 await change($, s =>
244 trimmed(
245 spawned(
246 s,
247 {
248 agentId,
249 parentAgentId: e.parentAgentId,
250 agentType: e.subagentType,
251 description: e.description,
252 prompt: e.prompt,
253 model: result.model,
254 background: e.background,
255 fork: e.fork,
256 workflowRunId: e.workflow?.runId || undefined,
257 },
258 now,
259 ),
260 keep,
261 ),
262 ).catch(() => undefined)
263 }
264 return result
265 }).catch(($, e, next) => next(e))
266
267 on('tool.call', async ($, e, next) => {
268 const agentId = e.agentId
269 if (!agentId) return next(e)
270 if (stoppingIds.has(agentId)) {
271 if (e.tool !== HANDBACK) {
272 const current = await read($, tree)
273 return { deny: wordsFor(lang).stopAnswer(current.agents.find(n => n.id === agentId)?.toolCount ?? 0) }
274 }
275 if (stopSentIds.has(agentId)) return next(e)
276 await change($, s => stopCancelled(s, agentId)).catch(() => undefined)
277 }
278 const label = toolLabel(String(e.tool), e as unknown as Record<string, unknown>)
279 const noted = (async () => {
280 const known = (await read($, tree)).agents.some(n => n.id === agentId)
281 if (!known) return
282 const now = await clock($)
283 await change($, s => toolRan(s, { agentId, label }, now))
284 })().catch(() => undefined)
285 const result = await next(e)
286 await noted
287 return result
288 }).catch(($, e, next) => next(e))
289
290 on('turn.complete', async ($, e, next) => {
291 const result = await next(e)
292 const now = await clock($)
293 await change($, s =>
294 completed(s, { agentId: e.agentId, answer: e.answer, reason: e.reason, isAborted: e.isAborted, durationMs: e.durationMs }, now),
295 ).catch(() => undefined)
296 if (!e.agentId) await catchUp($, now, keep).catch(() => undefined)
297 return result
298 })
299
300 on('session.measure', async ($, e, next) => {
301 const result = await next(e)
302 if (e.changed.includes('context') || e.changed.includes('cost')) {
303 await change($, s =>
304 measured(s, { tokens: e.context.tokens, window: e.context.window, percent: e.context.percent, costUsd: e.cost?.usd }),
305 ).catch(() => undefined)
306 }
307 return result
308 })
309
310 on('session.end', async ($, e, next) => {
311 const reset =
312 e.reason === 'clear' || e.reason === 'resume'
313 ? clock($).then(now => change($, s => withAdvisorModel(createTree(now), s.advisor.model))).catch(() => undefined)
314 : Promise.resolve()
315 const result = await next(e)
316 await reset
317 return result
318 })
319
320 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
321 const { Box, Text, Button } = $.ui.resolve(e)
322 const s = await read($, tree)
323 const tick = animate ? await read($, frame) : 0
324 const now = await clock($)
325 return drawTree({ Box, Text, Button } as never, s, e.props.bodyColumns, e.props.scroll?.bodyRows, {
326 frame: tick,
327 animate,
328 now,
329 lang,
330 actions: actionsFor($),
331 })
332 })
333
334 on('ui.press', { component: 'Pane', requestId: PANE }, async ($, e, next) =>
335 pressKey(actionsFor($), e.element) ? { element: e.element } : next(e),
336 )
337}
338hooks/model.ts 621 lines1// © 2026 Mike Pollow, Digitalroots. MIT-Lizenz.
2import type {
3 AgentTreeChoices,
4 AgentTreeCounts,
5 AgentTreeDetail,
6 AgentTreeGroup,
7 AgentTreeLogSource,
8 AgentTreeNode,
9 AgentTreePhase,
10 AgentTreeState,
11 AgentTreeStatus,
12} from '../types'
13
14export const LOG_LIMIT = 40
15export const LOG_EXCERPT = 60
16export const DESCRIPTION_LIMIT = 80
17export const LABEL_LIMIT = 40
18export const ADVISOR_CALLS_KEPT = 20
19export const TOOLS_KEPT = 5
20export const DETAIL_LIMIT = 160
21
22export type AdvisorUse = { id: string; startedAt: number; endedAt?: number }
23
24export type StepInput = {
25 agentId?: string
26 answer?: string
27 model: string
28 effort?: string | number
29 contextTokens?: number
30 advisorUses?: readonly AdvisorUse[]
31}
32
33export type SpawnInput = {
34 agentId: string
35 parentAgentId?: string
36 agentType: string
37 description: string
38 prompt?: string
39 model?: string
40 background: boolean
41 fork: boolean
42 workflowRunId?: string
43}
44
45export type CompleteInput = {
46 agentId?: string
47 answer: string
48 reason: string
49 isAborted: boolean
50 durationMs: number
51}
52
53type WorkflowGroup = Extract<AgentTreeGroup, { kind: 'workflow' }>
54
55const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
56
57const ENGINE_STATUS: Record<string, AgentTreeStatus> = {
58 completed: 'done',
59 failed: 'failed',
60 killed: 'stopped',
61}
62
63export const MODEL_FAMILIES = ['haiku', 'sonnet', 'opus', 'fable'] as const
64
65export const NEW_AGENTS = 'new'
66
67export function choicesOf(s: AgentTreeState): AgentTreeChoices {
68 return s.choices ?? { agents: {} }
69}
70
71export function familyLabel(family: string | undefined): string {
72 return family ? family.charAt(0).toUpperCase() + family.slice(1) : 'Standard'
73}
74
75export function pickerOpened(s: AgentTreeState, target: string | undefined): AgentTreeState {
76 return { ...s, detail: target === undefined ? s.detail : undefined, choices: { ...choicesOf(s), picker: target } }
77}
78
79export function closed(s: AgentTreeState): AgentTreeState {
80 return { ...s, detail: undefined, choices: { ...choicesOf(s), picker: undefined } }
81}
82
83export function detailOpened(s: AgentTreeState, agentId: string): AgentTreeState {
84 const node = findAgent(s, agentId)
85 if (!node || s.detail?.agentId === agentId) return { ...s, detail: undefined }
86 const detail: AgentTreeDetail = { agentId, state: node.task ? 'ready' : 'loading' }
87 return { ...s, detail, choices: { ...choicesOf(s), picker: undefined } }
88}
89
90export function detailLoaded(
91 s: AgentTreeState,
92 agentId: string,
93 found: { task?: string; answer?: string; denied?: boolean },
94): AgentTreeState {
95 const next = withAgent(s, agentId, n => ({
96 ...n,
97 task: n.task || (found.task ? excerpt(found.task) : undefined),
98 answer: n.answer || (found.answer ? excerpt(found.answer) : undefined),
99 }))
100 if (next.detail?.agentId !== agentId) return next
101 return { ...next, detail: { agentId, state: found.denied ? 'denied' : 'ready' } }
102}
103
104export function stopRequested(s: AgentTreeState, agentId: string, now: number): AgentTreeState {
105 const node = findAgent(s, agentId)
106 if (!node || node.status !== 'running' || isStopping(s, agentId)) return s
107 return logged({ ...s, stopping: [...(s.stopping ?? []), agentId] }, 'session', 'stopRequested', { type: node.type }, now)
108}
109
110export function isStopping(s: AgentTreeState, agentId: string | undefined): boolean {
111 return agentId !== undefined && (s.stopping ?? []).includes(agentId)
112}
113
114export function stopSent(s: AgentTreeState, agentId: string): AgentTreeState {
115 if (wasStopSent(s, agentId)) return s
116 return { ...s, stopSent: [...(s.stopSent ?? []), agentId] }
117}
118
119export function wasStopSent(s: AgentTreeState, agentId: string | undefined): boolean {
120 return agentId !== undefined && (s.stopSent ?? []).includes(agentId)
121}
122
123export function stopCancelled(s: AgentTreeState, agentId: string): AgentTreeState {
124 return isStopping(s, agentId) || wasStopSent(s, agentId) ? withoutStop(s, agentId) : s
125}
126
127function withoutStop(s: AgentTreeState, agentId: string): AgentTreeState {
128 return {
129 ...s,
130 stopping: (s.stopping ?? []).filter(id => id !== agentId),
131 stopSent: (s.stopSent ?? []).filter(id => id !== agentId),
132 }
133}
134
135export function chosen(s: AgentTreeState, target: string, family: string | undefined, now: number): AgentTreeState {
136 const current = choicesOf(s)
137 const picker = current.picker === target ? undefined : current.picker
138 const agents = { ...current.agents }
139 let choices: AgentTreeChoices
140 let who = ''
141 if (target === NEW_AGENTS) {
142 choices = { ...current, newAgents: family, agents, picker }
143 } else {
144 const node = findAgent(s, target)
145 if (!node) return s
146 if (family === undefined) delete agents[target]
147 else agents[target] = family
148 choices = { ...current, agents, picker }
149 who = node.type
150 }
151 return logged({ ...s, choices }, 'session', 'choice', { who, family: family ?? '' }, now)
152}
153
154export function stepModelFor(s: AgentTreeState, family: string): string {
155 const seen = [s.main.model, ...s.agents.map(n => n.model)]
156 return seen.find(model => model !== undefined && new RegExp(`claude-${family}-\\d`).test(model)) ?? family
157}
158
159export function createTree(now: number): AgentTreeState {
160 return {
161 startedAt: now,
162 main: { active: false, turn: 0, returned: 0, returnedTokens: 0 },
163 agents: [],
164 advisor: { total: 0, fromMain: 0, fromAgents: 0, calls: [] },
165 log: [],
166 }
167}
168
169const AGENT_MESSAGE = /^\s*<(agent-message|task-notification)\b/
170
171function logged(
172 s: AgentTreeState,
173 source: AgentTreeLogSource,
174 kind: string,
175 data: Record<string, string | number>,
176 at: number,
177): AgentTreeState {
178 const log = [...s.log]
179 let place = log.length
180 while (place > 0 && (log[place - 1]?.at ?? 0) > at) place -= 1
181 log.splice(place, 0, { at, source, kind, data })
182 return { ...s, log: log.slice(-LOG_LIMIT) }
183}
184
185function findAgent(s: AgentTreeState, id: string | undefined): AgentTreeNode | undefined {
186 return id === undefined ? undefined : s.agents.find(n => n.id === id)
187}
188
189function withAgent(s: AgentTreeState, id: string, change: (n: AgentTreeNode) => AgentTreeNode): AgentTreeState {
190 return { ...s, agents: s.agents.map(n => (n.id === id ? change(n) : n)) }
191}
192
193const ANSI = /\u001b\[[0-9;?]*[ -\/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
194
195const CONTROL = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g
196
197const TOKEN =
198 /\b(?:ghp_|gho_|ghu_|ghs_|github_pat_|sk-|xox[abprs]-|AKIA|AIza|eyJ)[A-Za-z0-9_\-.]{6,}|\bBasic\s+(?=[A-Za-z0-9+\/]*[0-9+\/=])[A-Za-z0-9+\/]{8,}={0,2}/g
199
200const SECRET =
201 /\bBearer\s+\S+|\b[a-z][a-z0-9+.-]*:\/\/[^\s\/:@]+:[^\s\/@]+@|[A-Za-z0-9_]*(?:password|passwort|passwd|token|secret|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*["']?\s*[=:]\s*(?:"[^"]*"|'[^']*'|["']?[^\s"',;]+)/gi
202
203const ENV_SECRET = /\b[A-Z][A-Z0-9_]*(?:PASS|PWD|KEY|CREDENTIALS?)[A-Z0-9_]*=\S+/g
204
205function oneLine(text: string): string {
206 return text
207 .replace(ANSI, ' ')
208 .replace(CONTROL, ' ')
209 .replace(TOKEN, '•••')
210 .replace(SECRET, '•••')
211 .replace(ENV_SECRET, '•••')
212 .replace(/\s+/g, ' ')
213 .trim()
214}
215
216export function excerpt(text: string | undefined, limit = DETAIL_LIMIT): string {
217 return oneLine(text ?? '').slice(0, limit)
218}
219
220export function counts(s: AgentTreeState): AgentTreeCounts {
221 const tally: AgentTreeCounts = { running: 0, done: 0, failed: 0, stopped: 0 }
222 for (const n of s.agents) tally[n.status] += 1
223 return tally
224}
225
226export function phaseOf(s: AgentTreeState): AgentTreePhase {
227 if (s.agents.some(n => n.status === 'running')) return 'delegating'
228 if (!s.main.active) return 'idle'
229 return s.main.returned > 0 ? 'reviewing' : 'planning'
230}
231
232export function withAdvisorModel(s: AgentTreeState, model: string | undefined): AgentTreeState {
233 return { ...s, advisor: { ...s.advisor, model } }
234}
235
236export function turnStarted(s: AgentTreeState, input: { text: string; agentId?: string }, now: number): AgentTreeState {
237 if (input.agentId !== undefined) {
238 const node = findAgent(s, input.agentId)
239 if (!node || node.status === 'running') return s
240 const reopened = withAgent(s, node.id, n => ({ ...n, status: 'running', endedAt: undefined, startedAt: now }))
241 return logged(reopened, 'agent', 'resumed', { type: node.type, description: node.description }, now)
242 }
243 if (AGENT_MESSAGE.test(input.text) || input.text.trim() === '') {
244 return { ...s, main: { ...s.main, active: true, resumed: true, turnStartedAt: now } }
245 }
246 const turn = s.main.turn + 1
247 const excerpt = oneLine(input.text).slice(0, LOG_EXCERPT)
248 const next: AgentTreeState = {
249 ...s,
250 startedAt: s.startedAt || now,
251 main: { ...s.main, active: true, resumed: false, turn, turnStartedAt: now, roundStartedAt: now, returned: 0, returnedTokens: 0 },
252 }
253 return logged(next, 'main', 'turn', { turn, excerpt }, now)
254}
255
256function advised(s: AgentTreeState, use: AdvisorUse, agentId: string | undefined, now: number): AgentTreeState {
257 const durationMs = use.endedAt === undefined ? undefined : Math.max(0, use.endedAt - use.startedAt)
258 const known = s.advisor.calls.find(c => c.id === use.id)
259 if (known) {
260 if (known.durationMs !== undefined || durationMs === undefined) return s
261 const calls = s.advisor.calls.map(c => (c.id === use.id ? { ...c, durationMs } : c))
262 return { ...s, advisor: { ...s.advisor, calls } }
263 }
264 const fromMain = agentId === undefined
265 const advisor = {
266 ...s.advisor,
267 total: s.advisor.total + 1,
268 fromMain: s.advisor.fromMain + (fromMain ? 1 : 0),
269 fromAgents: s.advisor.fromAgents + (fromMain ? 0 : 1),
270 calls: [...s.advisor.calls, { id: use.id, at: use.startedAt, durationMs, agentId }].slice(-ADVISOR_CALLS_KEPT),
271 }
272 const where = fromMain ? '' : findAgent(s, agentId)?.type ?? '?'
273 return logged({ ...s, advisor }, 'advisor', 'advisor', { where }, Math.min(use.startedAt, now))
274}
275
276export function stepped(s: AgentTreeState, input: StepInput, now: number): AgentTreeState {
277 const effort = input.effort === undefined ? undefined : String(input.effort)
278 let next = s
279 if (input.agentId === undefined) {
280 next = {
281 ...next,
282 main: {
283 ...next.main,
284 model: input.model,
285 effort: effort ?? next.main.effort,
286 contextTokens: input.contextTokens ?? next.main.contextTokens,
287 },
288 }
289 } else if (findAgent(next, input.agentId)) {
290 next = withAgent(next, input.agentId, n => ({
291 ...n,
292 model: input.model,
293 effort: effort ?? n.effort,
294 contextTokens: input.contextTokens ?? n.contextTokens,
295 answer: input.answer ? excerpt(input.answer) : n.answer,
296 }))
297 }
298 for (const use of input.advisorUses ?? []) next = advised(next, use, input.agentId, now)
299 return next
300}
301
302export const ORPHANS_KEPT = 20
303
304function returnedTo(s: AgentTreeState, node: AgentTreeNode, answerTokens: number): AgentTreeState {
305 if (node.parentId !== undefined) return s
306 return {
307 ...s,
308 main: { ...s.main, returned: s.main.returned + 1, returnedTokens: s.main.returnedTokens + answerTokens },
309 }
310}
311
312export function spawned(s: AgentTreeState, input: SpawnInput, now: number): AgentTreeState {
313 const existing = findAgent(s, input.agentId)
314 if (existing) {
315 const resumed = withAgent(s, existing.id, n => ({
316 ...n,
317 parentId: input.parentAgentId ?? n.parentId,
318 model: input.model ?? n.model,
319 status: 'running',
320 startedAt: now,
321 endedAt: undefined,
322 }))
323 return logged(resumed, 'agent', 'resumed', { type: existing.type, description: existing.description }, now)
324 }
325 const orphan = s.orphans?.[input.agentId]
326 const node: AgentTreeNode = {
327 id: input.agentId,
328 parentId: input.parentAgentId,
329 type: input.fork ? 'Fork' : input.agentType,
330 description: oneLine(input.description).slice(0, DESCRIPTION_LIMIT),
331 task: input.prompt ? excerpt(input.prompt) || undefined : undefined,
332 model: input.model,
333 background: input.background,
334 fork: input.fork,
335 workflowRunId: input.workflowRunId,
336 status: orphan?.status ?? 'running',
337 startedAt: orphan ? Math.min(now, orphan.at) : now,
338 endedAt: orphan?.at,
339 answerTokens: orphan?.answerTokens,
340 answer: orphan?.answer,
341 toolCount: 0,
342 }
343 const runSeen = node.workflowRunId !== undefined && s.agents.some(n => n.workflowRunId === node.workflowRunId)
344 const orphans = { ...s.orphans }
345 delete orphans[node.id]
346 let next: AgentTreeState = {
347 ...s,
348 startedAt: s.startedAt || now,
349 orphans,
350 agents: [...s.agents, node],
351 }
352 if (orphan) next = returnedTo(next, node, orphan.answerTokens)
353 if (runSeen) return next
354 if (node.workflowRunId !== undefined) return logged(next, 'agent', 'workflowStarted', { description: node.description }, node.startedAt)
355 next = logged(next, 'agent', 'agentStarted', { type: node.type, description: node.description }, node.startedAt)
356 if (!orphan) return next
357 const ended = { type: node.type, status: orphan.status, durationMs: orphan.durationMs ?? 0, tokens: orphan.answerTokens }
358 return logged(next, 'agent', 'agentEnded', ended, orphan.at)
359}
360
361export function toolRan(s: AgentTreeState, input: { agentId?: string; label: string }, now: number): AgentTreeState {
362 if (input.agentId === undefined || !findAgent(s, input.agentId)) return s
363 return withAgent(s, input.agentId, n => ({
364 ...n,
365 toolCount: n.toolCount + 1,
366 lastTool: input.label,
367 tools: [...(n.tools ?? []), input.label].slice(-TOOLS_KEPT),
368 }))
369}
370
371function statusOf(input: CompleteInput): AgentTreeStatus {
372 if (input.isAborted || input.reason === 'aborted') return 'stopped'
373 if (input.reason === 'refusal' || input.reason === 'error') return 'failed'
374 return 'done'
375}
376
377export function completed(s: AgentTreeState, input: CompleteInput, now: number): AgentTreeState {
378 const status = statusOf(input)
379 if (input.agentId === undefined) {
380 const next: AgentTreeState = { ...s, main: { ...s.main, active: false } }
381 if (s.main.resumed) return next
382 return logged(next, 'main', 'mainDone', { status, durationMs: input.durationMs }, now)
383 }
384 const answerTokens = estimateTokens(input.answer)
385 const node = findAgent(s, input.agentId)
386 if (!node) {
387 const answer = input.answer ? excerpt(input.answer) : undefined
388 const kept = Object.entries({ ...s.orphans, [input.agentId]: { status, at: now, answerTokens, answer, durationMs: input.durationMs } }).slice(
389 -ORPHANS_KEPT,
390 )
391 return { ...s, orphans: Object.fromEntries(kept) }
392 }
393 if (node.status !== 'running' && node.answerTokens !== undefined) return s
394 const final = node.status === 'stopped' || wasStopSent(s, node.id) ? 'stopped' : status
395 let next = withAgent(withoutStop(s, node.id), node.id, n => ({
396 ...n,
397 status: final,
398 endedAt: n.status === 'running' ? now : n.endedAt ?? now,
399 answerTokens,
400 answer: input.answer ? excerpt(input.answer) : n.answer,
401 }))
402 next = returnedTo(next, node, answerTokens)
403 if (node.workflowRunId !== undefined) {
404 const stillRunning = next.agents.some(n => n.workflowRunId === node.workflowRunId && n.status === 'running')
405 if (stillRunning) return next
406 const size = next.agents.filter(n => n.workflowRunId === node.workflowRunId).length
407 return logged(next, 'agent', 'workflowDone', { size }, now)
408 }
409 return logged(next, 'agent', 'agentEnded', { type: node.type, status: final, durationMs: input.durationMs, tokens: answerTokens }, now)
410}
411
412export function measured(
413 s: AgentTreeState,
414 input: { tokens?: number; window: number; percent?: number; costUsd?: number },
415): AgentTreeState {
416 return {
417 ...s,
418 main: {
419 ...s.main,
420 contextTokens: input.tokens ?? s.main.contextTokens,
421 window: input.window,
422 percent: input.percent ?? s.main.percent,
423 costUsd: input.costUsd ?? s.main.costUsd,
424 },
425 }
426}
427
428export function synced(s: AgentTreeState, list: readonly { id: string; status: string }[], now: number): AgentTreeState {
429 let next = s
430 for (const entry of list) {
431 const status = ENGINE_STATUS[entry.status]
432 const node = findAgent(next, entry.id)
433 if (!status || !node || node.status !== 'running') continue
434 const final = status === 'done' && wasStopSent(next, entry.id) ? 'stopped' : status
435 next = withAgent(withoutStop(next, entry.id), entry.id, n => ({ ...n, status: final, endedAt: n.endedAt ?? now }))
436 }
437 return next
438}
439
440export type ListedAgent = { id: string; description: string; type: string; status: string; parentId?: string }
441
442const LISTED_STATUS: Record<string, AgentTreeStatus> = {
443 pending: 'running',
444 running: 'running',
445 waiting: 'running',
446 idle: 'running',
447 completed: 'done',
448 failed: 'failed',
449 killed: 'stopped',
450}
451
452export function adopted(s: AgentTreeState, list: readonly ListedAgent[], now: number): AgentTreeState {
453 const known = new Set(s.agents.map(n => n.id))
454 const fresh: AgentTreeNode[] = []
455 for (const entry of list) {
456 const status = LISTED_STATUS[entry.status]
457 if (status !== 'running' || known.has(entry.id)) continue
458 known.add(entry.id)
459 fresh.push({
460 id: entry.id,
461 parentId: entry.parentId,
462 type: entry.type,
463 description: oneLine(entry.description).slice(0, DESCRIPTION_LIMIT),
464 background: false,
465 fork: false,
466 status,
467 startedAt: now,
468 endedAt: status === 'running' ? undefined : now,
469 toolCount: 0,
470 })
471 }
472 if (fresh.length === 0) return s
473 const next: AgentTreeState = { ...s, startedAt: s.startedAt || now, agents: [...s.agents, ...fresh] }
474 return logged(next, 'agent', 'adopted', { count: fresh.length }, now)
475}
476
477export function trimmed(s: AgentTreeState, maxAgents: number): AgentTreeState {
478 const limit = Math.max(1, Math.floor(maxAgents))
479 if (s.agents.length <= limit) return s
480 const finished = s.agents
481 .filter(n => n.status !== 'running')
482 .sort((a, b) => (a.endedAt ?? a.startedAt) - (b.endedAt ?? b.startedAt))
483 const dropped = new Set(finished.slice(0, s.agents.length - limit).map(n => n.id))
484 const choices = s.choices && {
485 ...s.choices,
486 agents: Object.fromEntries(Object.entries(s.choices.agents).filter(([id]) => !dropped.has(id))),
487 picker: s.choices.picker && dropped.has(s.choices.picker) ? undefined : s.choices.picker,
488 }
489 return {
490 ...s,
491 choices,
492 detail: s.detail && dropped.has(s.detail.agentId) ? undefined : s.detail,
493 agents: s.agents.filter(n => !dropped.has(n.id)),
494 }
495}
496
497export function groups(s: AgentTreeState): AgentTreeGroup[] {
498 const all: AgentTreeGroup[] = []
499 const runs = new Map<string, WorkflowGroup>()
500 for (const node of s.agents) {
501 if (node.workflowRunId === undefined) {
502 all.push({ kind: 'agent', node })
503 continue
504 }
505 let run = runs.get(node.workflowRunId)
506 if (!run) {
507 run = {
508 kind: 'workflow',
509 runId: node.workflowRunId,
510 size: 0,
511 running: 0,
512 done: 0,
513 failed: 0,
514 stopped: 0,
515 startedAt: node.startedAt,
516 lastDescription: '',
517 }
518 runs.set(node.workflowRunId, run)
519 all.push(run)
520 }
521 run.size += 1
522 run[node.status] += 1
523 run.lastDescription = node.description
524 }
525 const isRunning = (g: AgentTreeGroup) => (g.kind === 'agent' ? g.node.status === 'running' : g.running > 0)
526 const recency = (g: AgentTreeGroup) => (g.kind === 'agent' ? g.node.endedAt ?? g.node.startedAt : g.startedAt)
527 return [...all.filter(isRunning), ...all.filter(g => !isRunning(g)).sort((a, b) => recency(b) - recency(a))]
528}
529
530function decimal(value: number, digits: number, separator: string): string {
531 return value.toFixed(digits).replace(/\.0$/, '').replace('.', separator)
532}
533
534export function fmtTokens(n: number | undefined, separator = ','): string {
535 if (n === undefined || !Number.isFinite(n)) return ''
536 if (n < 1000) return String(Math.round(n))
537 const thousands = n / 1000
538 const thousandDigits = thousands < 100 ? 1 : 0
539 if (Number(thousands.toFixed(thousandDigits)) < 1000) return `${decimal(thousands, thousandDigits, separator)}k`
540 const millions = n / 1_000_000
541 return `${decimal(millions, millions < 100 ? 1 : 0, separator)}M`
542}
543
544export function fmtShortDuration(ms: number): string {
545 const seconds = Math.max(0, Math.round(ms / 1000))
546 if (seconds < 60) return `${seconds} s`
547 const minutes = Math.floor(seconds / 60)
548 if (minutes < 60) return `${minutes}:${String(seconds % 60).padStart(2, '0')}`
549 return `${Math.floor(minutes / 60)} h ${minutes % 60}`
550}
551
552export function typeLabel(type: string): string {
553 return type.split(':').at(-1) || type
554}
555
556export function fmtDuration(ms: number): string {
557 const seconds = Math.max(0, Math.round(ms / 1000))
558 if (seconds < 60) return `${seconds} s`
559 const minutes = Math.floor(seconds / 60)
560 if (minutes < 60) return `${minutes}:${String(seconds % 60).padStart(2, '0')} min`
561 return `${Math.floor(minutes / 60)} h ${minutes % 60} min`
562}
563
564export function effortBar(effort: string | number | undefined): string {
565 const level = EFFORT_LEVELS.indexOf(String(effort ?? '')) + 1
566 if (level === 0) return ''
567 return '▮'.repeat(level) + '▯'.repeat(EFFORT_LEVELS.length - level)
568}
569
570let clockFormat: Intl.DateTimeFormat | undefined
571
572export function clockTime(ms: number): string {
573 try {
574 clockFormat ??= new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
575 return clockFormat.format(ms)
576 } catch {
577 return new Date(ms).toISOString().slice(11, 19)
578 }
579}
580
581export function estimateTokens(text: string | undefined): number {
582 return Math.ceil((text ?? '').length / 4)
583}
584
585export function modelLabel(model: string | undefined): string {
586 if (!model) return ''
587 const capital = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
588 const plain = model.replace(/\[[^\]]*\]$/, '')
589 const modern = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(plain)
590 if (modern) return `${capital(modern[1] ?? '')} ${modern[2]}${modern[3] ? `.${modern[3]}` : ''}`
591 const legacy = /claude-(\d+)(?:-(\d+))?-([a-z]+)/.exec(plain)
592 if (legacy) return `${capital(legacy[3] ?? '')} ${legacy[1]}${legacy[2] ? `.${legacy[2]}` : ''}`
593 if (/^[a-z]+$/.test(plain)) return capital(plain)
594 return model
595}
596
597const BARE_TOOLS = new Set(['Grep', 'Glob', 'WebSearch', 'ToolSearch', 'SubagentHandback'])
598
599export function toolLabel(tool: string, input: Record<string, unknown>): string {
600 const text = (value: unknown) => (typeof value === 'string' ? oneLine(value) : '')
601 const fileName = (value: unknown) => text(value).split('/').filter(Boolean).at(-1) ?? ''
602 const program = (value: unknown) =>
603 fileName(text(typeof value === 'string' ? value.trimStart().replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, '') : value).split(' ')[0])
604 const host = (value: unknown) => {
605 try {
606 return new URL(text(value)).host
607 } catch {
608 return ''
609 }
610 }
611 const detail = BARE_TOOLS.has(tool)
612 ? ''
613 : text(input.description) ||
614 fileName(input.file_path) ||
615 fileName(input.notebook_path) ||
616 program(input.command) ||
617 text(input.skill) ||
618 host(input.url)
619 return (detail ? `${tool} ${detail}` : tool).slice(0, LABEL_LIMIT)
620}
621hooks/view.tsx 832 lines1// © 2026 Mike Pollow, Digitalroots. MIT-Lizenz.
2import type { Elements } from 'claude-code'
3
4import type { AgentTreeAdvisorCall, AgentTreeGroup, AgentTreeLogSource, AgentTreeNode, AgentTreeState, AgentTreeStatus } from '../types'
5import {
6 MODEL_FAMILIES,
7 NEW_AGENTS,
8 choicesOf,
9 clockTime,
10 counts,
11 effortBar,
12 familyLabel,
13 fmtShortDuration,
14 fmtTokens,
15 groups,
16 modelLabel,
17 phaseOf,
18 typeLabel,
19} from './model.ts'
20import { WORDS, familyOr, logLine, toolText, wordsFor } from './words.ts'
21import type { Lang, Words } from './words.ts'
22
23export { WORDS, logLine, wordsFor }
24export type { Lang }
25
26type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
27
28export const WIDE_FROM = 84
29export const TIGHT_BELOW = 36
30export const ADVISOR_WIDTH = 20
31export const AGENT_WIDTH = 19
32export const AGENT_GAP = 2
33export const MAIN_WIDTH = 31
34export const BACK_WIDTH = 35
35export const MAX_GROUPS = 9
36export const MAX_ROWS = 12
37export const LOG_ROWS = 6
38export const MIN_LOG_ROWS = 2
39export const ADVISOR_ROWS = 4
40export const ADVISOR_BASE_ROWS = 15
41export const ADVISOR_COMPACT_ROWS = 4
42export const LEGEND_EFFORT_FROM = 60
43export const BAR_WIDTH = 15
44export const DETAIL_ROWS = 8
45
46const COLOR = {
47 main: '#E6845D',
48 agent: '#7CAAE8',
49 advisor: '#B1A7E6',
50 done: '#7BE396',
51 dim: '#8E8E9A',
52 failed: 'error',
53}
54
55const SIZE = { title: 'heading', detail: 'footnote', note: 'caption' } as const
56
57const STATUS_MARK: Record<AgentTreeStatus, string> = { running: '◐', done: '●', failed: '✗', stopped: '○' }
58
59const SPIN = ['◐', '◓', '◑', '◒']
60
61export function runningMark(frame: number, animate: boolean): string {
62 if (!animate) return STATUS_MARK.running
63 return SPIN[((frame % SPIN.length) + SPIN.length) % SPIN.length] ?? STATUS_MARK.running
64}
65
66export type TreeActions = {
67 pick: (target: string) => void
68 choose: (target: string, family: string | undefined) => void
69 close: () => void
70 detail: (agentId: string) => void
71 stop: (agentId: string) => void
72}
73
74export type TreeLook = { frame: number; animate: boolean; now?: number; lang?: Lang; actions?: TreeActions }
75
76export function pressKey(actions: TreeActions, key: string): boolean {
77 const cut = key.indexOf(':')
78 if (cut < 0) return false
79 const kind = key.slice(0, cut)
80 const rest = key.slice(cut + 1)
81 if (key === 'picker:close' || key === 'detail:close') actions.close()
82 else if (kind === 'pick') actions.pick(rest)
83 else if (kind === 'choose') return chooseKey(actions, rest)
84 else if (kind === 'detail') actions.detail(rest)
85 else if (kind === 'stop') actions.stop(rest)
86 else return false
87 return true
88}
89
90function chooseKey(actions: TreeActions, rest: string): boolean {
91 const cut = rest.lastIndexOf(':')
92 if (cut < 1 || cut + 1 >= rest.length) return false
93 const family = rest.slice(cut + 1)
94 actions.choose(rest.slice(0, cut), family === 'default' ? undefined : family)
95 return true
96}
97
98const STATUS_COLOR: Record<AgentTreeStatus, string> = {
99 running: COLOR.agent,
100 done: COLOR.done,
101 failed: COLOR.failed,
102 stopped: COLOR.dim,
103}
104
105const SOURCE_COLOR: Record<AgentTreeLogSource, string> = {
106 session: COLOR.dim,
107 main: COLOR.main,
108 agent: COLOR.agent,
109 advisor: COLOR.advisor,
110}
111
112const GLYPH: Record<string, string> = {
113 udlr: '┼',
114 udl: '┤',
115 udr: '├',
116 ud: '│',
117 ulr: '┴',
118 ul: '┘',
119 ur: '└',
120 u: '│',
121 dlr: '┬',
122 dl: '┐',
123 dr: '┌',
124 d: '│',
125 lr: '─',
126 l: '─',
127 r: '─',
128}
129
130export function statusText(s: AgentTreeState, lang: Lang = 'de'): string | undefined {
131 const w = wordsFor(lang)
132 const c = counts(s)
133 if (c.running + c.done + c.failed + c.stopped === 0 && s.advisor.total === 0) return undefined
134 const parts = [`${w.agents} [${c.running}/${c.running + c.done + c.failed + c.stopped}]`]
135 if (c.failed) parts.push(`${c.failed} ${w.status.failed}`)
136 if (c.stopped) parts.push(`${c.stopped} ${w.status.stopped}`)
137 if (s.advisor.total) parts.push(`${w.advisorShort} [${s.advisor.total}×]`)
138 if (s.main.percent !== undefined) parts.push(`${w.contextShort} [${w.percent(s.main.percent)}]`)
139 if (s.main.costUsd !== undefined) parts.push(w.cost(s.main.costUsd))
140 return parts.join(' · ')
141}
142
143export type RowPlanInput = {
144 height?: number
145 wide: boolean
146 tight: boolean
147 asRows: boolean
148 perRow: number
149 total: number
150 advisorRows: number
151 earlier: boolean
152 pickerRows?: number
153 detailRows?: number
154 helpRows?: number
155}
156
157export type RowPlan = { shown: number; logRows: number }
158
159function treeRows(o: RowPlanInput, shown: number): number {
160 const top = 7 + 1
161 const later = o.earlier ? 1 : 0
162 if (o.total === 0) return top + 1 + later
163 const hidden = shown < o.total ? 1 : 0
164 const back = 1 + 5 + hidden + later
165 if (o.asRows) return top + 1 + shown + back
166 const boxRows = Math.ceil(shown / o.perRow)
167 return top + 4 + (10 * boxRows - 1) + 1 + back
168}
169
170function usedRows(o: RowPlanInput, shown: number, logRows: number): number {
171 const header = (o.tight ? 1 : 3) + (o.pickerRows ?? 0)
172 const tree = treeRows(o, shown)
173 const body = o.wide ? Math.max(o.advisorRows, tree) : o.advisorRows + tree
174 return header + 1 + body + (o.detailRows ?? 0) + 4 + logRows + (o.helpRows ?? 0)
175}
176
177export function planRows(o: RowPlanInput): RowPlan {
178 const cap = Math.min(o.total, o.asRows ? MAX_ROWS : MAX_GROUPS)
179 if (o.height === undefined) return { shown: cap, logRows: LOG_ROWS }
180 let shown = Math.min(1, cap)
181 for (let candidate = cap; candidate >= 1; candidate--) {
182 if (usedRows(o, candidate, MIN_LOG_ROWS) <= o.height) {
183 shown = candidate
184 break
185 }
186 }
187 const logRows = Math.max(1, Math.min(LOG_ROWS, o.height - usedRows(o, shown, 0)))
188 return { shown, logRows }
189}
190
191function oddFloor(n: number): number {
192 return n % 2 === 0 ? n - 1 : n
193}
194
195function rowWidth(boxes: number): number {
196 return boxes * AGENT_WIDTH + Math.max(0, boxes - 1) * AGENT_GAP + (boxes % 2 === 0 ? 1 : 0)
197}
198
199function boxGap(boxes: number, index: number): number {
200 if (index === 0) return 0
201 return AGENT_GAP + (boxes % 2 === 0 && index === boxes / 2 ? 1 : 0)
202}
203
204function rowCenters(boxes: number, offset: number): number[] {
205 const result: number[] = []
206 let left = offset
207 for (let i = 0; i < boxes; i++) {
208 left += boxGap(boxes, i)
209 result.push(left + (AGENT_WIDTH - 1) / 2)
210 left += AGENT_WIDTH
211 }
212 return result
213}
214
215export function connector(width: number, points: readonly number[], join: number, kind: 'split' | 'merge', step?: number): string {
216 const lo = Math.min(join, ...points)
217 const hi = Math.max(join, ...points)
218 const flow = (p: number): string | undefined => {
219 if (step === undefined || p === join) return undefined
220 const arm = p < join ? join - lo - 1 : hi - join - 1
221 if (arm < 2) return undefined
222 const out = p < join ? join - p : p - join
223 const head = kind === 'split' ? (step % arm) + 1 : arm - (step % arm)
224 const tail = kind === 'split' ? head - 1 : head + 1
225 return out === head ? '•' : out === tail && tail >= 1 && tail <= arm ? '·' : undefined
226 }
227 let line = ''
228 for (let p = 0; p < width; p++) {
229 if (p < lo || p > hi) {
230 line += ' '
231 continue
232 }
233 const atPoint = points.includes(p)
234 const up = kind === 'split' ? p === join : atPoint
235 const down = kind === 'split' ? atPoint : p === join
236 const key = `${up ? 'u' : ''}${down ? 'd' : ''}${p > lo ? 'l' : ''}${p < hi ? 'r' : ''}`
237 line += (key === 'lr' ? flow(p) : undefined) ?? GLYPH[key] ?? ' '
238 }
239 return line
240}
241
242export function timelineBar(start: number, end: number, from: number, to: number, width = BAR_WIDTH): string {
243 if (!(to > from)) return '█'.repeat(width)
244 const scale = (t: number) => Math.round(((Math.min(Math.max(t, from), to) - from) / (to - from)) * width)
245 const a = Math.min(width - 1, scale(start))
246 const b = Math.max(a + 1, scale(end))
247 return '░'.repeat(a) + '█'.repeat(b - a) + '░'.repeat(width - b)
248}
249
250export function shimmer(line: string, frame: number): string {
251 const chars = [...line]
252 if (chars.length === 0) return line
253 const at = ((frame % chars.length) + chars.length) % chars.length
254 return chars.map((c, i) => (i === at ? (c === '█' ? '▓' : '▒') : c)).join('')
255}
256
257function marks(width: number, points: readonly number[], glyph: string): string {
258 return Array.from({ length: width }, (_, p) => (points.includes(p) ? glyph : ' ')).join('')
259}
260
261function mostCommon(values: readonly (string | undefined)[]): string | undefined {
262 const tally = new Map<string, number>()
263 for (const v of values) if (v) tally.set(v, (tally.get(v) ?? 0) + 1)
264 let best: string | undefined
265 for (const [v, n] of tally) if (best === undefined || n > (tally.get(best) ?? 0)) best = v
266 return best
267}
268
269function effortLine(effort: string | undefined): string {
270 if (!effort) return ' '
271 const bar = effortBar(effort)
272 return bar ? `${bar} ${effort}` : `Effort ${effort}`
273}
274
275function contextLine(s: AgentTreeState, w: Words): string {
276 const { contextTokens, window, percent } = s.main
277 const share = percent !== undefined ? w.percent(percent) : ''
278 if (contextTokens === undefined) return share ? `${w.context} ${share}` : w.contextNone
279 const amount = window ? `${fmtTokens(contextTokens, w.decimal)} / ${fmtTokens(window, w.decimal)}` : fmtTokens(contextTokens, w.decimal)
280 return `${w.context} ${amount}${share ? ` · ${share}` : ''}`
281}
282
283function isRunning(g: AgentTreeGroup): boolean {
284 return g.kind === 'agent' ? g.node.status === 'running' : g.running > 0
285}
286
287function startOf(g: AgentTreeGroup): number {
288 return g.kind === 'agent' ? g.node.startedAt : g.startedAt
289}
290
291function endedSince(g: AgentTreeGroup, since: number): boolean {
292 return g.kind === 'agent' && g.node.endedAt !== undefined && g.node.endedAt >= since
293}
294
295function parentOf(s: AgentTreeState, n: AgentTreeNode): string | undefined {
296 if (n.parentId === undefined) return undefined
297 const parent = s.agents.find(p => p.id === n.parentId)
298 return parent ? typeLabel(parent.type) : '?'
299}
300
301function callPlace(call: AgentTreeAdvisorCall, w: Words): string {
302 return call.agentId === undefined ? w.fromMain : w.fromSub
303}
304
305export function pickerLines(name: string, width: number, lang: Lang = 'de'): number {
306 const w = wordsFor(lang)
307 const parts = [w.pickerFor(name), `[ ${w.standard} ]`, ...MODEL_FAMILIES.map(family => `[ ${familyLabel(family)} ]`), '[ × ]']
308 let lines = 1
309 let used = 0
310 for (const part of parts) {
311 const need = used === 0 ? part.length : used + 1 + part.length
312 if (need > width && used > 0) {
313 lines += 1
314 used = part.length
315 } else {
316 used = need
317 }
318 }
319 return lines
320}
321
322export function clip(text: string, width: number): string {
323 return text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
324}
325
326function chunk<T>(items: readonly T[], size: number): T[][] {
327 const rows: T[][] = []
328 for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size))
329 return rows
330}
331
332export function drawTree(ui: Ui, s: AgentTreeState, width: number, height?: number, look: TreeLook = { frame: 0, animate: false }) {
333 const { Box, Text, Button } = ui
334 const lang: Lang = look.lang ?? 'de'
335 const w = wordsFor(lang)
336 const spin = runningMark(look.frame, look.animate)
337 const choices = choicesOf(s)
338 const actions = look.actions
339 const now = look.now ?? Date.now()
340 const detail = s.detail
341 const tight = height !== undefined && height < TIGHT_BELOW
342 const wide = width >= WIDE_FROM && !tight
343 const treeWidth = Math.max(1, oddFloor(wide ? width - ADVISOR_WIDTH - 2 : width))
344 const perRow = Math.max(1, Math.floor((treeWidth + AGENT_GAP) / (AGENT_WIDTH + AGENT_GAP)))
345 const asRows = tight || perRow < 2
346 const mainWidth = Math.min(MAIN_WIDTH, treeWidth)
347 const backWidth = Math.min(BACK_WIDTH, treeWidth)
348 const phase = phaseOf(s)
349 const roundStart = s.main.roundStartedAt ?? 0
350 const all = groups(s)
351 const current = all.filter(g => isRunning(g) || startOf(g) >= roundStart || endedSince(g, roundStart))
352 const earlier = all.length - current.length
353 const recentCalls = s.advisor.calls.slice(-ADVISOR_ROWS)
354 const pickerTarget = choices.picker
355 const pickerNode = pickerTarget && pickerTarget !== NEW_AGENTS ? s.agents.find(n => n.id === pickerTarget) : undefined
356 const pickerName = pickerTarget === NEW_AGENTS ? w.newAgents : pickerNode ? `${typeLabel(pickerNode.type)} · ${pickerNode.description}` : '?'
357 const detailNode = detail ? s.agents.find(n => n.id === detail.agentId) : undefined
358 const plan = planRows({
359 height,
360 wide,
361 tight,
362 asRows,
363 perRow,
364 total: current.length,
365 advisorRows: wide ? ADVISOR_BASE_ROWS + recentCalls.length : ADVISOR_COMPACT_ROWS,
366 earlier: earlier > 0,
367 pickerRows: pickerTarget && actions ? 1 + pickerLines(pickerName, width, lang) : 0,
368 detailRows: detailNode && actions ? DETAIL_ROWS : 0,
369 helpRows: tight ? 0 : 1,
370 })
371 const shown = current.slice(0, plan.shown)
372 const rest = current.slice(shown.length)
373 const hiddenRunning = rest.filter(isRunning).length
374 const mainModel = modelLabel(s.main.model)
375 const advisorModel = modelLabel(s.advisor.model)
376 const agentEffort =
377 mostCommon(s.agents.filter(n => n.status === 'running').map(n => n.effort)) ?? mostCommon(s.agents.map(n => n.effort))
378 const lastCall = s.advisor.calls.at(-1)
379 const consulted = lastCall !== undefined && lastCall.at >= roundStart && roundStart > 0
380 const legendEffort = width >= LEGEND_EFFORT_FROM
381 const groupEnd = (g: AgentTreeGroup) => {
382 if (g.kind === 'agent') return g.node.endedAt ?? now
383 const members = s.agents.filter(n => n.workflowRunId === g.runId)
384 return members.some(n => n.status === 'running') ? now : Math.max(...members.map(n => n.endedAt ?? n.startedAt))
385 }
386 const spanFrom = current.length ? Math.min(...current.map(startOf)) : now
387 const spanTo = current.length ? Math.max(...current.map(groupEnd)) : now
388
389 const bar = (g: AgentTreeGroup) => timelineBar(startOf(g), groupEnd(g), spanFrom, spanTo)
390
391 const typeButton = (n: AgentTreeNode, running: boolean) =>
392 actions ? (
393 <Box key={`type-row:${n.id}`} width={AGENT_WIDTH - 2} flexDirection="row" justifyContent="center">
394 <Button key={`detail:${n.id}`} plain label={clip(typeLabel(n.type), AGENT_WIDTH - 2)} onPress={() => actions.detail(n.id)} />
395 </Box>
396 ) : (
397 <Text bold size={SIZE.title} color={running ? COLOR.agent : undefined} wrap="truncate-end">
398 {typeLabel(n.type)}
399 </Text>
400 )
401
402 const agentBox = (g: AgentTreeGroup, marginLeft: number) => {
403 if (g.kind === 'workflow') {
404 const color = g.running > 0 ? COLOR.agent : g.failed > 0 ? COLOR.failed : COLOR.dim
405 return (
406 <Box key={`wf:${g.runId}`} width={AGENT_WIDTH} marginLeft={marginLeft} borderStyle="single" borderColor={color} flexDirection="column" alignItems="center">
407 <Text bold size={SIZE.title} color={g.running > 0 ? COLOR.agent : undefined} wrap="truncate-end">
408 {`Workflow · ${g.size}`}
409 </Text>
410 <Text size={SIZE.detail} color={COLOR.agent} wrap="truncate-end">
411 {`${spin} ${w.running(g.running)}`}
412 </Text>
413 <Text size={SIZE.detail} color={COLOR.done} wrap="truncate-end">{`${STATUS_MARK.done} ${g.done} ${w.done}`}</Text>
414 <Text size={SIZE.detail} color={g.failed ? COLOR.failed : COLOR.dim} wrap="truncate-end">
415 {`${STATUS_MARK.failed} ${g.failed} ${w.status.failed}`}
416 </Text>
417 <Text size={SIZE.detail} wrap="truncate-end">{g.lastDescription || ' '}</Text>
418 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
419 {g.stopped ? `${STATUS_MARK.stopped} ${g.stopped} ${w.status.stopped}` : ' '}
420 </Text>
421 <Text color={color}>{g.running > 0 && look.animate ? shimmer(bar(g), look.frame) : bar(g)}</Text>
422 </Box>
423 )
424 }
425 const n = g.node
426 const running = n.status === 'running'
427 const nested = parentOf(s, n)
428 const statusLine =
429 n.status === 'running'
430 ? `${spin} ${w.status.running}${n.contextTokens ? ` · ${fmtTokens(n.contextTokens, w.decimal)}` : ''}`
431 : n.status === 'done' && n.endedAt !== undefined
432 ? `${STATUS_MARK.done} ${w.status.done} · ${fmtShortDuration(n.endedAt - n.startedAt)}`
433 : `${STATUS_MARK[n.status]} ${w.status[n.status]}`
434 return (
435 <Box key={`ag:${n.id}`} width={AGENT_WIDTH} marginLeft={marginLeft} borderStyle="single" borderColor={running ? COLOR.agent : n.status === 'failed' ? COLOR.failed : COLOR.dim} flexDirection="column" alignItems="center">
436 {typeButton(n, running)}
437 {running && actions ? (
438 <Box key={`pick-row:${n.id}`} width={AGENT_WIDTH - 2} flexDirection="row" justifyContent="center">
439 <Button
440 key={`pick:${n.id}`}
441 plain
442 label={clip(`${choices.agents[n.id] ? `→ ${familyLabel(choices.agents[n.id])}` : modelLabel(n.model) || w.model} ▾`, AGENT_WIDTH - 2)}
443 onPress={() => actions.pick(n.id)}
444 />
445 </Box>
446 ) : (
447 <Text color={running ? undefined : COLOR.dim} wrap="truncate-end">
448 {modelLabel(n.model) || ' '}
449 </Text>
450 )}
451 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
452 {nested ? `↳ ${w.under} ${nested}` : effortLine(n.effort)}
453 </Text>
454 <Text size={SIZE.detail} color={running ? undefined : COLOR.dim} wrap="truncate-end">
455 {n.description || ' '}
456 </Text>
457 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
458 {toolText(n.lastTool, lang) ?? `${w.tools}: ${n.toolCount}`}
459 </Text>
460 <Text size={SIZE.detail} color={STATUS_COLOR[n.status]} wrap="truncate-end">
461 {statusLine}
462 </Text>
463 <Text color={STATUS_COLOR[n.status]}>{running && look.animate ? shimmer(bar(g), look.frame) : bar(g)}</Text>
464 </Box>
465 )
466 }
467
468 const agentRow = (g: AgentTreeGroup) => {
469 if (g.kind === 'workflow') {
470 const color = g.running > 0 ? COLOR.agent : g.failed > 0 ? COLOR.failed : COLOR.dim
471 const state =
472 g.running > 0
473 ? `${spin} ${w.running(g.running)}`
474 : g.failed > 0
475 ? `${STATUS_MARK.failed} ${g.failed} ${w.status.failed}`
476 : g.done > 0
477 ? `${STATUS_MARK.done} ${w.done}`
478 : `${STATUS_MARK.stopped} ${w.status.stopped}`
479 return (
480 <Box key={`wf:${g.runId}`} width={treeWidth}>
481 <Text color={color} wrap="truncate-end">
482 {`${state} · Workflow · ${g.size}`}
483 </Text>
484 </Box>
485 )
486 }
487 const n = g.node
488 const indent = parentOf(s, n) ? '↳ ' : ''
489 const size = n.status === 'running' && n.contextTokens ? ` · ${fmtTokens(n.contextTokens, w.decimal)}` : ''
490 const text = `${indent}${n.status === 'running' ? spin : STATUS_MARK[n.status]} ${w.status[n.status]} · ${typeLabel(n.type)} · ${n.description}${size}`
491 return (
492 <Box key={`ag:${n.id}`} width={treeWidth}>
493 {actions ? (
494 <Button key={`detail:${n.id}`} plain label={clip(text, treeWidth)} onPress={() => actions.detail(n.id)} />
495 ) : (
496 <Text color={STATUS_COLOR[n.status]} wrap="truncate-end">
497 {text}
498 </Text>
499 )}
500 </Box>
501 )
502 }
503
504 const arrowRow = (key: string, boxWidth: number, box: unknown) => {
505 const side = Math.max(0, (treeWidth - boxWidth) / 2)
506 const arrow = wide && side >= 3 ? `${'┄'.repeat(side - 2)}▶ ` : ' '
507 return (
508 <Box key={key} flexDirection="row" width={treeWidth}>
509 <Box width={side} flexShrink={0} flexDirection="column" justifyContent="center" alignItems="flex-end">
510 <Text color={consulted ? COLOR.advisor : COLOR.dim}>{side > 0 ? arrow : ''}</Text>
511 </Box>
512 {box}
513 <Box width={side} flexShrink={0} />
514 </Box>
515 )
516 }
517
518 const verb = phase === 'idle' ? w.idle : w.working
519 const header = tight ? (
520 <Box key="head" flexDirection="column" alignItems="center" width={width}>
521 <Text bold size={SIZE.title} wrap="truncate-end">
522 {`AGENT TREE${mainModel ? ` · ${mainModel.toUpperCase()}` : ''} · ${verb}`}
523 </Text>
524 </Box>
525 ) : (
526 <Box key="head" flexDirection="column" alignItems="center" width={width}>
527 <Text bold size={SIZE.title} wrap="truncate-end">
528 {'AGENT TREE'}
529 {mainModel ? <Text color={COLOR.main}>{` · ${mainModel.toUpperCase()} ${verb}`}</Text> : null}
530 {advisorModel && wide ? <Text color={COLOR.advisor}>{` · ${w.advisorOnCall(advisorModel.toUpperCase())}`}</Text> : null}
531 </Text>
532 <Text color={COLOR.dim}>{'─'.repeat(Math.max(1, width - 2))}</Text>
533 <Text size={SIZE.note} wrap="truncate-end">
534 <Text color={COLOR.main}>{'■'}</Text>
535 {` ${w.main}${legendEffort && s.main.effort ? ` · ${s.main.effort}` : ''} `}
536 <Text color={COLOR.agent}>{'■'}</Text>
537 {` ${w.agents}${legendEffort && agentEffort ? ` · ${agentEffort}` : ''} `}
538 <Text color={COLOR.advisor}>{'■'}</Text>
539 {` ${w.advisor}`}
540 </Text>
541 </Box>
542 )
543
544 const roundLine = s.main.turn ? `${w.round} ${s.main.turn}${s.main.costUsd !== undefined ? ` · ${w.cost(s.main.costUsd)}` : ''}` : w.noRound
545
546 const mainBox = (
547 <Box key="main" width={mainWidth} flexShrink={0} borderStyle="single" borderColor={COLOR.main} flexDirection="column" alignItems="center">
548 <Text bold size={SIZE.title} color={COLOR.main} wrap="truncate-end">
549 {[mainModel, w.main].filter(Boolean).join(' · ')}
550 </Text>
551 <Text wrap="truncate-end">{w.phase[phase]}</Text>
552 {s.main.effort && effortBar(s.main.effort) ? (
553 <Text size={SIZE.detail} wrap="truncate-end">
554 <Text color={COLOR.dim}>{'Effort '}</Text>
555 <Text color={COLOR.main}>{effortBar(s.main.effort)}</Text>
556 {` ${s.main.effort}`}
557 </Text>
558 ) : (
559 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
560 {s.main.effort ? `Effort ${s.main.effort}` : w.effortNone}
561 </Text>
562 )}
563 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
564 {contextLine(s, w)}
565 </Text>
566 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
567 {roundLine}
568 </Text>
569 </Box>
570 )
571
572 const backBox = (
573 <Box key="back" width={backWidth} flexShrink={0} borderStyle={phase === 'reviewing' ? 'bold' : 'single'} borderColor={COLOR.main} flexDirection="column" alignItems="center">
574 <Text bold size={SIZE.title} color={COLOR.main} wrap="truncate-end">
575 {`${w.back}${s.main.effort ? ` · ${s.main.effort}` : ''}`}
576 </Text>
577 <Text wrap="truncate-end">{phase === 'reviewing' ? w.phase.reviewing : phase === 'delegating' ? w.waiting : w.ready}</Text>
578 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
579 {`${w.answers(s.main.returned)} · ↑ ${fmtTokens(s.main.returnedTokens, w.decimal) || '0'}`}
580 </Text>
581 </Box>
582 )
583
584 const notes = [
585 rest.length > 0 ? (
586 <Text key="hidden" size={SIZE.note} color={COLOR.dim} wrap="truncate-end">
587 {w.more(rest.length, hiddenRunning)}
588 </Text>
589 ) : null,
590 earlier > 0 ? (
591 <Text key="earlier" size={SIZE.note} color={COLOR.dim} wrap="truncate-end">
592 {w.earlier(earlier)}
593 </Text>
594 ) : null,
595 ]
596
597 const pickNew = actions ? (
598 <Button key="pick:new" plain hotkey="m" label={`${w.model}: ${familyOr(choices.newAgents, w.standard)} ▾`} onPress={() => actions.pick(NEW_AGENTS)} />
599 ) : null
600
601 const delegationLabel = (
602 <Box key="label" flexDirection="row">
603 <Text color={COLOR.agent} wrap="truncate-end">
604 {`● ${w.toAgents}${agentEffort ? ` · Effort ${agentEffort}` : ''}`}
605 </Text>
606 {pickNew ? <Text color={COLOR.dim}>{' · '}</Text> : null}
607 {pickNew}
608 </Box>
609 )
610
611 const flowing = look.animate && phase === 'delegating'
612 const returning = look.animate && (phase === 'reviewing' || (phase === 'delegating' && s.main.returned > 0))
613 const stemTop = flowing && look.frame % 2 === 0 ? '●' : '│'
614 const stemMid = flowing && look.frame % 2 === 1 ? '●' : '│'
615 const backMark = returning && look.frame % 2 === 0 ? '●' : '▼'
616
617 const flowText = (key: string, line: string, color: string) => (
618 <Text key={key} color={COLOR.dim}>
619 {line.split(/([•·]+)/).map((part, i) => (/^[•·]+$/.test(part) ? <Text key={`${key}:${i}`} color={color}>{part}</Text> : part))}
620 </Text>
621 )
622
623 const boxRows = chunk(shown, perRow)
624 const frame = rowWidth(Math.max(1, boxRows[0]?.length ?? 1))
625 const join = (frame - 1) / 2
626 const centersOf = (row: readonly AgentTreeGroup[]) => rowCenters(row.length, (frame - rowWidth(row.length)) / 2)
627
628 const delegation =
629 shown.length === 0 ? (
630 <Box key="agents" flexDirection="column" alignItems="center">
631 <Box key="none" flexDirection="row">
632 <Text size={SIZE.note} color={COLOR.dim}>{w.noAgents}</Text>
633 {pickNew ? <Text color={COLOR.dim}>{' · '}</Text> : null}
634 {pickNew}
635 </Box>
636 {notes}
637 </Box>
638 ) : asRows ? (
639 <Box key="agents" flexDirection="column" alignItems="center">
640 {delegationLabel}
641 <Box key="rows" flexDirection="column" width={treeWidth}>
642 {shown.map(agentRow)}
643 </Box>
644 <Text color={COLOR.main}>{backMark}</Text>
645 {arrowRow('back-row', backWidth, backBox)}
646 {notes}
647 </Box>
648 ) : (
649 <Box key="agents" flexDirection="column" alignItems="center">
650 {delegationLabel}
651 <Text color={stemMid === '●' ? COLOR.agent : COLOR.dim}>{stemMid}</Text>
652 {flowText('split', connector(frame, centersOf(boxRows[0] ?? []), join, 'split', flowing ? look.frame * 2 : undefined), COLOR.agent)}
653 <Text color={COLOR.agent}>{marks(frame, centersOf(boxRows[0] ?? []), '▼')}</Text>
654 <Box key="boxes" flexDirection="column" alignItems="center" rowGap={1} width={frame}>
655 {boxRows.map((row, r) => (
656 <Box key={`row:${r}`} flexDirection="row" width={rowWidth(row.length)}>
657 {row.map((g, i) => agentBox(g, boxGap(row.length, i)))}
658 </Box>
659 ))}
660 </Box>
661 {flowText('merge', connector(frame, centersOf(boxRows.at(-1) ?? []), join, 'merge', returning ? look.frame * 2 : undefined), COLOR.main)}
662 <Text color={COLOR.main}>{backMark}</Text>
663 {arrowRow('back-row', backWidth, backBox)}
664 {notes}
665 </Box>
666 )
667
668 const tree = (
669 <Box key="tree" flexDirection="column" alignItems="center" width={treeWidth}>
670 {arrowRow('main-row', mainWidth, mainBox)}
671 <Text color={stemTop === '●' ? COLOR.agent : COLOR.dim}>{stemTop}</Text>
672 {delegation}
673 </Box>
674 )
675
676 const advisorState = advisorModel ? advisorModel : w.off
677
678 const railLine = (key: string, text: string, plain = false) => (
679 <Text key={key} size={SIZE.detail} wrap="truncate-end">
680 <Text color={COLOR.advisor} dimColor>{'┆ '}</Text>
681 <Text color={plain ? undefined : COLOR.dim}>{text || ' '}</Text>
682 </Text>
683 )
684
685 const advisorColumn = (
686 <Box key="advisor" width={ADVISOR_WIDTH} flexShrink={0} borderStyle="single" borderColor={COLOR.advisor} flexDirection="column">
687 <Text bold size={SIZE.title} color={COLOR.advisor} wrap="truncate-end">
688 {w.advisorTitle}
689 </Text>
690 <Text size={SIZE.detail} color={COLOR.advisor} wrap="truncate-end">
691 {advisorState}
692 </Text>
693 <Text size={SIZE.detail} color={COLOR.advisor} wrap="truncate-end">
694 {advisorModel ? w.onCall : ' '}
695 </Text>
696 {railLine('reads-0', w.reads[0])}
697 {railLine('reads-1', w.reads[1])}
698 {railLine('gap-1', '')}
699 {railLine('calls', `${w.calls} ${s.advisor.total}`, true)}
700 {railLine('places', `${w.fromMain} ${s.advisor.fromMain} · ${w.fromSub} ${s.advisor.fromAgents}`)}
701 {railLine('last', lastCall ? `${w.last} ${clockTime(lastCall.at)}` : w.notAsked)}
702 {railLine('took', lastCall?.durationMs !== undefined ? `${w.duration} ${fmtShortDuration(lastCall.durationMs)}` : '')}
703 {railLine('gap-2', '')}
704 {recentCalls.map(call => (
705 <Box key={`adv:${call.id}`}>
706 <Text size={SIZE.detail} color={COLOR.advisor} wrap="truncate-end">
707 {`◆ ${clockTime(call.at)} ${callPlace(call, w)}`}
708 </Text>
709 </Box>
710 ))}
711 <Box key="advisor-fill" flexGrow={1} />
712 {railLine('writes-0', w.writes[0])}
713 {railLine('writes-1', w.writes[1])}
714 </Box>
715 )
716
717 const advisorCompact = (
718 <Box key="advisor" width={width} borderStyle="single" borderColor={COLOR.advisor} flexDirection="column">
719 <Text color={COLOR.advisor} wrap="truncate-end">
720 {`${w.advisor} · ${advisorModel ? `${advisorModel} · ${w.onCall}` : w.off} · ${w.calls} ${s.advisor.total}`}
721 </Text>
722 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
723 {lastCall ? `${w.fromMain} ${s.advisor.fromMain} · ${w.fromSub} ${s.advisor.fromAgents} · ${w.last} ${clockTime(lastCall.at)}` : w.notAsked}
724 </Text>
725 </Box>
726 )
727
728 const pickerCurrent = pickerTarget === NEW_AGENTS ? choices.newAgents : pickerTarget ? choices.agents[pickerTarget] : undefined
729 const pickerRow =
730 pickerTarget && actions ? (
731 <Box key="picker" flexDirection="row" flexWrap="wrap" columnGap={1} width={width} marginTop={1}>
732 <Text color={COLOR.agent} wrap="truncate-end">
733 {w.pickerFor(pickerName)}
734 </Text>
735 <Button key={`choose:${pickerTarget}:default`} hotkey="0" label={w.standard} variant={pickerCurrent === undefined ? 'primary' : undefined} onPress={() => actions.choose(pickerTarget, undefined)} />
736 {MODEL_FAMILIES.map((family, i) => (
737 <Button
738 key={`choose:${pickerTarget}:${family}`}
739 hotkey={String(i + 1)}
740 label={familyLabel(family)}
741 variant={pickerCurrent === family ? 'primary' : undefined}
742 onPress={() => actions.choose(pickerTarget, family)}
743 />
744 ))}
745 <Button key="picker:close" hotkey="x" label="×" onPress={() => actions.close()} />
746 </Box>
747 ) : null
748
749 const missing = (value: string | undefined, empty: string) =>
750 detail?.state === 'loading' ? w.loading : value || (detail?.state === 'denied' ? w.noAccess : empty)
751 const detailRunning = detailNode?.status === 'running'
752 const detailStopping = detailNode !== undefined && (s.stopping ?? []).includes(detailNode.id)
753 const detailBox =
754 detail && detailNode && actions ? (
755 <Box key="detail" width={width} borderStyle="single" borderColor={COLOR.agent} flexDirection="column" marginTop={1}>
756 <Box key="detail-head" flexDirection="row" justifyContent="space-between">
757 <Box key="detail-title" flexShrink={1}>
758 <Text bold size={SIZE.title} color={COLOR.agent} wrap="truncate-end">
759 {`${typeLabel(detailNode.type)} · ${detailNode.description}`}
760 </Text>
761 </Box>
762 <Box key="detail-buttons" flexDirection="row" columnGap={1} flexShrink={0}>
763 {detailRunning && !detailStopping ? (
764 <Button key={`stop:${detailNode.id}`} hotkey="s" label={w.stop} onPress={() => actions.stop(detailNode.id)} />
765 ) : null}
766 {detailStopping ? <Text color={COLOR.dim}>{w.stopping}</Text> : null}
767 <Button key="detail:close" plain hotkey="x" label={w.close} onPress={() => actions.close()} />
768 </Box>
769 </Box>
770 <Text wrap="truncate-end">{`${w.task}: ${missing(detailNode.task, '?')}`}</Text>
771 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
772 {`${w.tools}: ${detailNode.tools?.length ? detailNode.tools.map(t => toolText(t, lang)).join(' · ') : '0'}`}
773 </Text>
774 <Text wrap="truncate-end">{`${detailRunning ? w.latest : w.reply}: ${missing(detailNode.answer, w.noReply)}`}</Text>
775 <Text size={SIZE.detail} color={COLOR.dim} wrap="truncate-end">
776 {`${modelLabel(detailNode.model) || w.model} · ${w.status[detailNode.status]} · ${fmtShortDuration((detailNode.endedAt ?? now) - detailNode.startedAt)}`}
777 </Text>
778 </Box>
779 ) : null
780
781 const rows = s.log.slice(-plan.logRows)
782 const logBox = (
783 <Box key="log" width={width} borderStyle="single" borderColor={COLOR.dim} flexDirection="column" marginTop={1}>
784 <Text size={SIZE.note} color={COLOR.dim}>{w.log}</Text>
785 {rows.length === 0 ? (
786 <Text size={SIZE.note} color={COLOR.dim}>{w.logEmpty}</Text>
787 ) : (
788 rows.map((row, i) => (
789 <Box key={`log:${i}:${row.at}`} flexDirection="row">
790 <Box width={10} flexShrink={0}>
791 <Text size={SIZE.detail} color={COLOR.dim}>{clockTime(row.at)}</Text>
792 </Box>
793 <Box width={9} flexShrink={0}>
794 <Text bold size={SIZE.detail} color={SOURCE_COLOR[row.source]}>
795 {w.source[row.source]}
796 </Text>
797 </Box>
798 <Box flexGrow={1} flexShrink={1}>
799 <Text size={SIZE.detail} wrap="truncate-end">{logLine(row, lang)}</Text>
800 </Box>
801 </Box>
802 ))
803 )}
804 </Box>
805 )
806
807 return (
808 <Box flexDirection="column" width={width}>
809 {header}
810 {pickerRow}
811 {wide ? (
812 <Box key="body" flexDirection="row" columnGap={2} marginTop={1}>
813 {advisorColumn}
814 {tree}
815 </Box>
816 ) : (
817 <Box key="body" flexDirection="column" marginTop={1}>
818 {advisorCompact}
819 {tree}
820 </Box>
821 )}
822 {detailBox}
823 {logBox}
824 {tight ? null : (
825 <Text key="help" size={SIZE.note} color={COLOR.dim} wrap="truncate-end">
826 {w.help}
827 </Text>
828 )}
829 </Box>
830 )
831}
832hooks/words.ts 285 lines1// © 2026 Mike Pollow, Digitalroots. MIT-Lizenz.
2import type { AgentTreeLogEntry, AgentTreeLogSource, AgentTreePhase, AgentTreeStatus } from '../types'
3import { familyLabel, fmtDuration, fmtTokens } from './model.ts'
4
5export type Lang = 'de' | 'en'
6
7export type Words = {
8 working: string
9 idle: string
10 advisorOnCall: (model: string) => string
11 main: string
12 agents: string
13 advisor: string
14 phase: Record<AgentTreePhase, string>
15 status: Record<AgentTreeStatus, string>
16 mainEnd: Record<AgentTreeStatus, string>
17 running: (n: number) => string
18 done: string
19 effortNone: string
20 context: string
21 contextNone: string
22 round: string
23 noRound: string
24 toAgents: string
25 model: string
26 standard: string
27 noAgents: string
28 back: string
29 waiting: string
30 ready: string
31 answers: (n: number) => string
32 tools: string
33 under: string
34 more: (n: number, running: number) => string
35 earlier: (n: number) => string
36 advisorTitle: string
37 onCall: string
38 off: string
39 reads: [string, string]
40 calls: string
41 fromMain: string
42 fromSub: string
43 last: string
44 duration: string
45 notAsked: string
46 writes: [string, string]
47 log: string
48 logEmpty: string
49 source: Record<AgentTreeLogSource, string>
50 pickerFor: (name: string) => string
51 newAgents: string
52 help: string
53 task: string
54 reply: string
55 loading: string
56 close: string
57 noReply: string
58 noAccess: string
59 advisorShort: string
60 contextShort: string
61 cost: (usd: number) => string
62 decimal: string
63 percent: (p: number) => string
64 latest: string
65 stop: string
66 stopping: string
67 stopAnswer: (tools: number) => string
68 toolWords: Record<string, string>
69 commandDescription: string
70 opened: (count: number) => string
71 closedText: string
72 cleared: string
73 notOpened: (reason: string) => string
74 commandFailed: string
75 lines: {
76 turn: (d: Record<string, string | number>) => string
77 mainDone: (d: Record<string, string | number>) => string
78 agentStarted: (d: Record<string, string | number>) => string
79 workflowStarted: (d: Record<string, string | number>) => string
80 agentEnded: (d: Record<string, string | number>) => string
81 workflowDone: (d: Record<string, string | number>) => string
82 resumed: (d: Record<string, string | number>) => string
83 adopted: (d: Record<string, string | number>) => string
84 advisor: (d: Record<string, string | number>) => string
85 choice: (d: Record<string, string | number>) => string
86 stopRequested: (d: Record<string, string | number>) => string
87 }
88}
89
90export const familyOr = (family: string | number | undefined, standard: string) => (family ? familyLabel(String(family)) : standard)
91
92export const WORDS: Record<Lang, Words> = {
93 de: {
94 working: 'ARBEITET',
95 idle: 'BEREIT',
96 advisorOnCall: model => `BERATER ${model} AUF ABRUF`,
97 main: 'Hauptsitzung',
98 agents: 'Unteragenten',
99 advisor: 'Berater',
100 phase: { idle: 'bereit', planning: 'plant und entscheidet', delegating: 'wartet auf Unteragenten', reviewing: 'prüft und verifiziert' },
101 status: { running: 'läuft', done: 'fertig', failed: 'fehlgeschlagen', stopped: 'gestoppt' },
102 mainEnd: { running: 'läuft', done: 'fertig', failed: 'fehlgeschlagen', stopped: 'abgebrochen' },
103 running: n => `${n} ${n === 1 ? 'läuft' : 'laufen'}`,
104 done: 'fertig',
105 effortNone: 'ohne Effort-Stufe',
106 context: 'Kontext',
107 contextNone: 'Kontext noch nicht gemessen',
108 round: 'Runde',
109 noRound: 'noch keine Runde',
110 toAgents: 'an Unteragenten',
111 model: 'Modell',
112 standard: 'Standard',
113 noAgents: 'noch keine Unteragenten',
114 back: 'zurück zur Hauptsitzung',
115 waiting: 'wartet auf Antworten',
116 ready: 'bereit',
117 answers: n => `${n} ${n === 1 ? 'Antwort' : 'Antworten'}`,
118 tools: 'Werkzeuge',
119 under: 'unter',
120 more: (n, r) => (r > 0 ? `+${n} weitere, ${r} davon laufen` : `+${n} weitere beendet`),
121 earlier: n => `aus früheren Runden: ${n} ${n === 1 ? 'Agent' : 'Agenten'}`,
122 advisorTitle: 'BERATER',
123 onCall: 'auf Abruf',
124 off: 'nicht eingeschaltet',
125 reads: ['liest die ganze', 'Sitzung mit'],
126 calls: 'Aufrufe',
127 fromMain: 'Haupt',
128 fromSub: 'Sub',
129 last: 'zuletzt',
130 duration: 'Dauer',
131 notAsked: 'noch nie gefragt',
132 writes: ['schreibt nie', 'selbst Code'],
133 log: 'Sitzungsprotokoll',
134 logEmpty: 'noch nichts geschehen',
135 source: { session: 'sitzung', main: 'haupt', agent: 'agent', advisor: 'berater' },
136 pickerFor: name => `Modell für ${name}:`,
137 newAgents: 'neue Unteragenten',
138 help: 'ctrl+x tab: Leiste · Tab, Pfeile: wandern · m: Modell · 0 bis 4: wählen · x: schließen · s: stoppen · Typ anklicken: Details',
139 task: 'Auftrag',
140 reply: 'Antwort',
141 loading: 'lädt …',
142 close: 'schließen',
143 noReply: 'noch keine Antwort',
144 noAccess: 'nicht lesbar',
145 advisorShort: 'Berater',
146 contextShort: 'Kontext',
147 cost: usd => `${usd.toFixed(2).replace('.', ',')} $`,
148 decimal: ',',
149 percent: p => `${p} %`,
150 latest: 'Zuletzt',
151 stop: 'stoppen',
152 stopping: 'hält an …',
153 stopAnswer: n => `Im Agent Tree vom Nutzer gestoppt, nach ${n} ${n === 1 ? 'Werkzeugaufruf' : 'Werkzeugaufrufen'}.`,
154 toolWords: { SubagentHandback: 'Antwort übergeben' },
155 commandDescription: 'Agentenbaum der Sitzung als Kästen (stop, clear)',
156 opened: n => `Agent Tree geöffnet · ${n} ${n === 1 ? 'Agent' : 'Agenten'} bisher · /agent-tree stop schließt`,
157 closedText: 'Agent Tree geschlossen',
158 cleared: 'Agent Tree geleert',
159 notOpened: reason => `Agent Tree: Leiste nicht geöffnet: ${reason}`,
160 commandFailed: 'Agent Tree: Befehl gescheitert',
161 lines: {
162 turn: d => `Runde ${d.turn}: ${d.excerpt}`,
163 mainDone: d => `Hauptsitzung ${WORDS.de.mainEnd[d.status as AgentTreeStatus] ?? d.status} nach ${fmtDuration(Number(d.durationMs))}`,
164 agentStarted: d => `${d.type} gestartet: ${d.description}`,
165 workflowStarted: d => `Workflow gestartet: ${d.description}`,
166 agentEnded: d =>
167 d.status === 'done'
168 ? `${d.type} fertig nach ${fmtDuration(Number(d.durationMs))} · ↑ ${fmtTokens(Number(d.tokens))}`
169 : `${d.type} ${WORDS.de.status[d.status as AgentTreeStatus] ?? d.status}`,
170 workflowDone: d => `Workflow fertig: ${d.size} Agenten`,
171 resumed: d => `${d.type} fortgesetzt: ${d.description}`,
172 adopted: d => `${d.count} ${Number(d.count) === 1 ? 'Agent' : 'Agenten'} aus der Agentenliste übernommen`,
173 advisor: d => `gefragt aus ${d.where ? d.where : 'Hauptsitzung'}`,
174 choice: d => `Modell für ${d.who ? d.who : 'neue Unteragenten'}: ${familyOr(d.family, 'Standard')}`,
175 stopRequested: d => `Stopp für ${d.type} angefordert`,
176 },
177 },
178 en: {
179 working: 'WORKING',
180 idle: 'IDLE',
181 advisorOnCall: model => `ADVISOR ${model} ON CALL`,
182 main: 'Main session',
183 agents: 'Sub-agents',
184 advisor: 'Advisor',
185 phase: { idle: 'idle', planning: 'plans and decides', delegating: 'waits for sub-agents', reviewing: 'reviews and verifies' },
186 status: { running: 'running', done: 'done', failed: 'failed', stopped: 'stopped' },
187 mainEnd: { running: 'running', done: 'done', failed: 'failed', stopped: 'aborted' },
188 running: n => `${n} running`,
189 done: 'done',
190 effortNone: 'no effort level',
191 context: 'Context',
192 contextNone: 'context not measured yet',
193 round: 'Round',
194 noRound: 'no round yet',
195 toAgents: 'to sub-agents',
196 model: 'Model',
197 standard: 'Default',
198 noAgents: 'no sub-agents yet',
199 back: 'back to main session',
200 waiting: 'waits for answers',
201 ready: 'idle',
202 answers: n => `${n} ${n === 1 ? 'answer' : 'answers'}`,
203 tools: 'Tools',
204 under: 'under',
205 more: (n, r) => (r > 0 ? `+${n} more, ${r} of them running` : `+${n} more finished`),
206 earlier: n => `from earlier rounds: ${n} ${n === 1 ? 'agent' : 'agents'}`,
207 advisorTitle: 'ADVISOR',
208 onCall: 'on call',
209 off: 'not enabled',
210 reads: ['reads the whole', 'session'],
211 calls: 'Calls',
212 fromMain: 'main',
213 fromSub: 'sub',
214 last: 'last',
215 duration: 'took',
216 notAsked: 'not asked yet',
217 writes: ['never writes', 'code itself'],
218 log: 'Session log',
219 logEmpty: 'nothing happened yet',
220 source: { session: 'session', main: 'main', agent: 'agent', advisor: 'advisor' },
221 pickerFor: name => `Model for ${name}:`,
222 newAgents: 'new sub-agents',
223 help: 'ctrl+x tab: pane · Tab, arrows: move · m: model · 0 to 4: choose · x: close · s: stop · click a type: details',
224 task: 'Task',
225 reply: 'Answer',
226 loading: 'loading …',
227 close: 'close',
228 noReply: 'no answer yet',
229 noAccess: 'not readable',
230 advisorShort: 'advisor',
231 contextShort: 'context',
232 cost: usd => `$${usd.toFixed(2)}`,
233 decimal: '.',
234 percent: p => `${p}%`,
235 latest: 'Latest',
236 stop: 'stop',
237 stopping: 'stopping …',
238 stopAnswer: n => `Stopped by the user in Agent Tree after ${n} tool ${n === 1 ? 'call' : 'calls'}.`,
239 toolWords: { SubagentHandback: 'answer handed back' },
240 commandDescription: "The session's agent tree as boxes (stop, clear)",
241 opened: n => `Agent Tree opened · ${n} ${n === 1 ? 'agent' : 'agents'} so far · /agent-tree stop closes`,
242 closedText: 'Agent Tree closed',
243 cleared: 'Agent Tree cleared',
244 notOpened: reason => `Agent Tree: pane not opened: ${reason}`,
245 commandFailed: 'Agent Tree: command failed',
246 lines: {
247 turn: d => `Round ${d.turn}: ${d.excerpt}`,
248 mainDone: d => `Main session ${WORDS.en.mainEnd[d.status as AgentTreeStatus] ?? d.status} after ${fmtDuration(Number(d.durationMs))}`,
249 agentStarted: d => `${d.type} started: ${d.description}`,
250 workflowStarted: d => `Workflow started: ${d.description}`,
251 agentEnded: d =>
252 d.status === 'done'
253 ? `${d.type} done after ${fmtDuration(Number(d.durationMs))} · ↑ ${fmtTokens(Number(d.tokens), '.')}`
254 : `${d.type} ${WORDS.en.status[d.status as AgentTreeStatus] ?? d.status}`,
255 workflowDone: d => `Workflow done: ${d.size} agents`,
256 resumed: d => `${d.type} resumed: ${d.description}`,
257 adopted: d => `${d.count} ${Number(d.count) === 1 ? 'agent' : 'agents'} taken over from the agent list`,
258 advisor: d => `consulted from ${d.where ? d.where : 'main session'}`,
259 choice: d => `Model for ${d.who ? d.who : 'new sub-agents'}: ${familyOr(d.family, 'Default')}`,
260 stopRequested: d => `Stop requested for ${d.type}`,
261 },
262 },
263}
264
265export function wordsFor(lang: string | undefined): Words {
266 return lang === 'en' ? WORDS.en : WORDS.de
267}
268
269export function logLine(entry: AgentTreeLogEntry, lang: Lang = 'de'): string {
270 const lines = wordsFor(lang).lines as Record<string, (d: Record<string, string | number>) => string>
271 const render = entry.kind ? lines[entry.kind] : undefined
272 if (render) return render(entry.data ?? {})
273 return entry.text ?? ''
274}
275
276export function resolveLang(configured: unknown, sessionLanguage: unknown): Lang {
277 if (configured === 'de' || configured === 'en') return configured
278 return typeof sessionLanguage === 'string' && /^\s*(de|german|deutsch)/i.test(sessionLanguage) ? 'de' : 'en'
279}
280
281export function toolText(label: string | undefined, lang: Lang = 'de'): string | undefined {
282 if (label === undefined) return undefined
283 return wordsFor(lang).toolWords[label] ?? label
284}
285types/index.d.ts 127 lines1// © 2026 Mike Pollow, Digitalroots. MIT-Lizenz.
2export type AgentTreeStatus = 'running' | 'done' | 'failed' | 'stopped'
3
4export type AgentTreePhase = 'idle' | 'planning' | 'delegating' | 'reviewing'
5
6export type AgentTreeNode = {
7 id: string
8 parentId?: string
9 type: string
10 description: string
11 model?: string
12 effort?: string
13 background: boolean
14 fork: boolean
15 workflowRunId?: string
16 status: AgentTreeStatus
17 startedAt: number
18 endedAt?: number
19 contextTokens?: number
20 toolCount: number
21 lastTool?: string
22 tools?: string[]
23 task?: string
24 answer?: string
25 answerTokens?: number
26}
27
28export type AgentTreeAdvisorCall = {
29 id: string
30 at: number
31 durationMs?: number
32 agentId?: string
33}
34
35export type AgentTreeAdvisor = {
36 model?: string
37 total: number
38 fromMain: number
39 fromAgents: number
40 calls: AgentTreeAdvisorCall[]
41}
42
43export type AgentTreeLogSource = 'session' | 'main' | 'agent' | 'advisor'
44
45export type AgentTreeLogEntry = {
46 at: number
47 source: AgentTreeLogSource
48 kind?: string
49 data?: Record<string, string | number>
50 text?: string
51}
52
53export type AgentTreeMain = {
54 active: boolean
55 resumed?: boolean
56 turn: number
57 turnStartedAt?: number
58 roundStartedAt?: number
59 model?: string
60 effort?: string
61 contextTokens?: number
62 window?: number
63 percent?: number
64 returned: number
65 returnedTokens: number
66 costUsd?: number
67}
68
69export type AgentTreeOrphanEnd = {
70 status: AgentTreeStatus
71 at: number
72 answerTokens: number
73 answer?: string
74 durationMs?: number
75}
76
77export type AgentTreeChoices = {
78 newAgents?: string
79 agents: Record<string, string>
80 picker?: string
81}
82
83export type AgentTreeDetail = {
84 agentId: string
85 state: 'loading' | 'ready' | 'denied'
86}
87
88export type AgentTreeState = {
89 startedAt: number
90 choices?: AgentTreeChoices
91 detail?: AgentTreeDetail
92 stopping?: string[]
93 stopSent?: string[]
94 orphans?: Record<string, AgentTreeOrphanEnd>
95 main: AgentTreeMain
96 agents: AgentTreeNode[]
97 advisor: AgentTreeAdvisor
98 log: AgentTreeLogEntry[]
99}
100
101export type AgentTreeCounts = {
102 running: number
103 done: number
104 failed: number
105 stopped: number
106}
107
108export type AgentTreeGroup =
109 | { kind: 'agent'; node: AgentTreeNode }
110 | {
111 kind: 'workflow'
112 runId: string
113 size: number
114 running: number
115 done: number
116 failed: number
117 stopped: number
118 startedAt: number
119 lastDescription: string
120 }
121
122declare module 'claude-code' {
123 interface PluginState {
124 'agent-tree': { tree: Shaped<AgentTreeState>; frame: number }
125 }
126}
127