SLOPSHOPPER

Agent Tree

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

newpaneguardcommandstatustimer
★ 1v0.3.0MITupdated 2026-10-25MikeGT4/agent-tree
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-tree
│ ┃ Agent Tree ✕ › fix the failing auth test and add an audit log call │ ┃ AGENT TREE · IDLE │ ┃ ⏺ Read(src/auth.ts) │ ┃ ┌─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │Advisor · not enabled · Calls 0 ⏺ Update(src/auth.ts) │ ┃ │not asked yet ⎿ Added 2 lines, removed 1 line │ ┃ └─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ┌─────────────────────────────┐ ⎿ 3 pass, 1 fail │ ┃ │ Main session │ │ ┃ │ idle │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ no effort level │ │ ┃ │ Context 97.4k / 200k · 49% │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ no round yet │ │ ┃ └─────────────────────────────┘ › /agent-tree │ ┃ │ ⎿ agent-tree: Agent Tree opened · 0 agents so far · /agent-tree st │ ┃ no sub-agents yet · m: Model: De │ ┃ │ ┃ ┌─────────────────────────────────────────── │ ┃ │Session log │ ┃ │08:53:20 main Main session done after │ ┃ └─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Tree
AGENT TREE · IDLE ┌──────────────────────────────────────────────────────┐ │Advisor · not enabled · Calls 0 │ │not asked yet │ └──────────────────────────────────────────────────────┘ ┌─────────────────────────────┐ │ Main session │ │ idle │ │ no effort level │ │ Context 97.4k / 200k · 49% │ │ no round yet │ └─────────────────────────────┘ │ no sub-agents yet · m: Model: Default ▾ ┌──────────────────────────────────────────────────────┐ │Session log │ │08:53:20 main Main session done after 42 s │ └──────────────────────────────────────────────────────┘
README

Agent Tree

Deutsch

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.

Agent Tree

Install

/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.

Use

/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).

License

MIT, © 2026 Mike Pollow, Digitalroots

Source 5 files
hooks/register.tsx 338 lines
1// © 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}
338
hooks/model.ts 621 lines
1// © 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}
621
hooks/view.tsx 832 lines
1// © 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}
832
hooks/words.ts 285 lines
1// © 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}
285
types/index.d.ts 127 lines
1// © 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