轨迹面板:按时间轴展示 agent 的输入/模型/工具调用 trace,支持时长/轮次/调用视图与搜索

A Claude Code mod that adds a 轨迹 (trace) pane: see what the agent did in this session, step by step.
Open it with /agent-trace.
At the prompt of a terminal session:
/plugin install agent-trace --marketplace jasoncai0/claude-agent-trace
Answer y to add the marketplace, then pick a scope (user is first).
From a shell:
claude plugin install agent-trace --marketplace jasoncai0/claude-agent-trace
In the desktop app, where /plugin does not run: click + next to the prompt box → Plugins → Add marketplace with jasoncai0/claude-agent-trace → Add plugin.
Needs a Claude Code build with mods (function hooks), 2.1.29x or newer.
captureSystemPrompt (off by default): record the system prompt (every section and the tool list) for the 系统提示 tab. An organization's managed plugin such as cc-plugin-sec-default can take the prompt.compose event past every user plugin; with one present the hook never runs and is only reported as skipped, which is why it is off by default. Turn it on with /config where no such plugin is seated.| Lane | Event |
|---|---|
| 输入 | turn.start (main loop and subagents) |
| 系统提示 | prompt.compose when captureSystemPrompt is on (sections + tools, stored once per distinct prompt, latest 5) |
| 模型 | turn.step: the request's messages ($.session.messages({ as: 'api' })) diffed against the loop's previous step, time to first token, usage |
| 工具 | tool.call, with args, output and errors |
Limits: the timeline draws the latest 120 entries; at most 160 rows are drawn (Claude Desktop drops a pane tree of a few hundred kB); the state keeps 2000 rows and the full payloads (prompt, args, results, each block clipped at 8k chars) of the latest 150. The system prompt is rendered once per session, so it is captured only when the mod is loaded before the session starts.
claude plugin validate .
claude plugin test .
MIT
hooks/register.tsx 221 lines1// Records the session's trace (inputs, model steps, tool calls, system prompt); the pane draws it.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { Payload, SystemSnapshot, TraceEvent } from '../types'
6import {
7 clipText,
8 latestSystem,
9 pretty,
10 prevMessageCount,
11 putPayload,
12 putSystem,
13 stepPayload,
14 systemId,
15} from './context'
16import { registerPane } from './pane'
17import { toolSummary } from './rows'
18import { append, formatMs, fromMessages, oneLine, patch, toJson, toolArgs } from './trace'
19
20const COMMAND = 'agent-trace'
21const PANE = 'agent-trace'
22const events = atom({ plugin: 'agent-trace', key: 'events' } as const, [] as TraceEvent[])
23const payloads = atom({ plugin: 'agent-trace', key: 'payloads' } as const, {} as Record<string, Payload>)
24const systems = atom({ plugin: 'agent-trace', key: 'systems' } as const, [] as SystemSnapshot[])
25const TITLE = '轨迹'
26const TITLE_MAX = 400
27const DETAIL_MAX = 2000
28const FULL_MAX = 20000
29
30const clip = (ev: Omit<TraceEvent, 'turn'>): Omit<TraceEvent, 'turn'> => ({
31 ...ev,
32 title: oneLine(ev.title, TITLE_MAX),
33 detail: ev.detail.slice(0, DETAIL_MAX),
34})
35
36// Recording must never break the agent loop: a failed write is logged and dropped.
37const safely = async ($: EngineInterface, label: string, fn: () => Promise<unknown>): Promise<void> => {
38 try {
39 await fn()
40 } catch (err) {
41 $.ui.log(`agent-trace: ${label} failed: ${String(err)}`, { to: 'debug' })
42 }
43}
44
45const record = ($: EngineInterface, fn: (list: TraceEvent[]) => TraceEvent[]) =>
46 safely($, 'write', () => update($, events, list => fn(list ?? [])))
47
48const savePayload = ($: EngineInterface, id: string, fn: (old: Payload | undefined) => Payload) =>
49 safely($, 'payload', () => update($, payloads, map => putPayload(map ?? {}, id, fn(map?.[id]))))
50
51const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: TITLE })
52
53export const register: Register = (on, options) => {
54 on('session.start', async ($, e, next) => {
55 // /trace is taken by oh-my-claudecode; a refused registration must not skip the backfill.
56 await $.command
57 .register({ name: 'agent-trace', description: '打开轨迹面板,查看 agent 运行 trace' })
58 .catch(err => $.ui.log(`agent-trace: /${COMMAND} not registered: ${String(err)}`))
59 const list = await read($, events)
60 if (list.length === 0) {
61 const history = fromMessages(await $.session.messages())
62 await record($, () => history)
63 }
64 return next(e)
65 })
66
67 on('command.run', { command: 'agent-trace' }, async $ => {
68 const { isPlaced } = await openPane($)
69 const list = await read($, events)
70 const now = await $.clock.now()
71 const lastAt = list.map(ev => ev.end ?? ev.start).filter((t): t is number => t !== undefined).at(-1)
72 const age = lastAt !== undefined ? `,最新一条在 ${formatMs(now - lastAt)} 前` : ''
73 const placed = isPlaced ? '' : ';但当前没有界面在显示这个面板(Desktop 侧栏可能被收起)'
74 return { text: `轨迹面板已打开:${list.length} 条记录${age}${placed}。` }
75 })
76
77 // The system prompt as sent: every section and the tool list, deduplicated by content. Opt-in: an organization's
78 // managed plugin (cc-plugin-sec-default) can take this event past every user plugin, and the hook is then only
79 // reported as skipped.
80 if (options.captureSystemPrompt === true) on('prompt.compose', async ($, e, next) => {
81 const res = await next(e)
82 const base = {
83 model: e.model,
84 tools: [...e.tools],
85 sections: res.sections.map(s => ({ id: s.id, scope: s.scope, text: s.text })),
86 }
87 const at = await $.clock.now()
88 await safely($, 'system', () =>
89 update($, systems, list => putSystem(list ?? [], { ...base, id: systemId(base), at })),
90 )
91 return res
92 }).catch(($, e, next) => next(e))
93
94 on('turn.start', async ($, e, next) => {
95 const now = await $.clock.now()
96 const id = `turn:${e.turnId}`
97 const ev = {
98 id,
99 kind: 'input' as const,
100 title: e.agentId ? `子代理 ${e.agentId}` : '用户',
101 detail: e.text,
102 start: now,
103 end: now,
104 ...(e.agentId ? { agentId: e.agentId } : {}),
105 }
106 await record($, list => append(list, clip(ev)))
107 await savePayload($, id, () => ({
108 parts: [{ role: 'user', kind: 'prompt', text: clipText(e.text, FULL_MAX) }],
109 omitted: 0,
110 }))
111 return next(e)
112 })
113
114 on('turn.step', async function* ($, e, next) {
115 const id = `step:${e.agentId ?? 'main'}:${e.turnId}:${e.index}`
116 const scope = e.agentId ? { agentId: e.agentId } : {}
117 const start = await $.clock.now()
118 const fetched = await $.session.messages({ as: 'api', ...scope }).catch(err => ({ deny: String(err) }))
119 const messages = Array.isArray(fetched) ? fetched : []
120 const prev = prevMessageCount(await read($, events), e.agentId)
121 const { payload, newCount, newChars } = stepPayload(messages, prev)
122 const ev = {
123 id,
124 kind: 'model' as const,
125 title: e.model,
126 detail: '',
127 start,
128 messageCount: messages.length,
129 newCount,
130 newChars,
131 ...scope,
132 }
133 await record($, list => append(list, ev))
134 await savePayload($, id, () => payload)
135
136 // Read the stream by hand to time the first content chunk; every chunk passes on unchanged.
137 const stream = next(e)
138 let firstAt: number | undefined
139 let thinking = ''
140 for (;;) {
141 const step = await stream.next()
142 if (step.done) {
143 const res = step.value
144 const end = await $.clock.now()
145 const usage = res.usage
146 ? {
147 input: res.usage.input_tokens,
148 output: res.usage.output_tokens,
149 cacheRead: res.usage.cache_read_input_tokens,
150 cacheWrite: res.usage.cache_creation_input_tokens,
151 }
152 : undefined
153 const calls = res.toolUses.map(use => use.name).join(', ')
154 const system = latestSystem(await read($, systems), e.model)
155 await record($, list =>
156 patch(list, id, {
157 end,
158 ...(firstAt !== undefined ? { firstAt } : {}),
159 ...(usage ? { usage } : {}),
160 ...(system ? { systemId: system.id } : {}),
161 detail: (res.answer || (calls ? `→ ${calls}` : '')).slice(0, DETAIL_MAX),
162 }),
163 )
164 const output = [res.answer, ...res.toolUses.map(use => `→ ${use.name} ${pretty(use.input)}`)]
165 .filter(Boolean)
166 .join('\n\n')
167 await savePayload($, id, old => ({
168 ...(old ?? payload),
169 output: clipText(output, FULL_MAX),
170 ...(thinking ? { thinking: clipText(thinking, FULL_MAX) } : {}),
171 }))
172 return res
173 }
174 if (step.value.kind === 'thinking' && thinking.length < FULL_MAX) thinking += step.value.text
175 if (firstAt === undefined && ['text', 'thinking', 'tool'].includes(step.value.kind)) {
176 firstAt = await $.clock.now()
177 }
178 yield step.value
179 }
180 })
181
182 on('tool.call', async ($, e, next) => {
183 const id = `tool:${e.tool_use_id ?? Math.random().toString(36).slice(2)}`
184 const args = toolArgs(e as Record<string, unknown>)
185 const start = await $.clock.now()
186 const { name, arg } = toolSummary(e.tool, args)
187 const ev = {
188 id,
189 kind: 'tool' as const,
190 title: `${e.tool} ${toJson(args)}`,
191 detail: '',
192 name,
193 summary: arg,
194 start,
195 ...(e.agentId ? { agentId: e.agentId } : {}),
196 }
197 await record($, list => append(list, clip(ev)))
198 const argsPart = { role: 'tool', kind: `参数 · ${e.tool}`, text: clipText(pretty(args)) }
199 await savePayload($, id, () => ({ parts: [argsPart], omitted: 0 }))
200 const ran = await next(e)
201 const end = await $.clock.now()
202 const detail = ran.deny !== undefined ? `deny: ${ran.deny}` : (ran.text ?? toJson(ran.result))
203 const isDenied = ran.deny !== undefined
204 const isError = !isDenied && ran.isError === true
205 await record($, list =>
206 patch(list, id, {
207 end,
208 detail: detail.slice(0, DETAIL_MAX),
209 ...(isError ? { isError } : {}),
210 ...(isDenied ? { isDenied } : {}),
211 }),
212 )
213 const resultKind = isDenied ? '结果 · 被拒绝' : isError ? '结果 · 错误' : '结果'
214 const resultPart = { role: 'tool', kind: resultKind, text: clipText(detail, FULL_MAX) }
215 await savePayload($, id, () => ({ parts: [argsPart, resultPart], omitted: 0 }))
216 return ran
217 }).catch(($, e, next) => next(e))
218
219 registerPane(on, options.captureSystemPrompt === true)
220}
221hooks/context.ts 100 lines1import type { InputPart, Payload, SystemSnapshot, TraceEvent } from '../types'
2import { clean } from './trace'
3
4const PART_MAX = 8000
5const STEP_PARTS = 40
6export const PAYLOADS_MAX = 150
7const SYSTEMS_MAX = 5
8
9type Block = { type: string; [field: string]: unknown }
10type Message = { role: string; content: readonly Block[] }
11
12export const clipText = (raw: string, max = PART_MAX): string => {
13 const text = clean(raw)
14 return text.length > max ? `${text.slice(0, max)}\n…(截断,共 ${text.length} 字符)` : text
15}
16
17const json = (value: unknown, indent?: number): string => {
18 try {
19 return JSON.stringify(value, null, indent) ?? ''
20 } catch {
21 return String(value)
22 }
23}
24
25export const pretty = (value: unknown): string => json(value, 2)
26
27const resultText = (content: unknown): string => {
28 if (typeof content === 'string') return content
29 if (!Array.isArray(content)) return json(content)
30 return content
31 .map((c: Block) => (c.type === 'text' ? String(c.text ?? '') : `[${c.type}]`))
32 .join('\n')
33}
34
35export const blockPart = (role: string, b: Block): InputPart => {
36 switch (b.type) {
37 case 'text':
38 return { role, kind: 'text', text: String(b.text ?? '') }
39 case 'tool_use':
40 return { role, kind: `tool_use · ${String(b.name)}`, text: pretty(b.input) }
41 case 'tool_result':
42 return { role, kind: b.is_error ? 'tool_result · error' : 'tool_result', text: resultText(b.content) }
43 case 'thinking':
44 return { role, kind: 'thinking', text: String(b.thinking ?? '') }
45 case 'image':
46 case 'document':
47 case 'redacted_thinking':
48 return { role, kind: b.type, text: `[${b.type}]` }
49 default:
50 return { role, kind: b.type, text: json(b) }
51 }
52}
53
54// What a request added since the loop's previous request: everything when it shrank (compaction).
55export const stepPayload = (
56 messages: readonly Message[],
57 prevCount: number,
58): { payload: Payload; newCount: number; newChars: number } => {
59 const fresh = prevCount <= messages.length ? messages.slice(prevCount) : messages
60 const parts = fresh.flatMap(m => m.content.map(b => blockPart(m.role, b)))
61 const kept = parts.slice(-STEP_PARTS).map(p => ({ ...p, text: clipText(p.text) }))
62 return {
63 payload: { parts: kept, omitted: parts.length - kept.length },
64 newCount: fresh.length,
65 newChars: parts.reduce((n, p) => n + p.text.length, 0),
66 }
67}
68
69export const prevMessageCount = (list: readonly TraceEvent[], agentId: string | undefined): number => {
70 const prev = [...list]
71 .reverse()
72 .find(ev => ev.kind === 'model' && ev.agentId === agentId && ev.messageCount !== undefined)
73 return prev?.messageCount ?? 0
74}
75
76export const putPayload = (
77 map: Readonly<Record<string, Payload>>,
78 id: string,
79 payload: Payload,
80): Record<string, Payload> => {
81 const { [id]: _old, ...rest } = map
82 return Object.fromEntries(Object.entries({ ...rest, [id]: payload }).slice(-PAYLOADS_MAX))
83}
84
85const hash = (text: string): string => {
86 let h = 5381
87 for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
88 return (h >>> 0).toString(36)
89}
90
91export const systemId = (s: Omit<SystemSnapshot, 'id' | 'at'>): string =>
92 hash(`${s.model}\n${s.tools.join(',')}\n${s.sections.map(x => `${x.id}:${x.text}`).join('\n')}`)
93
94// Newest last; an identical prompt only moves to the end.
95export const putSystem = (list: readonly SystemSnapshot[], snap: SystemSnapshot): SystemSnapshot[] =>
96 [...list.filter(s => s.id !== snap.id), snap].slice(-SYSTEMS_MAX)
97
98export const latestSystem = (list: readonly SystemSnapshot[], model: string): SystemSnapshot | undefined =>
99 [...list].reverse().find(s => s.model === model) ?? list.at(-1)
100hooks/pane.tsx 837 lines1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4import type { DetailTab, Payload, SystemSnapshot, TraceEvent, TraceMode } from '../types'
5import { latestSystem, pretty } from './context'
6import {
7 CACHE_LOW,
8 cacheHit,
9 compactNum,
10 durationTone,
11 highlight,
12 isContextJump,
13 preview,
14 resultSummary,
15 toolStats,
16 toolSummary,
17} from './rows'
18import { nodeStatus, statusOf } from './status'
19import type { Status } from './status'
20import { currentTurn, formatMs, matches, oneLine, TIMELINE_HEIGHT, timelineModel, timelineSvg } from './trace'
21import { planJump } from './jump'
22import { buildTree, countOf, filterTree, flatten, isOpenBy, spanOf } from './tree'
23import type { FlatRow, TreeNode } from './tree'
24
25const PANE = 'agent-trace'
26const events = atom({ plugin: 'agent-trace', key: 'events' } as const, [] as TraceEvent[])
27const mode = atom({ plugin: 'agent-trace', key: 'mode' } as const, 'call' as TraceMode)
28const query = atom({ plugin: 'agent-trace', key: 'query' } as const, '')
29const selected = atom({ plugin: 'agent-trace', key: 'selected' } as const, '')
30const tab = atom({ plugin: 'agent-trace', key: 'tab' } as const, 'overview' as DetailTab)
31const payloads = atom({ plugin: 'agent-trace', key: 'payloads' } as const, {} as Record<string, Payload>)
32const systems = atom({ plugin: 'agent-trace', key: 'systems' } as const, [] as SystemSnapshot[])
33const openParts = atom({ plugin: 'agent-trace', key: 'openParts' } as const, [] as string[])
34const toggled = atom({ plugin: 'agent-trace', key: 'toggled' } as const, [] as string[])
35// How many of the newest rows are drawn.
36const rowLimit = atom({ plugin: 'agent-trace', key: 'rowLimit' } as const, 40)
37const toolFilter = atom({ plugin: 'agent-trace', key: 'toolFilter' } as const, '')
38const showStats = atom({ plugin: 'agent-trace', key: 'showStats' } as const, false)
39
40// The pane scrolls natively (Desktop has no sticky header; pinning one by snapping the body back made it shake).
41// Desktop silently drops a pane tree past a few hundred kB (178 kB drew, 340 kB did not), so only the newest rows
42// are drawn, a page at a time, and never more than fit a size budget beside the timeline and the open detail.
43const ROW_PAGE = 40
44const ROW_MAX = 160
45const TREE_BUDGET = 150_000
46// Serialized characters per row (measured about 2.1 kB) and for the header around the rows.
47const ROW_COST = 2300
48const HEADER_COST = 10_000
49// What one block of a closed detail part costs at most: its first lines.
50const PART_PREVIEW_COST = 3000
51const STATS_MAX = 8
52const OPEN_TURNS = 2
53const TOGGLED_MAX = 200
54const BAR_CELLS = 12
55// Cells each tree level indents: the fold column, so a child's fold sits under its parent's dot.
56const INDENT = 2
57// Deeper levels stop indenting, so nested subagent runs keep room for their text.
58const INDENT_LEVELS_MAX = 4
59// Below this many body columns the latency bars are left out to give the text room.
60const BAR_MIN_COLUMNS = 70
61const PREVIEW_LINES = 12
62const OPEN_PARTS_MAX = 50
63const MAIN_MAX = 56
64const REST_MAX = 110
65const LABEL_COLUMNS = 10
66// CSS pixels per body column on the desktop surface, to draw the timeline near the pane's width
67// (the Svg scales down to the slot, never up past its markup width).
68const PX_PER_COLUMN = 8.6
69const TIMELINE_MIN_WIDTH = 320
70const TIMELINE_MIN_COLUMNS = 30
71const MATCH_BG = '#f5d76e'
72const MATCH_FG = '#1a1a1a'
73const SELECTED_BG = 'rgba(128, 128, 128, 0.14)'
74const HOVER_BG = 'rgba(128, 128, 128, 0.07)'
75
76type Chip = { label: string; fg: string; bg: string; solid: string }
77const CHIPS: Record<'user' | 'sub' | 'model' | 'tool' | 'error', Chip> = {
78 user: { label: '用户', fg: '#3d6fd6', bg: 'rgba(91, 141, 239, 0.14)', solid: '#5b8def' },
79 sub: { label: '子代理', fg: '#1f8a7e', bg: 'rgba(42, 157, 143, 0.14)', solid: '#2a9d8f' },
80 model: { label: '助手', fg: '#7b55b5', bg: 'rgba(142, 108, 192, 0.16)', solid: '#8e6cc0' },
81 tool: { label: '工具', fg: '#c2620c', bg: 'rgba(224, 134, 42, 0.16)', solid: '#e0862a' },
82 error: { label: '错误', fg: '#c9302c', bg: 'rgba(217, 83, 79, 0.14)', solid: '#d9534f' },
83}
84
85const MODES: readonly { value: TraceMode; label: string }[] = [
86 { value: 'time', label: '⏱ 时长' },
87 { value: 'turn', label: '轮次' },
88 { value: 'call', label: '调用' },
89]
90
91const STATUS_MARKS: Record<Status, { mark: string; color?: string; label: string }> = {
92 ok: { mark: ' ', label: '成功' },
93 running: { mark: '◌', color: 'warning', label: '运行中' },
94 interrupted: { mark: '⊘', color: 'warning', label: '已中断' },
95 denied: { mark: '⊖', color: 'error', label: '被拒绝' },
96 error: { mark: '✕', color: 'error', label: '失败' },
97}
98
99const percent = (r: number): string => `${Math.round(r * 100)}%`
100
101const toolNameOf = (ev: TraceEvent): string => ev.name ?? ev.title.split(' ')[0] ?? ''
102
103const chipOf = (ev: TraceEvent): Chip => {
104 if (ev.isError) return CHIPS.error
105 if (ev.kind === 'tool') return CHIPS.tool
106 if (ev.kind === 'model') return CHIPS.model
107 return ev.agentId ? CHIPS.sub : CHIPS.user
108}
109
110// Older rows carry only the title (`Tool {json}`); recover a summary from it.
111const toolLine = (ev: TraceEvent): { name: string; arg: string } => {
112 if (ev.name !== undefined) return { name: ev.name, arg: ev.summary ?? '' }
113 const [tool = '', ...rest] = ev.title.split(' ')
114 try {
115 return toolSummary(tool, JSON.parse(rest.join(' ')) as Record<string, unknown>)
116 } catch {
117 return { name: tool, arg: oneLine(rest.join(' '), 90) }
118 }
119}
120
121const isJsonKind = (kind: string): boolean => kind.startsWith('参数') || kind.startsWith('tool_use')
122
123const pad = (n: number, width = 2): string => String(n).padStart(width, '0')
124
125const clockTime = (ms: number): string => {
126 const d = new Date(ms)
127 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`
128}
129
130const timeOfDay = (ms: number): string => {
131 const d = new Date(ms)
132 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
133}
134
135// `step:<loop>:<turnId>:<index>` → the step's place in its turn, from 1.
136const stepNumber = (ev: TraceEvent): number => Number(ev.id.split(':').at(-1) ?? 0) + 1
137
138export const registerPane = (on: On, isSystemCaptured: boolean): void => {
139 // ponytail: the tree of the latest draw, for a timeline click; a reload resets it until the next draw.
140 let lastWhole: TreeNode[] = []
141
142 // A click on a timeline bar opens the folds above that row, draws it, selects it and scrolls to it.
143 on('ui.message', { component: 'Pane', requestId: PANE, element: 'timeline' }, async ($, e) => {
144 const id = (e.data as { jump?: unknown } | null)?.jump
145 if (typeof id !== 'string') return {}
146 const plan = planJump(lastWhole, await read($, toggled), OPEN_TURNS, id)
147 if (!plan) return {}
148 await update($, query, () => '')
149 await update($, toolFilter, () => '')
150 await update($, toggled, () => plan.toggled)
151 await update($, rowLimit, v => Math.min(ROW_MAX, Math.max(v ?? ROW_PAGE, plan.total - plan.index)))
152 await update($, tab, () => 'overview' as const)
153 await update($, selected, () => id)
154 await $.ui.scroll({ in: PANE, to: { key: `rowbox-${id}` }, block: 'start' }).catch(() => undefined)
155 return {}
156 })
157
158 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
159 const els = $.ui.resolve(e)
160 const { Box, Text, Button, Code, Markdown } = els
161 const Input = 'Input' in els ? els.Input : undefined
162 const Svg = 'Svg' in els ? els.Svg : undefined
163 const Client = 'Client' in els ? els.Client : undefined
164 const isRich = e.surface !== 'terminal'
165 const [list, current, q, pick, which, opened, store, snaps, flipped, limitRows, onlyTool, isStatsOpen] =
166 await Promise.all([
167 read($, events),
168 read($, mode),
169 read($, query),
170 read($, selected),
171 read($, tab),
172 read($, openParts),
173 read($, payloads),
174 read($, systems),
175 read($, toggled),
176 read($, rowLimit),
177 read($, toolFilter),
178 read($, showStats),
179 ])
180 const now = await $.clock.now()
181 const lastTurn = currentTurn(list)
182 const stats = toolStats(list)
183 const isShown = (ev: TraceEvent) =>
184 (!q || matches(ev, q)) && (!onlyTool || (ev.kind === 'tool' && toolNameOf(ev) === onlyTool))
185 const isFiltering = q !== '' || onlyTool !== ''
186 const matchCount = isFiltering ? list.filter(isShown).length : list.length
187 const whole = buildTree(list)
188 const top = isFiltering ? filterTree(whole, isShown) : whole
189 const isOpen = isFiltering ? () => true : isOpenBy(whole, flipped, OPEN_TURNS)
190 // Each model step's previous step in its own loop, for spotting a context jump.
191 const prevUsage = new Map<string, TraceEvent['usage']>()
192 list
193 .filter(ev => ev.kind === 'model')
194 .reduce<Map<string, TraceEvent['usage']>>((last, ev) => {
195 const loop = ev.agentId ?? ''
196 prevUsage.set(ev.id, last.get(loop))
197 return ev.usage ? new Map(last).set(loop, ev.usage) : last
198 }, new Map())
199 const allRows = flatten(top, isOpen, n => flipped.includes(`all:${n.id}`))
200 // An estimate of what the open detail adds to the tree: closed parts show their first lines, open ones all.
201 const detailCost = (): number => {
202 const ev = list.find(x => x.id === pick)
203 if (!ev) return 0
204 const cost = (key: string, text: string) => (opened.includes(key) ? text.length : Math.min(text.length, PART_PREVIEW_COST))
205 const payload = store[ev.id]
206 const parts = (payload?.parts ?? []).reduce((n, p, i) => n + cost(`${ev.id}:${i}`, p.text), 0)
207 const snap = which === 'system' ? (snaps.find(x => x.id === ev.systemId) ?? snaps.at(-1)) : undefined
208 const system = (snap?.sections ?? []).reduce((n, x) => n + cost(`${ev.id}:sec:${x.id}`, x.text), 0)
209 const output = cost(`${ev.id}:out`, payload?.output ?? '') + (payload?.thinking?.length ?? 0)
210 return 8000 + parts + system + output
211 }
212 const model = timelineModel(list, current, now, pick, Math.max(TIMELINE_MIN_COLUMNS, e.props.bodyColumns - 2))
213 const rowRoom = Math.max(
214 ROW_PAGE / 2,
215 Math.floor((TREE_BUDGET - HEADER_COST - JSON.stringify(model).length - detailCost()) / ROW_COST),
216 )
217 const flat = allRows.slice(-Math.min(ROW_MAX, limitRows, rowRoom))
218 const startRow = allRows.length - flat.length
219 lastWhole = whole
220
221 const scrollToRow = (id: string, block: 'start' | 'nearest') =>
222 $.ui.scroll({ in: PANE, to: { key: `rowbox-${id}` }, block }).catch(() => undefined)
223
224 // Opening a row's detail brings the row to the top, so the detail opens in view.
225 const toggleRow = async (id: string) => {
226 await update($, selected, old => (old === id ? '' : id))
227 if ((await read($, selected)) === id) await scrollToRow(id, 'start')
228 }
229
230 const toggleFold = async (id: string) => {
231 await update($, toggled, old => (old.includes(id) ? old.filter(k => k !== id) : [...old, id].slice(-TOGGLED_MAX)))
232 await scrollToRow(id.startsWith('all:') ? id.slice('all:'.length) : id, 'nearest')
233 }
234
235 const togglePart = (key: string) =>
236 update($, openParts, old =>
237 old.includes(key) ? old.filter(k => k !== key) : [...old, key].slice(-OPEN_PARTS_MAX),
238 )
239
240 const marked = (text: string) =>
241 highlight(text, q).map(run =>
242 run.isMatch ? (
243 <Text backgroundColor={MATCH_BG} color={MATCH_FG}>
244 {run.text}
245 </Text>
246 ) : (
247 run.text
248 ),
249 )
250
251 const chipView = (chip: Chip) =>
252 isRich ? (
253 <Text size="footnote" weight="medium" color={chip.fg} backgroundColor={chip.bg}>
254 {` ${chip.label} `}
255 </Text>
256 ) : (
257 <Text backgroundColor={chip.solid} color="#ffffff">{` ${chip.label} `}</Text>
258 )
259
260 const kv = (label: string, value: string) => (
261 <Box key={`kv-${label}`} flexDirection="row" gap={1}>
262 <Box width={LABEL_COLUMNS} flexShrink={0}>
263 <Text dimColor>{label}</Text>
264 </Box>
265 <Box flexGrow={1} flexShrink={1}>
266 <Text wrap="wrap">{value}</Text>
267 </Box>
268 </Box>
269 )
270
271 const heading = (text: string) => <Text weight="semibold">{text}</Text>
272
273 // A long block shows its first lines until opened.
274 const partView = (key: string, label: string, text: string, language?: string) => {
275 const isOpen = opened.includes(key)
276 const { head, total, isCut } = preview(text, PREVIEW_LINES)
277 const source = (isOpen || !isCut ? text : `${head}\n…`) || '(空)'
278 return (
279 <Box key={`part-${key}`} flexDirection="column">
280 <Box flexDirection="row" gap={1} alignItems="center">
281 <Text dimColor size="footnote">
282 {label} · {compactNum(text.length)} 字符 · {total} 行
283 </Text>
284 {isCut && (
285 <Button key={`more-${key}`} plain label={isOpen ? '收起' : '展开全部'} onPress={() => togglePart(key)} />
286 )}
287 </Box>
288 {language ? <Code source={source} language={language} wrap="wrap" /> : <Code source={source} wrap="wrap" />}
289 </Box>
290 )
291 }
292
293 const tabsView = (items: readonly { value: DetailTab; label: string }[], active: DetailTab) => (
294 <Box flexDirection="row" gap={1} flexWrap="wrap">
295 {items.map(t => (
296 <Button
297 key={`tab-${t.value}`}
298 label={t.label}
299 variant={t.value === active ? 'primary' : 'secondary'}
300 onPress={() => update($, tab, () => t.value)}
301 />
302 ))}
303 </Box>
304 )
305
306 const timingView = (ev: TraceEvent) => {
307 if (ev.start === undefined) return null
308 const total = (ev.end ?? now) - ev.start
309 const first = ev.firstAt !== undefined ? ev.firstAt - ev.start : undefined
310 const gen = ev.end !== undefined && ev.firstAt !== undefined ? ev.end - ev.firstAt : undefined
311 const rate = gen && ev.usage ? `${(ev.usage.output / (gen / 1000)).toFixed(1)} tok/s` : undefined
312 return (
313 <Box flexDirection="column">
314 {heading(ev.kind === 'model' ? '请求计时' : '计时')}
315 {kv('开始时间', clockTime(ev.start))}
316 {kv('总时长', ev.end === undefined ? `${formatMs(total)}(进行中)` : formatMs(total))}
317 {first !== undefined && kv('首 token', formatMs(first))}
318 {gen !== undefined && kv('生成', formatMs(gen))}
319 {rate !== undefined && kv('吞吐量', rate)}
320 </Box>
321 )
322 }
323
324 const partsView = (ev: TraceEvent, payload: Payload | undefined) =>
325 payload && payload.parts.length > 0 ? (
326 <Box flexDirection="column" gap={1}>
327 {payload.omitted > 0 && <Text dimColor>前面还有 {payload.omitted} 块未保留</Text>}
328 {payload.parts.map((p, i) =>
329 partView(`${ev.id}:${i}`, `${p.role} · ${p.kind}`, p.text, isJsonKind(p.kind) ? 'json' : undefined),
330 )}
331 </Box>
332 ) : (
333 <Text dimColor>无完整内容(mod 加载前的历史,或已超出保留的最近 150 条)。</Text>
334 )
335
336 const rawView = (ev: TraceEvent, payload: Payload | undefined) =>
337 partView(`${ev.id}:raw`, '原始记录', pretty({ event: ev, payload }), 'json')
338
339 const modelDetail = (ev: TraceEvent, payload: Payload | undefined) => {
340 const active: DetailTab = which === 'raw' || which === 'input' || which === 'system' ? which : 'overview'
341 const snap = snaps.find(s => s.id === ev.systemId) ?? latestSystem(snaps, ev.title.split(' ')[0] ?? '')
342 const output = payload?.output ?? ev.detail
343 const outPreview = preview(output, PREVIEW_LINES)
344 const isOutOpen = opened.includes(`${ev.id}:out`)
345 const isThinkOpen = opened.includes(`${ev.id}:think`)
346 const usage = ev.usage
347 const tabs = [
348 { value: 'overview' as const, label: '概述' },
349 { value: 'input' as const, label: `输入 ${ev.newCount ?? 0}` },
350 { value: 'system' as const, label: '系统提示' },
351 { value: 'raw' as const, label: '原始' },
352 ]
353 const overview = (
354 <Box flexDirection="column" gap={1}>
355 <Box flexDirection="column">
356 {kv('来源', `请求 #${stepNumber(ev)} · ${ev.messageCount ?? '?'} 条消息(本步新增 ${ev.newCount ?? 0})`)}
357 {kv('状态', ev.end === undefined ? '生成中…' : '已完成')}
358 {kv('模型', ev.title.split(' ')[0] ?? '')}
359 {usage &&
360 kv(
361 'Token',
362 `${compactNum(usage.input + usage.cacheRead + usage.cacheWrite)} 输入(缓存读 ${compactNum(usage.cacheRead)} / 写 ${compactNum(usage.cacheWrite)})→ ${compactNum(usage.output)} 输出`,
363 )}
364 {usage && cacheHit(usage) !== undefined && kv('缓存命中', percent(cacheHit(usage) ?? 0))}
365 {usage && isContextJump(prevUsage.get(ev.id), usage) && kv('提示', '本步未命中缓存的输入比上一步多 3 倍以上')}
366 </Box>
367 <Box flexDirection="column" gap={1}>
368 {heading('预览')}
369 {payload?.thinking && (
370 <Box flexDirection="column">
371 <Button
372 key={`think-${ev.id}`}
373 plain
374 label={`${isThinkOpen ? '▾' : '▸'} 思考 · ${compactNum(payload.thinking.length)} 字符`}
375 onPress={() => togglePart(`${ev.id}:think`)}
376 />
377 {isThinkOpen && (
378 <Text dimColor italic wrap="wrap">
379 {payload.thinking}
380 </Text>
381 )}
382 </Box>
383 )}
384 {output ? (
385 <Box flexDirection="column">
386 <Markdown text={isOutOpen || !outPreview.isCut ? output : `${outPreview.head}\n\n…`} />
387 {outPreview.isCut && (
388 <Button
389 key={`more-${ev.id}:out`}
390 plain
391 label={isOutOpen ? '收起' : `展开全部 · ${outPreview.total} 行`}
392 onPress={() => togglePart(`${ev.id}:out`)}
393 />
394 )}
395 </Box>
396 ) : (
397 <Text dimColor>(无输出)</Text>
398 )}
399 </Box>
400 {timingView(ev)}
401 </Box>
402 )
403 const system =
404 snap === undefined ? (
405 <Text dimColor wrap="wrap">
406 {isSystemCaptured
407 ? '没抓到系统提示:引擎只在会话启动时生成一次;mod 晚于会话启动加载,或组织的托管插件(如 cc-plugin-sec-default)接管了这个事件,都会拿不到。'
408 : '系统提示采集已关闭(默认):组织的托管插件(如 cc-plugin-sec-default)会接管生成系统提示的事件,用户插件收不到。没有这类插件时,可在 /config 里打开 agent-trace 的「采集系统提示」。'}
409 </Text>
410 ) : (
411 <Box flexDirection="column" gap={1}>
412 <Text dimColor wrap="wrap">
413 {snap.model} · {snap.sections.length} 段 · {compactNum(snap.sections.reduce((n, s) => n + s.text.length, 0))}{' '}
414 字符 · 工具 {snap.tools.length} 个:{snap.tools.join(', ')}
415 </Text>
416 {snap.sections.map(s => partView(`${ev.id}:sec:${s.id}`, `${s.id} (${s.scope})`, s.text))}
417 </Box>
418 )
419 return (
420 <Box flexDirection="column" gap={1}>
421 {tabsView(tabs, active)}
422 {active === 'overview' && overview}
423 {active === 'input' && partsView(ev, payload)}
424 {active === 'system' && system}
425 {active === 'raw' && rawView(ev, payload)}
426 </Box>
427 )
428 }
429
430 const toolDetail = (ev: TraceEvent, payload: Payload | undefined) => {
431 const active: DetailTab = which === 'raw' ? 'raw' : 'overview'
432 const { name } = toolLine(ev)
433 const status = STATUS_MARKS[statusOf(ev, lastTurn)].label
434 return (
435 <Box flexDirection="column" gap={1}>
436 {tabsView(
437 [
438 { value: 'overview', label: '概述' },
439 { value: 'raw', label: '原始' },
440 ],
441 active,
442 )}
443 {active === 'overview' && (
444 <Box flexDirection="column" gap={1}>
445 <Box flexDirection="column">
446 {kv('工具', name)}
447 {kv('状态', status)}
448 </Box>
449 {partsView(ev, payload)}
450 {timingView(ev)}
451 </Box>
452 )}
453 {active === 'raw' && rawView(ev, payload)}
454 </Box>
455 )
456 }
457
458 const inputDetail = (ev: TraceEvent, payload: Payload | undefined) => (
459 <Box flexDirection="column" gap={1}>
460 <Box flexDirection="column">
461 {kv('来源', ev.agentId ? `子代理 ${ev.agentId}` : '用户')}
462 {ev.start !== undefined && kv('时间', clockTime(ev.start))}
463 </Box>
464 {partsView(ev, payload)}
465 </Box>
466 )
467
468 const detailView = (ev: TraceEvent) => {
469 const payload = store[ev.id]
470 const where = ev.kind === 'model' ? `第 ${ev.turn} 轮 · 第 ${stepNumber(ev)} 步` : `第 ${ev.turn} 轮`
471 const close = async () => {
472 await update($, selected, () => '')
473 await scrollToRow(ev.id, 'nearest')
474 }
475 return (
476 <Box
477 key={`detail-${ev.id}`}
478 flexDirection="column"
479 gap={1}
480 marginLeft={2}
481 marginBottom={1}
482 paddingX={1}
483 borderStyle="round"
484 borderDimColor
485 >
486 <Box flexDirection="row" gap={1} alignItems="center">
487 {chipView(chipOf(ev))}
488 <Box flexGrow={1} flexShrink={1}>
489 <Text dimColor>{where}</Text>
490 </Box>
491 <Button key={`close-${ev.id}`} plain role="dismiss" label="✕" onPress={close} />
492 </Box>
493 {ev.kind === 'model' && modelDetail(ev, payload)}
494 {ev.kind === 'tool' && toolDetail(ev, payload)}
495 {ev.kind === 'input' && inputDetail(ev, payload)}
496 <Button key={`collapse-${ev.id}`} plain label="▴ 收起" onPress={close} />
497 </Box>
498 )
499 }
500
501 // The clickable part of a row: a Button's own text (Desktop draws a Button's children unstyled).
502 const rowMain = (ev: TraceEvent): string => {
503 if (ev.kind === 'tool') {
504 const { name, arg } = toolLine(ev)
505 return oneLine(`${name} ${arg}`, MAIN_MAX)
506 }
507 if (ev.kind === 'model') {
508 if (ev.end === undefined) return '生成中…'
509 return oneLine(ev.detail, MAIN_MAX) || '(无文本)'
510 }
511 return oneLine(ev.detail, MAIN_MAX * 2)
512 }
513
514 const rowRest = (ev: TraceEvent): string => {
515 if (ev.kind === 'tool') {
516 if (ev.end === undefined) return '→ 运行中…'
517 return `→ ${oneLine(resultSummary(ev.detail), REST_MAX)}`
518 }
519 if (ev.kind === 'model') {
520 return [
521 `+${ev.newCount ?? 0} 输入`,
522 ev.usage && `${compactNum(ev.usage.input + ev.usage.cacheRead)} in · ${compactNum(ev.usage.output)} out`,
523 ev.firstAt !== undefined && ev.start !== undefined && `首 ${formatMs(ev.firstAt - ev.start)}`,
524 ]
525 .filter(Boolean)
526 .join(' · ')
527 }
528 return ''
529 }
530
531 // Coloured flags after a model row's numbers: low cache reuse, and a jump in uncached input.
532 const badges = (ev: TraceEvent) => {
533 if (ev.kind !== 'model' || !ev.usage) return null
534 const hit = cacheHit(ev.usage)
535 const isJump = isContextJump(prevUsage.get(ev.id), ev.usage)
536 return (
537 <Box flexShrink={0} flexDirection="row" gap={1}>
538 {hit !== undefined &&
539 (hit < CACHE_LOW ? (
540 <Text size="footnote" color="warning">{`缓存 ${percent(hit)}`}</Text>
541 ) : (
542 <Text size="footnote" dimColor>{`缓存 ${percent(hit)}`}</Text>
543 ))}
544 {isJump && (
545 <Text size="footnote" color="error">
546 ⚠ 突增
547 </Text>
548 )}
549 </Box>
550 )
551 }
552
553 const showsBars = isRich && e.props.bodyColumns >= BAR_MIN_COLUMNS
554
555 const barView = (n: TreeNode, root: { start?: number; end?: number }, chip: Chip) => {
556 const span = spanOf(n, now)
557 if (!showsBars || root.start === undefined || root.end === undefined || span.start === undefined) return null
558 const whole = Math.max(1, root.end - root.start)
559 const offset = Math.min(BAR_CELLS - 1, Math.floor(((span.start - root.start) / whole) * BAR_CELLS))
560 const length = Math.max(
561 1,
562 Math.min(BAR_CELLS - offset, Math.round((((span.end ?? now) - span.start) / whole) * BAR_CELLS)),
563 )
564 return (
565 <Box width={BAR_CELLS} flexShrink={0}>
566 <Text mono size="caption">
567 {' '.repeat(offset)}
568 <Text backgroundColor={chip.solid}>{' '.repeat(length)}</Text>
569 {' '.repeat(BAR_CELLS - offset - length)}
570 </Text>
571 </Box>
572 )
573 }
574
575 const nodeChip = (n: TreeNode): Chip => (n.kind === 'agent' ? CHIPS.sub : n.ev ? chipOf(n.ev) : CHIPS.user)
576
577 const nodeMain = (n: TreeNode): string =>
578 n.kind === 'agent' ? `子代理 ${String(n.agentId).slice(0, 12)}` : n.ev ? rowMain(n.ev) : ''
579
580 const nodeRest = (n: TreeNode): string => {
581 if (n.kind === 'turn' || n.kind === 'agent') {
582 const { steps, tools } = countOf(n)
583 return `${steps} 步 · ${tools} 工具`
584 }
585 return n.ev ? rowRest(n.ev) : ''
586 }
587
588 const rowView = (row: FlatRow, root: { start?: number; end?: number }) => {
589 const n = row.node
590 const ev = n.ev
591 const isPicked = ev !== undefined && ev.id === pick
592 const span = spanOf(n, now)
593 const total = span.start !== undefined && span.end !== undefined ? span.end - span.start : undefined
594 const tone = total !== undefined ? durationTone(total) : undefined
595 const rest = nodeRest(n)
596 const chip = nodeChip(n)
597 const hasKids = n.children.length > 0
598 const look = isRich
599 ? { hover: { backgroundColor: HOVER_BG }, ...(isPicked ? { backgroundColor: SELECTED_BG } : {}) }
600 : {}
601 const onMain = () => (ev && n.kind !== 'agent' ? toggleRow(ev.id) : toggleFold(n.id))
602 const mark = STATUS_MARKS[nodeStatus(n, lastTurn)]
603 return (
604 <Box key={`rowbox-${n.id}`} flexDirection="column" minWidth={0} {...look}>
605 <Box flexDirection="row" alignItems="center" minWidth={0} overflow="hidden">
606 {row.depth > 0 && <Box width={Math.min(row.depth, INDENT_LEVELS_MAX) * INDENT} flexShrink={0} />}
607 <Box width={2} flexShrink={0}>
608 {hasKids ? (
609 <Button key={`fold-${n.id}`} plain label={row.isOpen ? '▾' : '▸'} onPress={() => toggleFold(n.id)} />
610 ) : (
611 <Text> </Text>
612 )}
613 </Box>
614 <Box flexDirection="row" gap={1} alignItems="center" flexGrow={1} flexShrink={1} minWidth={0}>
615 {/* The kind is the press target: a Button's own text never truncates, so long text stays a Text. */}
616 {/* The dot carries the kind's colour, or the status mark when something is off. */}
617 {mark.color ? <Text color={mark.color}>{mark.mark}</Text> : isRich && <Text color={chip.solid}>●</Text>}
618 <Box flexShrink={0}>
619 <Button key={`row-${n.id}`} plain label={chip.label} onPress={onMain} />
620 </Box>
621 {/* Sibling Texts, each truncating: Desktop wraps a Text that nests others instead of cutting it. */}
622 {n.kind === 'turn' && ev && !ev.agentId && (
623 <Box flexShrink={0}>
624 <Text dimColor tabular>{`#${ev.turn}`}</Text>
625 </Box>
626 )}
627 <Box flexShrink={1} minWidth={0} overflow="hidden">
628 <Text weight={isPicked ? 'bold' : 'medium'} mono={n.kind === 'tool'} wrap="truncate-end">
629 {q ? marked(nodeMain(n)) : nodeMain(n)}
630 </Text>
631 </Box>
632 <Box flexGrow={1} flexShrink={3} minWidth={0} overflow="hidden">
633 {rest !== '' && (
634 <Text dimColor mono={n.kind === 'tool'} wrap="truncate-end">
635 {q ? marked(rest) : rest}
636 </Text>
637 )}
638 </Box>
639 {ev && badges(ev)}
640 {barView(n, root, chip)}
641 {span.start !== undefined && (
642 <Box flexShrink={0}>
643 <Text size="footnote" tabular dimColor>
644 {timeOfDay(span.start)}
645 </Text>
646 </Box>
647 )}
648 {total !== undefined && (
649 <Box width={7} flexShrink={0} justifyContent="flex-end">
650 {tone ? (
651 <Text size="footnote" tabular color={tone}>
652 {formatMs(total)}
653 </Text>
654 ) : (
655 <Text size="footnote" tabular dimColor>
656 {formatMs(total)}
657 </Text>
658 )}
659 </Box>
660 )}
661 </Box>
662 </Box>
663 {isPicked && ev && detailView(ev)}
664 {row.hiddenChildren > 0 && (
665 <Box paddingLeft={(Math.min(row.depth, INDENT_LEVELS_MAX) + 2) * INDENT}>
666 <Button
667 key={`all-${n.id}`}
668 plain
669 label={`… 前面还有 ${row.hiddenChildren} 项,显示全部`}
670 onPress={() => toggleFold(`all:${n.id}`)}
671 />
672 </Box>
673 )}
674 </Box>
675 )
676 }
677
678 // Each row's bar is scaled to the span of the turn it belongs to.
679 const roots = allRows.reduce<{ start?: number; end?: number }[]>((acc, row, i) => {
680 const prev = acc[i - 1] ?? {}
681 return [...acc, row.depth === 0 ? spanOf(row.node, now) : prev]
682 }, [])
683
684 const lastModel = list.filter(ev => ev.kind === 'model').at(-1)
685 const toolbar = (
686 <Box key="header" flexDirection="row" gap={1} alignItems="center" flexWrap="wrap">
687 {MODES.map(m => (
688 <Button
689 key={`mode-${m.value}`}
690 label={m.label}
691 variant={m.value === current ? 'primary' : 'secondary'}
692 onPress={() => update($, mode, () => m.value)}
693 />
694 ))}
695 <Button
696 key="stats"
697 label="工具统计"
698 variant={isStatsOpen ? 'primary' : 'secondary'}
699 onPress={() => update($, showStats, v => !v)}
700 />
701 <Button
702 key="system"
703 label="系统提示"
704 onPress={async () => {
705 if (!lastModel) return
706 await update($, tab, () => 'system' as const)
707 await update($, selected, () => lastModel.id)
708 await scrollToRow(lastModel.id, 'start')
709 }}
710 />
711 <Button
712 key="top"
713 plain
714 label="↑ 顶部"
715 onPress={() => $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)}
716 />
717 <Button
718 key="end"
719 plain
720 label="↓ 最新"
721 onPress={() => $.ui.scroll({ in: PANE, to: 'end' }).catch(() => undefined)}
722 />
723 <Button
724 key="clear"
725 plain
726 label="清空"
727 onPress={async () => {
728 await update($, events, () => [])
729 await update($, payloads, () => ({}))
730 await update($, selected, () => '')
731 await update($, openParts, () => [])
732 await update($, toggled, () => [])
733 await update($, toolFilter, () => '')
734 await update($, rowLimit, () => ROW_PAGE)
735 }}
736 />
737 <Text dimColor size="footnote">
738 {isFiltering ? `匹配 ${matchCount} / ${list.length}` : `${list.length} 条 · ${whole.length} 轮`}
739 {startRow > 0 ? ` · 显示最近 ${flat.length} / ${allRows.length} 行` : ''}
740 </Text>
741 </Box>
742 )
743
744 const search = Input && (
745 <Box flexDirection="row" gap={1} alignItems="center">
746 <Box flexGrow={1} flexShrink={1}>
747 <Input
748 key="search"
749 placeholder="搜索工具、参数、输出、回复…"
750 value={q}
751 submitLabel="搜索"
752 onInput={value => update($, query, () => value)}
753 onSubmit={value => update($, query, () => value)}
754 />
755 </Box>
756 {q !== '' && <Button key="clear-search" plain label="✕ 清除" onPress={() => update($, query, () => '')} />}
757 </Box>
758 )
759
760 const statsView = isStatsOpen && (
761 <Box flexDirection="column">
762 {stats.length === 0 && <Text dimColor>还没有工具调用。</Text>}
763 {stats.slice(0, STATS_MAX).map(t => (
764 <Box key={`stat-${t.name}`} flexDirection="row" gap={1} alignItems="center">
765 <Box flexShrink={0}>
766 <Button
767 key={`stat-pick-${t.name}`}
768 plain
769 label={t.name === onlyTool ? `▶ ${t.name}` : t.name}
770 onPress={() => update($, toolFilter, v => (v === t.name ? '' : t.name))}
771 />
772 </Box>
773 <Box flexGrow={1} flexShrink={1} minWidth={0}>
774 <Text dimColor size="footnote" wrap="truncate-end">
775 {`${t.count} 次 · 总 ${formatMs(t.totalMs)} · 均 ${formatMs(t.avgMs)}`}
776 {t.errors > 0 ? <Text color="error">{` · 失败 ${t.errors}`}</Text> : ''}
777 </Text>
778 </Box>
779 </Box>
780 ))}
781 {stats.length > STATS_MAX && <Text dimColor size="footnote">还有 {stats.length - STATS_MAX} 种工具未列出</Text>}
782 </Box>
783 )
784
785 const filterChip = onlyTool !== '' && (
786 <Button key="tool-filter" plain label={`只看工具:${onlyTool} ✕`} onPress={() => update($, toolFilter, () => '')} />
787 )
788
789 const svgWidth = Math.max(TIMELINE_MIN_WIDTH, Math.round(e.props.bodyColumns * PX_PER_COLUMN))
790 const timeline = Client ? (
791 <Client
792 key="timeline"
793 module="./timeline.tsx"
794 props={model}
795 width="100%"
796 flexGrow={1}
797 />
798 ) : Svg ? (
799 <Svg
800 source={timelineSvg(list, current, now, svgWidth, pick)}
801 alt="轨迹时间轴:输入 / 模型 / 工具"
802 height={TIMELINE_HEIGHT}
803 isInteractive
804 />
805 ) : (
806 <Text dimColor>(时间轴在 Desktop 中显示)</Text>
807 )
808
809 const empty = q ? `没有匹配「${q}」的记录。` : '暂无轨迹。发一条消息后这里会出现输入 / 模型 / 工具调用。'
810
811 return (
812 <Box flexDirection="column" gap={1} width="100%" minWidth={0} overflow="hidden">
813 {toolbar}
814 {statsView}
815 {search}
816 {filterChip}
817 {timeline}
818 <Box flexDirection="column" minWidth={0}>
819 {flat.length === 0 && <Text dimColor>{empty}</Text>}
820 {startRow > 0 &&
821 (flat.length < Math.min(ROW_MAX, rowRoom) ? (
822 <Button
823 key="older"
824 plain
825 label={`▴ 加载更早的 ${Math.min(ROW_PAGE, startRow, Math.min(ROW_MAX, rowRoom) - flat.length)} 行(上方还有 ${startRow} 行)`}
826 onPress={() => update($, rowLimit, v => Math.min(ROW_MAX, (v ?? ROW_PAGE) + ROW_PAGE))}
827 />
828 ) : (
829 <Text dimColor>上方还有 {startRow} 行:折叠几轮或用搜索查看更早的记录。</Text>
830 ))}
831 {flat.map((row, i) => rowView(row, roots[startRow + i] ?? {}))}
832 </Box>
833 </Box>
834 )
835 })
836}
837hooks/rows.ts 108 lines1import type { StepUsage, TraceEvent } from '../types'
2import { oneLine, toJson } from './trace'
3
4const ARG_MAX = 90
5const RESULT_MAX = 90
6
7export const compactNum = (n: number): string =>
8 n < 1000 ? String(n) : n < 1e6 ? `${+(n / 1000).toFixed(1)}k` : `${+(n / 1e6).toFixed(1)}M`
9
10const str = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined)
11
12// Keys that best describe a call, in order; the first string one present wins.
13const ARG_KEYS = [
14 'command',
15 'file_path',
16 'notebook_path',
17 'path',
18 'pattern',
19 'url',
20 'query',
21 'description',
22 'prompt',
23 'skill',
24 'subject',
25]
26
27// What a row says about a call: the tool's short name and its most telling argument.
28export const toolSummary = (tool: string, args: Readonly<Record<string, unknown>>): { name: string; arg: string } => {
29 const name = tool.startsWith('mcp__') ? tool.split('__').slice(1).join('.') : tool
30 const pattern = str(args.pattern)
31 const path = str(args.path) ?? str(args.file_path)
32 const picked =
33 tool === 'Grep' && pattern
34 ? `${pattern}${path ? ` in ${path}` : ''}`
35 : (ARG_KEYS.map(k => str(args[k])).find(Boolean) ??
36 Object.values(args).map(str).find(Boolean) ??
37 (Object.keys(args).length ? toJson(args) : ''))
38 return { name, arg: oneLine(picked, ARG_MAX) }
39}
40
41// First non-empty line of a result, with how many more lines it had.
42export const resultSummary = (text: string): string => {
43 const lines = text.split('\n').filter(line => line.trim() !== '')
44 if (lines.length === 0) return '(无输出)'
45 const more = lines.length > 1 ? ` (+${lines.length - 1} 行)` : ''
46 return `${oneLine(lines[0] ?? '', RESULT_MAX)}${more}`
47}
48
49export type Run = { text: string; isMatch: boolean }
50
51// Splits text into runs around case-insensitive matches of query, for highlighting.
52export const highlight = (text: string, query: string): Run[] => {
53 const q = query.trim().toLowerCase()
54 if (q === '') return [{ text, isMatch: false }]
55 const lower = text.toLowerCase()
56 const runs: Run[] = []
57 let at = 0
58 for (let hit = lower.indexOf(q); hit !== -1; hit = lower.indexOf(q, at)) {
59 if (hit > at) runs.push({ text: text.slice(at, hit), isMatch: false })
60 runs.push({ text: text.slice(hit, hit + q.length), isMatch: true })
61 at = hit + q.length
62 }
63 if (at < text.length) runs.push({ text: text.slice(at), isMatch: false })
64 return runs
65}
66
67// The first n lines of a long text, and how many it has.
68export const preview = (text: string, n: number): { head: string; total: number; isCut: boolean } => {
69 const lines = text.split('\n')
70 return { head: lines.slice(0, n).join('\n'), total: lines.length, isCut: lines.length > n }
71}
72
73export const durationTone = (ms: number): 'error' | 'warning' | undefined =>
74 ms >= 60_000 ? 'error' : ms >= 10_000 ? 'warning' : undefined
75
76// Share of the prompt served from the cache; undefined when the step sent nothing.
77export const cacheHit = (u: StepUsage): number | undefined => {
78 const sent = u.input + u.cacheRead + u.cacheWrite
79 return sent > 0 ? u.cacheRead / sent : undefined
80}
81
82export const CACHE_LOW = 0.5
83const JUMP_RATIO = 3
84const JUMP_MIN_TOKENS = 5000
85
86// Tokens a step sent past the cache grew sharply against the loop's previous step.
87export const isContextJump = (prev: StepUsage | undefined, cur: StepUsage): boolean => {
88 const fresh = (u: StepUsage) => u.input + u.cacheWrite
89 return prev !== undefined && fresh(cur) >= JUMP_MIN_TOKENS && fresh(cur) > fresh(prev) * JUMP_RATIO
90}
91
92export type ToolStat = { name: string; count: number; totalMs: number; avgMs: number; errors: number }
93
94// Per-tool totals, slowest in total first.
95export const toolStats = (events: readonly TraceEvent[]): ToolStat[] => {
96 const by = new Map<string, { count: number; totalMs: number; errors: number }>()
97 for (const ev of events) {
98 if (ev.kind !== 'tool') continue
99 const name = ev.name ?? ev.title.split(' ')[0] ?? ''
100 const ms = ev.start !== undefined && ev.end !== undefined ? ev.end - ev.start : 0
101 const old = by.get(name) ?? { count: 0, totalMs: 0, errors: 0 }
102 by.set(name, { count: old.count + 1, totalMs: old.totalMs + ms, errors: old.errors + (ev.isError || ev.isDenied ? 1 : 0) })
103 }
104 return [...by.entries()]
105 .map(([name, v]) => ({ name, ...v, avgMs: v.count ? v.totalMs / v.count : 0 }))
106 .sort((a, b) => b.totalMs - a.totalMs)
107}
108hooks/trace.ts 287 lines1import type { SessionMessage } from 'claude-code'
2
3import type { TraceEvent, TraceKind, TraceMode } from '../types'
4
5export const MAX_EVENTS = 2000
6const SNIPPET = 160
7const LANES: readonly TraceKind[] = ['input', 'model', 'tool']
8const LANE_NAMES: Record<TraceKind, string> = { input: '输入', model: '模型', tool: '工具' }
9const COLORS: Record<TraceKind, string> = { input: '#5b8def', model: '#8e6cc0', tool: '#e0862a' }
10const ERROR_COLOR = '#d9534f'
11const WAIT_COLOR = '#c4b3dd'
12const STEP_INPUT_COLOR = '#5cb85c'
13const INPUT_SHARE = 0.25
14const AXIS_HEIGHT = 16
15const TICK_TARGET = 8
16const TICK_STEPS = [100, 200, 500, 1e3, 2e3, 5e3, 1e4, 15e3, 3e4, 6e4, 12e4, 3e5, 6e5, 9e5, 18e5, 36e5]
17// Room kept clear at the axis' right end for the span label.
18const SPAN_ROOM = 110
19// A tick label closer than this to the previous drawn one keeps its gridline only.
20const LABEL_GAP = 70
21const SVG_WIDTH = 1000
22const LABEL_WIDTH = 44
23const LANE_HEIGHT = 18
24const BAR_HEIGHT = 12
25const MIN_BAR = 2
26// Svg source caps at 131072 chars; a model step draws up to three rects, ~850 chars.
27export const TIMELINE_MAX = 120
28
29// Terminal colour codes and control characters: noise in a row, and invalid inside the SVG.
30const ANSI = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g
31const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
32
33export const clean = (text: string): string => text.replace(ANSI, '').replace(CONTROL, '')
34
35export const oneLine = (text: string, max = SNIPPET): string => {
36 const flat = clean(text).replace(/\s+/g, ' ').trim()
37 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
38}
39
40export const toJson = (value: unknown): string => {
41 try {
42 return JSON.stringify(value) ?? ''
43 } catch {
44 return String(value)
45 }
46}
47
48// Tool args minus the envelope keys tool.call adds beside them.
49export const toolArgs = (e: Record<string, unknown>): Record<string, unknown> => {
50 const { tool: _t, tool_use_id: _id, agentId: _a, requestMeta: _m, consent: _c, ...args } = e
51 return args
52}
53
54export const currentTurn = (list: readonly TraceEvent[]): number =>
55 list.reduce((n, ev) => (ev.kind === 'input' && !ev.agentId ? n + 1 : n), 0)
56
57export const append = (list: readonly TraceEvent[], ev: Omit<TraceEvent, 'turn'>): TraceEvent[] => {
58 const turn = ev.kind === 'input' && !ev.agentId ? currentTurn(list) + 1 : Math.max(1, currentTurn(list))
59 return [...list, { ...ev, turn }].slice(-MAX_EVENTS)
60}
61
62export const patch = (list: readonly TraceEvent[], id: string, fields: Partial<TraceEvent>): TraceEvent[] =>
63 list.map(ev => (ev.id === id ? { ...ev, ...fields } : ev))
64
65export const matches = (ev: TraceEvent, query: string): boolean => {
66 const q = query.trim().toLowerCase()
67 return q === '' || `${ev.title} ${ev.detail}`.toLowerCase().includes(q)
68}
69
70export const formatMs = (ms: number): string =>
71 ms < 1000 ? `${Math.round(ms)}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${(ms / 60_000).toFixed(1)}m`
72
73// Rebuilds an untimed trace from the transcript, for history from before the mod loaded.
74export const fromMessages = (messages: readonly SessionMessage[]): TraceEvent[] =>
75 messages.reduce<TraceEvent[]>((list, m, i) => {
76 if (m.role === 'user') {
77 return m.text.trim() ? append(list, { id: `h${i}`, kind: 'input', title: '用户', detail: m.text }) : list
78 }
79 const withText = m.text.trim()
80 ? append(list, { id: `h${i}`, kind: 'model', title: '助手', detail: m.text })
81 : list
82 return m.toolUses.reduce(
83 (acc, use) =>
84 append(acc, {
85 id: use.tool_use_id,
86 kind: 'tool',
87 title: `${use.tool} ${toJson(use.input)}`,
88 detail: use.text ?? '',
89 ...(use.isError ? { isError: true } : {}),
90 }),
91 withText,
92 )
93 }, [])
94
95type Bar = { ev: TraceEvent; x: number; w: number }
96
97const rangeOf = (timed: readonly TraceEvent[], now: number): { t0: number; span: number } => {
98 const t0 = Math.min(...timed.map(ev => ev.start ?? now))
99 return { t0, span: Math.max(1, Math.max(...timed.map(ev => ev.end ?? now)) - t0) }
100}
101
102const timeBars = (timed: readonly TraceEvent[], now: number): Bar[] => {
103 const { t0, span } = rangeOf(timed, now)
104 return timed.map(ev => {
105 const x = (((ev.start ?? now) - t0) / span) * SVG_WIDTH
106 return { ev, x, w: (((ev.end ?? now) - (ev.start ?? now)) / span) * SVG_WIDTH }
107 })
108}
109
110const turnBars = (timed: readonly TraceEvent[], now: number): Bar[] => {
111 const turns = [...new Set(timed.map(ev => ev.turn))]
112 const slot = SVG_WIDTH / Math.max(1, turns.length)
113 return turns.flatMap((turn, i) =>
114 timeBars(
115 timed.filter(ev => ev.turn === turn),
116 now,
117 ).map(bar => ({ ...bar, x: i * slot + (bar.x / SVG_WIDTH) * slot, w: (bar.w / SVG_WIDTH) * slot })),
118 )
119}
120
121const callBars = (list: readonly TraceEvent[]): Bar[] => {
122 const slot = SVG_WIDTH / Math.max(1, list.length)
123 return list.map((ev, i) => ({ ev, x: i * slot, w: slot }))
124}
125
126export const layout = (list: readonly TraceEvent[], mode: TraceMode, now: number): Bar[] => {
127 if (mode === 'call') return callBars(list)
128 const timed = list.filter(ev => ev.start !== undefined)
129 if (timed.length === 0) return []
130 return mode === 'time' ? timeBars(timed, now) : turnBars(timed, now)
131}
132
133// The smallest round step that gives at most TICK_TARGET ticks; whole hours past the table.
134export const niceStep = (span: number): number =>
135 TICK_STEPS.find(step => step >= span / TICK_TARGET) ?? Math.ceil(span / TICK_TARGET / 36e5) * 36e5
136
137export const tickLabel = (ms: number): string => {
138 if (ms === 0) return '0'
139 if (ms < 1e3) return `${ms}ms`
140 if (ms < 6e4) return `${+(ms / 1e3).toFixed(1)}s`
141 if (ms < 36e5) return ms % 6e4 === 0 ? `${ms / 6e4}m` : `${Math.floor(ms / 6e4)}m${Math.round((ms % 6e4) / 1e3)}s`
142 return ms % 36e5 === 0 ? `${ms / 36e5}h` : `${Math.floor(ms / 36e5)}h${Math.round((ms % 36e5) / 6e4)}m`
143}
144
145type Tick = { x: number; label: string; isStart?: true }
146
147// Ticks (x in 0..SVG_WIDTH) and the span label under the lanes, per mode.
148export const axis = (
149 list: readonly TraceEvent[],
150 mode: TraceMode,
151 now: number,
152): { ticks: Tick[]; caption: string } => {
153 const timed = list.filter(ev => ev.start !== undefined)
154 if (timed.length === 0) return { ticks: [], caption: list.length ? `${list.length} 项 · 无耗时数据` : '' }
155 const { span } = rangeOf(timed, now)
156 const caption = `跨度 ${formatMs(span)}`
157 if (mode === 'time') {
158 const step = niceStep(span)
159 const count = Math.floor(span / step)
160 const ticks = Array.from({ length: count + 1 }, (_, k) => ({ x: ((k * step) / span) * SVG_WIDTH, label: tickLabel(k * step) }))
161 return { ticks, caption }
162 }
163 if (mode === 'turn') {
164 const turns = [...new Set(timed.map(ev => ev.turn))]
165 const slot = SVG_WIDTH / turns.length
166 const ticks = turns.map((turn, i) => ({
167 x: i * slot,
168 label: `#${turn} ${formatMs(rangeOf(timed.filter(ev => ev.turn === turn), now).span)}`,
169 isStart: true as const,
170 }))
171 return { ticks, caption: `${turns.length} 轮 · ${caption}` }
172 }
173 return { ticks: [], caption: `${list.length} 项 · ${caption}` }
174}
175
176const escapeXml = (text: string): string =>
177 clean(text).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c] ?? c)
178
179export const TIMELINE_HEIGHT = LANES.length * LANE_HEIGHT + AXIS_HEIGHT
180
181// Drawn at `width` CSS pixels so labels keep their size in a narrow pane.
182export const timelineSvg = (
183 list: readonly TraceEvent[],
184 mode: TraceMode,
185 now: number,
186 width = SVG_WIDTH,
187 selected = '',
188): string => {
189 const view = list.slice(-TIMELINE_MAX)
190 const bars = layout(view, mode, now)
191 const scale = (width - LABEL_WIDTH) / SVG_WIDTH
192 const lanesHeight = LANES.length * LANE_HEIGHT
193 const labels = LANES.map(
194 (kind, row) =>
195 `<text x="0" y="${row * LANE_HEIGHT + 13}" font-size="11" fill="#888">${LANE_NAMES[kind]}</text>`,
196 ).join('')
197 const { ticks, caption } = axis(view, mode, now)
198 const axisY = lanesHeight + 12
199 const placed = ticks
200 .map(t => ({ ...t, px: LABEL_WIDTH + t.x * scale }))
201 .filter(t => t.px <= width - SPAN_ROOM)
202 const labelled = placed.reduce<number[]>(
203 (kept, t) => (kept.length === 0 || t.px - kept[kept.length - 1]! >= LABEL_GAP ? [...kept, t.px] : kept),
204 [],
205 )
206 const grid = placed
207 .map(
208 (t, i) =>
209 `<line x1="${t.px.toFixed(1)}" x2="${t.px.toFixed(1)}" y1="0" y2="${lanesHeight}" stroke="#888" stroke-opacity="0.25"/>` +
210 (labelled.includes(t.px)
211 ? `<text x="${t.px.toFixed(1)}" y="${axisY}" font-size="10" fill="#888"${i === 0 || t.isStart ? '' : ' text-anchor="middle"'}>${escapeXml(t.label)}</text>`
212 : ''),
213 )
214 .join('')
215 const axisRow =
216 `<text x="0" y="${axisY}" font-size="11" fill="#888">时间</text>${grid}` +
217 `<text x="${width}" y="${axisY}" font-size="10" fill="#888" text-anchor="end">${escapeXml(caption)}</text>`
218 const rect = (row: number, x: number, w: number, fill: string, tip: string, opacity = 0.9, isPicked = false): string =>
219 `<rect${isPicked ? ' stroke="#3d6fd6" stroke-width="2"' : ''} x="${(LABEL_WIDTH + x * scale).toFixed(1)}" y="${row * LANE_HEIGHT + 3}" width="${Math.max(MIN_BAR, w * scale - 1).toFixed(1)}" height="${BAR_HEIGHT}" rx="2" fill="${fill}" opacity="${opacity}"><title>${tip}</title></rect>`
220 const rects = bars
221 .map(({ ev, x, w }) => {
222 const row = LANES.indexOf(ev.kind)
223 const total = ev.start !== undefined ? (ev.end ?? now) - ev.start : undefined
224 const dur = total !== undefined ? ` · ${formatMs(total)}` : ''
225 const tip = escapeXml(`${oneLine(ev.title, 120)}${dur}\n${oneLine(ev.detail, 120)}`)
226 const isRunning = ev.end === undefined && ev.start !== undefined
227 if (ev.kind !== 'model') {
228 return rect(row, x, w, ev.isError ? ERROR_COLOR : COLORS[ev.kind], tip, isRunning ? 0.5 : 0.9, ev.id === selected)
229 }
230 // Model bar: light until the first token, dark while streaming; the step's new input on the input lane.
231 const wait = ev.firstAt !== undefined && total ? Math.min(1, (ev.firstAt - (ev.start ?? 0)) / total) : 0
232 const inputTip = escapeXml(`本步新增输入 ${ev.newCount ?? 0} 条 · ${ev.newChars ?? 0} 字符`)
233 return [
234 ev.newCount ? rect(LANES.indexOf('input'), x, Math.max(w * INPUT_SHARE, 0), STEP_INPUT_COLOR, inputTip) : '',
235 wait > 0 ? rect(row, x, w * wait, WAIT_COLOR, `等待首 token ${formatMs(ev.firstAt! - ev.start!)}`) : '',
236 rect(row, x + w * wait, w * (1 - wait), COLORS.model, tip, isRunning ? 0.5 : 0.9, ev.id === selected),
237 ].join('')
238 })
239 .join('')
240 const height = lanesHeight + AXIS_HEIGHT
241 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${labels}${axisRow}${rects}</svg>`
242}
243
244// One bar of the clickable timeline: lane 0 input, 1 model, 2 tool; x0..x1 a fraction of the width.
245export type TimelineBar = { id: string; lane: number; x0: number; x1: number; color: string; tip: string }
246
247export type TimelineProps = {
248 // cells across the pane, so the region is drawn full width from its first frame
249 columns: number
250 bars: TimelineBar[]
251 ticks: { x: number; label: string }[]
252 caption: string
253 picked: string
254}
255
256// The timeline as data for the Client module that draws it in cells and reports clicks.
257export const timelineModel = (
258 list: readonly TraceEvent[],
259 mode: TraceMode,
260 now: number,
261 picked: string,
262 columns: number,
263): TimelineProps => {
264 const view = list.slice(-TIMELINE_MAX)
265 const bars = layout(view, mode, now).flatMap(({ ev, x, w }): TimelineBar[] => {
266 const total = ev.start !== undefined ? (ev.end ?? now) - ev.start : undefined
267 const tip = `${oneLine(ev.title, 80)}${total !== undefined ? ` · ${formatMs(total)}` : ''}`
268 const x0 = x / SVG_WIDTH
269 const x1 = (x + w) / SVG_WIDTH
270 const own = {
271 id: ev.id,
272 lane: LANES.indexOf(ev.kind),
273 x0,
274 x1,
275 color: ev.isError || ev.isDenied ? ERROR_COLOR : COLORS[ev.kind],
276 tip,
277 }
278 const input =
279 ev.kind === 'model' && ev.newCount
280 ? [{ ...own, lane: 0, x1: x0 + (x1 - x0) * INPUT_SHARE, color: STEP_INPUT_COLOR, tip: `本步新增输入 ${ev.newCount} 条 · ${tip}` }]
281 : []
282 return [...input, own]
283 })
284 const { ticks, caption } = axis(view, mode, now)
285 return { columns, bars, ticks: ticks.map(t => ({ x: t.x / SVG_WIDTH, label: t.label })), caption, picked }
286}
287hooks/status.ts 22 lines1import type { TraceEvent } from '../types'
2import type { TreeNode } from './tree'
3
4export type Status = 'ok' | 'running' | 'interrupted' | 'denied' | 'error'
5
6// Worst first: a folded parent shows the worst status among everything under it.
7const PRIORITY: readonly Status[] = ['error', 'denied', 'interrupted', 'running', 'ok']
8
9// A call or step with no end is still running in the latest turn, and was cut off in any earlier one.
10export const statusOf = (ev: TraceEvent, lastTurn: number): Status => {
11 if (ev.isDenied) return 'denied'
12 if (ev.isError) return 'error'
13 if (ev.start !== undefined && ev.end === undefined) return ev.turn < lastTurn ? 'interrupted' : 'running'
14 return 'ok'
15}
16
17export const worst = (statuses: readonly Status[]): Status =>
18 PRIORITY.find(s => statuses.includes(s)) ?? 'ok'
19
20export const nodeStatus = (n: TreeNode, lastTurn: number): Status =>
21 worst([...(n.ev ? [statusOf(n.ev, lastTurn)] : []), ...n.children.map(c => nodeStatus(c, lastTurn))])
22hooks/jump.ts 34 lines1import { flatten, isOpenBy } from './tree'
2import type { TreeNode } from './tree'
3
4const pathTo = (nodes: readonly TreeNode[], id: string): TreeNode[] | undefined => {
5 for (const n of nodes) {
6 if (n.id === id) return [n]
7 const below = pathTo(n.children, id)
8 if (below) return [n, ...below]
9 }
10 return undefined
11}
12
13// Opens every folded ancestor of `id` and says where its row lands; undefined when the tree lacks it.
14export const planJump = (
15 whole: readonly TreeNode[],
16 toggled: readonly string[],
17 openTurns: number,
18 id: string,
19): { toggled: string[]; index: number; total: number } | undefined => {
20 const path = pathTo(whole, id)
21 if (!path) return undefined
22 const isOpen = isOpenBy(whole, toggled, openTurns)
23 const toOpen = path.slice(0, -1).filter(n => !isOpen(n)).map(n => n.id)
24 const opened = [...toggled.filter(k => !toOpen.includes(k)), ...toOpen.filter(k => !toggled.includes(k))]
25 const rowsOf = (keys: readonly string[]) =>
26 flatten(whole, isOpenBy(whole, keys, openTurns), n => keys.includes(`all:${n.id}`))
27 // A row past its parent's shown children needs the parent to show all of them.
28 const parent = path.at(-2)
29 const next =
30 parent && rowsOf(opened).every(r => r.node.id !== id) ? [...opened, `all:${parent.id}`] : opened
31 const rows = rowsOf(next)
32 return { toggled: next, index: Math.max(0, rows.findIndex(r => r.node.id === id)), total: rows.length }
33}
34hooks/tree.ts 124 lines1import type { TraceEvent } from '../types'
2
3// A row of the trace tree: a turn (its input), a model step, a tool call, or a subagent's run.
4export type TreeNode = {
5 id: string
6 kind: 'turn' | 'model' | 'tool' | 'agent'
7 ev?: TraceEvent
8 agentId?: string
9 children: TreeNode[]
10}
11
12export type FlatRow = { node: TreeNode; depth: number; isOpen: boolean; hiddenChildren: number }
13
14const SPAWNING_TOOLS = ['Agent', 'Task']
15export const CHILDREN_MAX = 40
16
17const node = (ev: TraceEvent): TreeNode => ({
18 id: ev.id,
19 kind: ev.kind === 'input' ? 'turn' : ev.kind,
20 ev,
21 children: [],
22})
23
24// One loop's events, in order: an input opens a turn, a step joins the turn, a tool joins the step that asked for it.
25const buildLoop = (evs: readonly TraceEvent[]): TreeNode[] => {
26 const top: TreeNode[] = []
27 let turn: TreeNode | undefined
28 let step: TreeNode | undefined
29 for (const ev of evs) {
30 const n = node(ev)
31 if (ev.kind === 'input') {
32 top.push(n)
33 turn = n
34 step = undefined
35 } else if (ev.kind === 'model') {
36 ;(turn ? turn.children : top).push(n)
37 step = n
38 } else {
39 const parent = step ?? turn
40 if (parent) parent.children.push(n)
41 else top.push(n)
42 }
43 }
44 return top
45}
46
47const walk = (nodes: readonly TreeNode[]): TreeNode[] => nodes.flatMap(n => [n, ...walk(n.children)])
48
49export const spanOf = (n: TreeNode, now: number): { start?: number; end?: number; isRunning: boolean } => {
50 const evs = [n, ...walk(n.children)].map(x => x.ev).filter((ev): ev is TraceEvent => ev !== undefined)
51 const starts = evs.map(ev => ev.start).filter((t): t is number => t !== undefined)
52 const isRunning = evs.some(ev => ev.start !== undefined && ev.end === undefined)
53 const ends = evs.map(ev => ev.end ?? (ev.start !== undefined ? now : undefined)).filter((t): t is number => t !== undefined)
54 return {
55 ...(starts.length ? { start: Math.min(...starts) } : {}),
56 ...(ends.length ? { end: Math.max(...ends) } : {}),
57 isRunning,
58 }
59}
60
61// Main-loop turns, with each subagent's run nested under the call that spawned it.
62export const buildTree = (events: readonly TraceEvent[]): TreeNode[] => {
63 const main = buildLoop(events.filter(ev => !ev.agentId))
64 const agentIds = [...new Set(events.map(ev => ev.agentId).filter((id): id is string => !!id))]
65 const mainNodes = walk(main)
66 for (const agentId of agentIds) {
67 const evs = events.filter(ev => ev.agentId === agentId)
68 const group: TreeNode = { id: `agent:${agentId}`, kind: 'agent', agentId, children: buildLoop(evs) }
69 const at = evs.find(ev => ev.start !== undefined)?.start
70 const spawner = [...mainNodes]
71 .reverse()
72 .find(
73 n =>
74 n.kind === 'tool' &&
75 at !== undefined &&
76 n.ev?.start !== undefined &&
77 n.ev.start <= at &&
78 (n.ev.end === undefined || n.ev.end >= at) &&
79 SPAWNING_TOOLS.includes(n.ev.name ?? n.ev.title.split(' ')[0] ?? ''),
80 )
81 const fallback = [...mainNodes].reverse().find(n => n.kind === 'model' && (at === undefined || (n.ev?.start ?? 0) <= at))
82 const parent = spawner ?? fallback
83 if (parent) parent.children.push(group)
84 else main.push(group)
85 }
86 return main
87}
88
89export const countOf = (n: TreeNode): { steps: number; tools: number } =>
90 walk(n.children).reduce(
91 (c, x) => ({ steps: c.steps + (x.kind === 'model' ? 1 : 0), tools: c.tools + (x.kind === 'tool' ? 1 : 0) }),
92 { steps: 0, tools: 0 },
93 )
94
95// Keeps the nodes that match, with their ancestors; null when nothing under `n` matches.
96export const filterTree = (nodes: readonly TreeNode[], isMatch: (ev: TraceEvent) => boolean): TreeNode[] =>
97 nodes.flatMap(n => {
98 const children = filterTree(n.children, isMatch)
99 return (n.ev && isMatch(n.ev)) || children.length ? [{ ...n, children }] : []
100 })
101
102// Open by default: all but the main loop's older turns and subagent runs. `toggled` flips the default.
103export const isOpenBy =
104 (top: readonly TreeNode[], toggled: readonly string[], openTurns: number) =>
105 (n: TreeNode): boolean => {
106 const isOldTurn = top.some(t => t.id === n.id) && !top.slice(-openTurns).some(t => t.id === n.id)
107 const byDefault = n.kind !== 'agent' && !isOldTurn
108 return toggled.includes(n.id) ? !byDefault : byDefault
109 }
110
111// Depth-first rows of the open part of the tree; long child lists show their last CHILDREN_MAX.
112export const flatten = (
113 nodes: readonly TreeNode[],
114 isOpen: (n: TreeNode) => boolean,
115 showsAll: (n: TreeNode) => boolean,
116 depth = 0,
117): FlatRow[] =>
118 nodes.flatMap(n => {
119 const open = n.children.length > 0 && isOpen(n)
120 const kids = open && !showsAll(n) ? n.children.slice(-CHILDREN_MAX) : n.children
121 const row = { node: n, depth, isOpen: open, hiddenChildren: open ? n.children.length - kids.length : 0 }
122 return open ? [row, ...flatten(kids, isOpen, showsAll, depth + 1)] : [row]
123 })
124hooks/timeline.tsx 98 lines1import type { ClientModule } from 'claude-code'
2
3// Self-contained: a surface module runs on the drawing side, apart from the hooks module.
4type Bar = { id: string; lane: number; x0: number; x1: number; color: string; tip: string }
5type Props = { columns: number; bars: Bar[]; ticks: { x: number; label: string }[]; caption: string; picked: string }
6type State = { hover: string }
7
8const LANE_NAMES = ['输入', '模型', '工具']
9const LABEL_CELLS = 5
10const PICKED_COLOR = '#3d6fd6'
11// A click this many cells away from a thin bar still picks it.
12const REACH_CELLS = 1.5
13
14const hitAt = (bars: readonly Bar[], lane: number, f: number, reach: number): Bar | undefined => {
15 const inLane = bars.filter(b => b.lane === lane)
16 const inside = inLane.filter(b => b.x0 <= f && f <= b.x1).at(-1)
17 if (inside) return inside
18 const near = inLane
19 .map(b => ({ b, d: Math.min(Math.abs(b.x0 - f), Math.abs(b.x1 - f)) }))
20 .filter(n => n.d <= reach)
21 .sort((a, c) => a.d - c.d)[0]
22 return near?.b
23}
24
25// One lane as runs of coloured cells; later bars paint over earlier ones.
26const laneCells = (bars: readonly Bar[], lane: number, width: number, picked: string): (string | undefined)[] => {
27 const cells: (string | undefined)[] = Array.from({ length: width }, () => undefined)
28 for (const b of bars) {
29 if (b.lane !== lane) continue
30 const c0 = Math.min(width - 1, Math.floor(b.x0 * width))
31 const c1 = Math.max(c0 + 1, Math.min(width, Math.ceil(b.x1 * width)))
32 for (let c = c0; c < c1; c++) cells[c] = b.id === picked ? PICKED_COLOR : b.color
33 }
34 return cells
35}
36
37const runsOf = (cells: readonly (string | undefined)[]): { color?: string; n: number }[] =>
38 cells.reduce<{ color?: string; n: number }[]>((runs, color) => {
39 const last = runs.at(-1)
40 if (last && last.color === color) return [...runs.slice(0, -1), { ...last, n: last.n + 1 }]
41 return [...runs, color ? { color, n: 1 } : { n: 1 }]
42 }, [])
43
44const tickLine = (ticks: Props['ticks'], width: number): string => {
45 const chars = Array.from({ length: width }, () => ' ')
46 let free = 0
47 for (const t of ticks) {
48 const at = Math.min(width - 1, Math.round(t.x * width))
49 if (at < free || at + t.label.length > width) continue
50 for (let i = 0; i < t.label.length; i++) chars[at + i] = t.label[i] ?? ' '
51 free = at + t.label.length + 2
52 }
53 return chars.join('')
54}
55
56const Timeline: ClientModule<Props, State> = (props, surface) => {
57 const { Box, Text } = surface.elements
58 // Draw at least to the pane's reported width (a region can shrink to what it drew), and wider once the laid-out
59 // region turns out wider; a resize calls the module again.
60 const width = Math.max(10, Math.max(props.columns, surface.columns) - LABEL_CELLS)
61 const hover = surface.state?.hover ?? ''
62
63 surface.onPointer(ev => {
64 const f = (ev.x - LABEL_CELLS + 0.5) / width
65 const hit = ev.y >= 0 && ev.y < LANE_NAMES.length && f >= 0 ? hitAt(props.bars, ev.y, f, REACH_CELLS / width) : undefined
66 if (ev.type === 'down' && ev.button === 'left' && hit) surface.post({ jump: hit.id })
67 if ((ev.type === 'move' || ev.type === 'leave') && (hit?.id ?? '') !== hover) surface.setState({ hover: hit?.id ?? '' })
68 })
69
70 const tip = props.bars.find(b => b.id === hover)?.tip
71 return (
72 <Box flexDirection="column">
73 {LANE_NAMES.map((name, lane) => (
74 <Box key={`lane-${lane}`} flexDirection="row">
75 <Text dimColor>{name.padEnd(LABEL_CELLS - 1, ' ')} </Text>
76 {/* Monospaced, so each space is one cell wide: a proportional space is about half a cell. */}
77 <Text mono>
78 {runsOf(laneCells(props.bars, lane, width, props.picked)).map(r =>
79 r.color ? <Text backgroundColor={r.color}>{' '.repeat(r.n)}</Text> : ' '.repeat(r.n),
80 )}
81 </Text>
82 </Box>
83 ))}
84 <Box flexDirection="row">
85 <Text dimColor>{'时间'.padEnd(LABEL_CELLS - 1, ' ')} </Text>
86 <Text dimColor mono>
87 {tickLine(props.ticks, width)}
88 </Text>
89 </Box>
90 <Text dimColor wrap="truncate-end">
91 {tip ? `▸ ${tip}(点击跳到这一行)` : `${props.caption} · 点击色块跳到对应行`}
92 </Text>
93 </Box>
94 )
95}
96
97export default Timeline
98types/index.d.ts 61 lines1export type TraceKind = 'input' | 'model' | 'tool'
2
3export type TraceMode = 'time' | 'turn' | 'call'
4
5export type DetailTab = 'overview' | 'input' | 'system' | 'raw'
6
7export type StepUsage = { input: number; output: number; cacheRead: number; cacheWrite: number }
8
9export type TraceEvent = {
10 id: string
11 kind: TraceKind
12 turn: number
13 title: string
14 detail: string
15 start?: number
16 end?: number
17 agentId?: string
18 isError?: boolean
19 // a hook or the permission check refused the call
20 isDenied?: boolean
21 // tools: short name and the most telling argument, for the row
22 name?: string
23 summary?: string
24 // model steps: when the first content chunk arrived, the request's size and what it cost
25 firstAt?: number
26 messageCount?: number
27 newCount?: number
28 newChars?: number
29 systemId?: string
30 usage?: StepUsage
31}
32
33// One content block of a request (or a tool's args/result), flattened to text.
34export type InputPart = { role: string; kind: string; text: string }
35
36// The full payload behind a row, kept for the latest rows only.
37export type Payload = { parts: InputPart[]; omitted: number; output?: string; thinking?: string }
38
39export type SystemSection = { id: string; scope: string; text: string }
40
41export type SystemSnapshot = { id: string; at: number; model: string; tools: string[]; sections: SystemSection[] }
42
43declare module 'claude-code' {
44 interface PluginState {
45 'agent-trace': {
46 events: TraceEvent[]
47 mode: TraceMode
48 query: string
49 selected: string
50 tab: DetailTab
51 payloads: Record<string, Payload>
52 systems: SystemSnapshot[]
53 openParts: string[]
54 toggled: string[]
55 rowLimit: number
56 toolFilter: string
57 showStats: boolean
58 }
59 }
60}
61