SLOPSHOPPER

agent-trace

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

paneguardcommandprompt
v0.5.0MITupdated 2026-10-23jasoncai0/claude-agent-trace
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-trace
│ ┃ 轨迹 ✕ › fix the failing auth test and add an audit log call │ ┃ [ ⏱ 时长 ] [ 轮次 ] [ 调用 ] [ 工具统计 ] [ │ ┃ ● agent-trace: agent-trace: write failed: TypeError: undefined is not │ ┃ 搜索工具、参数、输出、回复… ⏎ 搜索 ● agent-trace: agent-trace: payload failed: TypeError: undefined is n │ ┃ ⏺ Read(src/auth.ts) │ ┃ ▣ client module ./timeline.tsx ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▾ ✕ 用户 #1 fix the failing auth test 1 步 · ⎿ Added 2 lines, removed 1 line │ ┃ ▾ ✕ 助手 生成中… +0 输入 08:53:20 340 ⏺ Bash(bun test) │ ┃ 工具 undefined /work/app/src/auth.ts → 运行 ⎿ 3 pass, 1 fail │ ┃ 工具 Read /work/app/src/auth.ts → import { │ ┃ 工具 Grep refresh\( in /work/app/src → src ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 工具 Edit /work/app/src/auth.ts → The file │ ┃ 工具 Write /work/app/src/audit.ts → File c ✻ Worked for 42s · done 4:20 PM │ ┃ 工具 Write /work/app/src/cache.ts → File c │ ┃ ✕ 错误 Bash bun test → src/auth.test.ts: ( › /agent-trace │ ┃ 工具 Bash git status --porcelain → M src/a ⎿ agent-trace: 轨迹面板已打开:12 条记录,最新一条在 20ms 前。 │ ┃ 工具 Bash rm -rf build && git push --force │ ┃ 工具 Bash cat .env → DATABASE_URL=postgres │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 轨迹
[ ⏱ 时长 ] [ 轮次 ] [ 调用 ] [ 工具统计 ] [ 系统提示 ] ↑ 顶部 搜索工具、参数、输出、回复… ⏎ 搜索 ▣ client module ./timeline.tsx ▾ ✕ 用户 #1 fix the failing auth test 1 步 · 10 工具 08:53:2 ▾ ✕ 助手 生成中… +0 输入 08:53:20 340 工具 undefined /work/app/src/auth.ts → 运行中… 工具 Read /work/app/src/auth.ts → import { verify } from ' 工具 Grep refresh\( in /work/app/src → src/api.ts:12 (+1 行 工具 Edit /work/app/src/auth.ts → The file /work/app/src/a 工具 Write /work/app/src/audit.ts → File created successfu 工具 Write /work/app/src/cache.ts → File created successfu ✕ 错误 Bash bun test → src/auth.test.ts: (+7 行) 08:53:20 工具 Bash git status --porcelain → M src/auth.ts (+1 行) 0 工具 Bash rm -rf build && git push --force origin main → ( 工具 Bash cat .env → DATABASE_URL=postgres://app:hunter2@d
README

agent-trace

A Claude Code mod that adds a 轨迹 (trace) pane: see what the agent did in this session, step by step.

  • Langfuse-style tree: turns → model steps → the tool calls each step asked for → subagent runs nested under the Agent call that spawned them. ▸/▾ folds a node; the latest 2 turns start open, subagent runs folded. Each row carries a small latency bar scaled to its turn.
  • Status at a glance: ◌ running, ⊘ interrupted (cut off in an earlier turn), ⊖ denied, ✕ failed; a folded parent shows the worst status under it.
  • What the model saw: open a model step for its request: overview (source, status, tokens, cache hit, thinking + reply preview, request timing: start, total, time to first token, generation, tok/s), the messages this step added since the previous request, the system prompt (every section + tool list), and the raw record.
  • Flags: cache hit per step (yellow under 50%), ⚠ when uncached input jumps 3× against the loop's previous step, long durations yellow (≥10s) / red (≥1m).
  • Tool stats: count, total, average and failures per tool; click one to show only that tool's calls.
  • Clickable timeline: 3 lanes (input / model / tool) with time ticks and span; hover names a bar, a click opens its folds and jumps to its row.
  • Native scrolling: the newest 80 rows are drawn, more load a page at a time from the top (up to 160, as Claude Desktop drops a very large pane tree).
  • Search with highlighted matches, 清空 (clear), and history from before the mod loaded rebuilt from the transcript (without durations).

Open it with /agent-trace.

Install

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.

Options

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

How it records

LaneEvent
输入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.

Develop

claude plugin validate .
claude plugin test .

License

MIT

Source 10 files
hooks/register.tsx 221 lines
1// 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}
221
hooks/context.ts 100 lines
1import 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)
100
hooks/pane.tsx 837 lines
1import { 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}
837
hooks/rows.ts 108 lines
1import 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}
108
hooks/trace.ts 287 lines
1import 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 => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[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}
287
hooks/status.ts 22 lines
1import 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))])
22
hooks/jump.ts 34 lines
1import { 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}
34
hooks/tree.ts 124 lines
1import 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  })
124
hooks/timeline.tsx 98 lines
1import 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
98
types/index.d.ts 61 lines
1export 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