A Hermes-style trajectory pane: every system, user, context, assistant and tool row of the session on one timeline, with a minimap and search

A Claude Code mod that shows the trajectory of a session in a pane, in the style of DeepSeek Hermes' Trajectory view: every system prompt, user prompt, injected context, assistant reply, thinking block and tool call on one timeline.
Duration Turns Calls role: all 42 rows ▲ 12 earlier latest ▼
[Search ]
Input ⣿⣿ ⣿ ⣿
Model ⣿⣿ ⣿⣿ ⣿⣿
Tools ⣿⣿⣿ ⣿⣿⣿⣿
T2 ASSISTANT asst Let me list the config files first.
CONTEXT user [nested_memory] <system-reminder>…
▶ TOOL asst Bash {"command":"ls -la"} 1.2s → package.json
TOOL Bash · role: assistant · response · Turn 2 · 01:12:03 · 1.2s · 5 lines · ≈8 tok y: copy k: prev j: next p: ▲ n: ▼
{
"command": "ls -la"
}
→ package.json
3 turns · 12 steps · 30 calls · 2.1M tok · cache hit 96% · 4.2m
At the prompt of a terminal session:
/plugin install trajectory --marketplace him0/claude-trajectory
Answer y to add the marketplace, then pick a scope.
From a shell:
claude plugin install trajectory --marketplace him0/claude-trajectory
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with him0/claude-trajectory, then Add plugin.
Run /trajectory to open the pane.
T1, T2, … on the turn's first row), a colored badge (SYSTEM, USER, CONTEXT, ASSISTANT, THINKING, TOOL) and the role a request carries it under (sys, user, asst, or — for a row the model never reads, drawn dim). Tool rows show the input, how long the call took and its result.d, wall-clock), Turns (t) or Calls (c, one slot per row).role: all cycles through system, user, assistant and none (not sent to the model).y copies it). The header shows its role (or that it is not sent to the model), the door it came in by, its turn, time, duration, line count and an estimated token count.Keys, once the pane has focus (ctrl+x tab):
| Key | Action |
|---|---|
k / j | previous / next row |
p / n | scroll the detail up / down |
g | back to the newest rows |
y | copy the selected row to the clipboard |
r | cycle the role filter |
d / t / c | minimap by duration / turns / calls |
CONTEXT row at turn 0 shows the CLAUDE.md files and rules the first user message carries, as sent.CONTEXT row each, including those joined onto the end of a tool result.prompt.compose and prompt.context hooks do not run: the system prompt is then composed when /trajectory opens the pane, and before any request is sent, the CLAUDE.md files /context lists are read from disk (as they are now, which may differ from what will be sent)./clear starts the timeline over.hooks/register.tsx 957 lines1import { atom, read, update } from 'claude-code'
2import type {
3 EngineInterface,
4 InstructionFile,
5 PromptComposeSection,
6 PromptContextBlock,
7 Register,
8 RenderSurface,
9 ThemeKey,
10} from 'claude-code'
11
12import type {
13 TrajectoryKind,
14 TrajectoryMeta,
15 TrajectoryMode,
16 TrajectoryRole,
17 TrajectoryRoleFilter,
18 TrajectoryRow,
19 TrajectoryStep,
20 TrajectorySystemPrompt,
21 TrajectoryUserContext,
22} from '../types'
23
24const PANE = 'trajectory'
25const MAX_ROWS = 1000
26const MAX_TEXT = 20_000
27// A drawn tree shows its first 100,000 characters: leave room around the prompt.
28const MAX_SYSTEM_TEXT = 80_000
29// Header, search, minimap with its margins, divider and footer.
30const CHROME_ROWS = 10
31const GUTTER = 5
32const BADGE = 10
33const MINIMAP_LABEL = 7
34const MARK = '⣿'
35
36const rows = atom({ plugin: 'trajectory', key: 'rows' } as const, [] as TrajectoryRow[])
37const steps = atom({ plugin: 'trajectory', key: 'steps' } as const, [] as TrajectoryStep[])
38const meta = atom({ plugin: 'trajectory', key: 'meta' } as const, {
39 startAt: 0,
40 turns: 0,
41 turnStartAt: 0,
42} as TrajectoryMeta)
43const mode = atom({ plugin: 'trajectory', key: 'mode' } as const, 'calls' as TrajectoryMode)
44const query = atom({ plugin: 'trajectory', key: 'query' } as const, '')
45const roleFilter = atom({ plugin: 'trajectory', key: 'roleFilter' } as const, 'all' as TrajectoryRoleFilter)
46const selected = atom({ plugin: 'trajectory', key: 'selected' } as const, '')
47const listTop = atom({ plugin: 'trajectory', key: 'listTop' } as const, -1)
48const detailTop = atom({ plugin: 'trajectory', key: 'detailTop' } as const, 0)
49// Kept apart from the rows: the engine may compose the prompt before
50// session.start or not again after /clear, and a reset must not lose it.
51const systemPrompt = atom(
52 { plugin: 'trajectory', key: 'systemPrompt' } as const,
53 null as TrajectorySystemPrompt | null,
54)
55// The context blocks (CLAUDE.md among them) the first user message carries:
56// the engine prepends them at request time, so no session.append brings them.
57const userContext = atom(
58 { plugin: 'trajectory', key: 'userContext' } as const,
59 null as TrajectoryUserContext | null,
60)
61// The reminders already made rows, by where they stand in the conversation:
62// injected at request time, they reach no session.append.
63const seenReminders = atom({ plugin: 'trajectory', key: 'seenReminders' } as const, [] as string[])
64
65const COLORS: Record<TrajectoryKind, ThemeKey> = {
66 system: 'inactive',
67 user: 'suggestion',
68 context: 'success',
69 assistant: 'merged',
70 thinking: 'subtle',
71 tool: 'warning',
72}
73
74const ROLE_FILTERS: TrajectoryRoleFilter[] = ['all', 'system', 'user', 'assistant', 'none']
75const ROLE_WIDTH = 5
76const roleLabelOf = (role: TrajectoryRole | undefined) =>
77 role === 'system' ? 'sys' : role === 'user' ? 'user' : role === 'assistant' ? 'asst' : role === 'none' ? '—' : '?'
78
79type Block = { type: string; [field: string]: unknown }
80
81const clip = (text: string, max = MAX_TEXT) =>
82 text.length > max ? `${text.slice(0, max)}… (${text.length - max} more chars)` : text
83
84const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
85
86// East Asian wide characters take two cells in a terminal.
87const cellsOf = (char: string) => {
88 const c = char.codePointAt(0) ?? 0
89 const isWide =
90 (c >= 0x1100 && c <= 0x115f) ||
91 (c >= 0x2e80 && c <= 0xa4cf) ||
92 (c >= 0xac00 && c <= 0xd7a3) ||
93 (c >= 0xf900 && c <= 0xfaff) ||
94 (c >= 0xfe30 && c <= 0xfe4f) ||
95 (c >= 0xff00 && c <= 0xff60) ||
96 (c >= 0xffe0 && c <= 0xffe6) ||
97 (c >= 0x1f300 && c <= 0x1faff) ||
98 c >= 0x20000
99
100 return isWide ? 2 : 1
101}
102
103const fit = (text: string, width: number) => {
104 if (width <= 0) return ''
105 let used = 0
106 let out = ''
107 for (const char of text) {
108 const cells = cellsOf(char)
109 if (used + cells > width - 1) {
110 const rest = [...text].slice([...out].length)
111 if (rest.reduce((n, c) => n + cellsOf(c), 0) + used <= width) return out + rest.join('')
112
113 return `${out}…`
114 }
115 out += char
116 used += cells
117 }
118
119 return out
120}
121
122const textOf = (content: unknown): string => {
123 if (typeof content === 'string') return content
124 if (!Array.isArray(content)) return ''
125
126 return content
127 .map(block => {
128 const b = block as Block
129 if (b.type === 'text') return String(b.text ?? '')
130 if (b.type === 'image') return '[image]'
131 if (b.type === 'document') return '[document]'
132
133 return ''
134 })
135 .filter(Boolean)
136 .join('\n')
137}
138
139const formatTokens = (n: number) =>
140 n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`
141
142const formatMs = (ms: number) =>
143 ms >= 60_000 ? `${(ms / 60_000).toFixed(1)}m` : ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
144
145const systemRow = (at: number, prompt: TrajectorySystemPrompt | null): TrajectoryRow => ({
146 id: 'system',
147 kind: 'system',
148 role: 'system',
149 turn: 0,
150 at,
151 text: 'Initial System Prompt',
152 ...prompt,
153})
154
155// Keeps the first prompt composed, and fills the system row if it is still bare.
156async function recordSystemPrompt($: EngineInterface, sections: readonly PromptComposeSection[], model: string) {
157 let prompt = await read($, systemPrompt)
158 if (prompt === null) {
159 const chars = sections.reduce((n, s) => n + s.text.length, 0)
160 const body = sections.map(s => `## ${s.id} (${s.scope})\n${s.text}`).join('\n\n')
161 const first = {
162 title: `Initial System Prompt · ${sections.length} sections · ${formatTokens(chars)} chars · ${model}`,
163 text: clip(body, MAX_SYSTEM_TEXT),
164 }
165 prompt = first
166 await update($, systemPrompt, () => first)
167 }
168 const list = await read($, rows)
169 if (list.some(row => row.id === 'system' && row.title === undefined)) {
170 const shown = prompt
171 await update($, rows, all => all.map(row => (row.id === 'system' ? { ...row, ...shown, result: undefined } : row)))
172 }
173}
174
175const contextRow = (at: number, context: TrajectoryUserContext): TrajectoryRow => ({
176 id: 'context',
177 kind: 'context',
178 role: 'user',
179 source: 'context',
180 turn: 0,
181 at,
182 ...context,
183})
184
185// Rows from the reset start with the system row, then the context row once known.
186const openingRows = (at: number, prompt: TrajectorySystemPrompt | null, context: TrajectoryUserContext | null) =>
187 context ? [systemRow(at, prompt), contextRow(at, context)] : [systemRow(at, prompt)]
188
189const filesLine = (files: readonly { path: string; kind: string }[]) =>
190 files.map(f => `- ${f.path} (${f.kind})`).join('\n')
191
192// Keeps the latest context the engine computed (it computes it again after /clear
193// or a compaction), and puts its row after the system row.
194async function recordUserContext($: EngineInterface, context: TrajectoryUserContext) {
195 await update($, userContext, () => context)
196 const at = await $.clock.now()
197 await update($, rows, list => {
198 if (list.some(row => row.id === 'context')) {
199 return list.map(row => (row.id === 'context' ? { ...row, ...context } : row))
200 }
201 const after = list.findIndex(row => row.id === 'system') + 1
202 if (list.length === 0) return list
203
204 return [...list.slice(0, after), contextRow(at, context), ...list.slice(after)].slice(-MAX_ROWS)
205 })
206}
207
208function contextOfBlocks(blocks: readonly PromptContextBlock[], files: readonly InstructionFile[] | undefined) {
209 const shown = blocks.filter(b => b.text.trim() !== '')
210 const names = shown.map(b =>
211 b.name === 'claudeMd' && files ? `${b.name} (${files.length} files)` : b.name,
212 )
213 const body = [
214 ...(files && files.length > 0 ? [`# instruction files\n${filesLine(files)}`] : []),
215 ...shown.map(b => `# ${b.name}\n${b.text}`),
216 ].join('\n\n')
217
218 return {
219 title: `User Context · ${names.join(' · ') || 'no blocks'}`,
220 text: clip(body, MAX_SYSTEM_TEXT),
221 }
222}
223
224// Where no prompt.context hook ran: the memory files /context lists, read from
225// disk now, which may differ from what the session was sent.
226async function contextFromDisk($: EngineInterface): Promise<TrajectoryUserContext | null> {
227 const usage = await $.session.usage({ breakdown: 'summary' })
228 const files = usage.context.breakdown?.memoryFiles ?? []
229 if (files.length === 0) return null
230 const texts = await Promise.all(
231 files.map(async f => {
232 const content = await $.fs.read(f.path).catch(() => '(could not read)')
233
234 return `## ${f.path} (${f.type})\n${typeof content === 'string' ? content : '(binary)'}`
235 }),
236 )
237 const listed = files.map(f => ({ path: f.path, kind: f.type }))
238
239 return {
240 title: `User Context · claudeMd (${files.length} files, read at open)`,
241 text: clip(`# instruction files\n${filesLine(listed)}\n\n# claudeMd\n${texts.join('\n\n')}`, MAX_SYSTEM_TEXT),
242 }
243}
244
245const REMINDER = '<system-reminder>'
246// How the engine opens the block that carries the CLAUDE.md files and rules.
247const INSTRUCTIONS = 'Codebase and user instructions are shown below'
248const MAX_SEEN = 5000
249
250const hashOf = (text: string) => {
251 let h = 5381
252 for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0
253
254 return `${(h >>> 0).toString(36)}.${text.length}`
255}
256
257// The instruction files as the request carried them, in place of the disk's.
258function contextOfInstructions(text: string): TrajectoryUserContext {
259 const files = [...text.matchAll(/^Contents of (.+?)(?: \(([^)]*)\))?:$/gm)].map(m => ({
260 path: m[1] ?? '',
261 kind: m[2] ?? 'project',
262 }))
263
264 return {
265 title: `User Context · claudeMd (${files.length} files, as sent)`,
266 text: clip(`# instruction files\n${filesLine(files)}\n\n# claudeMd\n${text}`, MAX_SYSTEM_TEXT),
267 }
268}
269
270// A reminder is a text block of its own, or is joined onto the end of the
271// last tool result (a nested CLAUDE.md, a path-scoped rule a Read brought).
272function remindersOf(block: { type: string; [field: string]: unknown }): string[] {
273 if (block.type === 'text') {
274 const text = typeof block.text === 'string' ? block.text : ''
275
276 return text.trimStart().startsWith(REMINDER) ? [text] : []
277 }
278 if (block.type !== 'tool_result') return []
279
280 return [...textOf(block.content).matchAll(/<system-reminder>[\s\S]*?<\/system-reminder>/g)].map(m => m[0])
281}
282
283// Makes rows of the reminders the request about to be sent carries: the
284// trailing user messages, or with `isWhole` the whole conversation.
285async function scanRequest($: EngineInterface, isWhole: boolean) {
286 const messages = await $.session.messages({ as: 'api' })
287 const lastReply = messages.findLastIndex(m => m.role === 'assistant')
288 const found: { key: string; text: string }[] = []
289 let replies = messages.slice(0, isWhole ? 0 : lastReply + 1).filter(m => m.role === 'assistant').length
290 messages.slice(isWhole ? 0 : lastReply + 1).forEach(m => {
291 if (m.role === 'assistant') {
292 replies++
293 return
294 }
295 const counts = new Map<string, number>()
296 for (const text of m.content.flatMap(remindersOf)) {
297 const hash = hashOf(text)
298 const n = (counts.get(hash) ?? 0) + 1
299 counts.set(hash, n)
300 found.push({ key: `${replies}:${hash}:${n}`, text })
301 }
302 })
303 if (found.length === 0) return
304
305 let fresh: typeof found = []
306 await update($, seenReminders, seen => {
307 const known = new Set(seen)
308 fresh = found.filter(f => !known.has(f.key))
309
310 return [...seen, ...fresh.map(f => f.key)].slice(-MAX_SEEN)
311 })
312 const instructions = fresh.find(f => f.text.includes(INSTRUCTIONS))
313 if (instructions) await recordUserContext($, contextOfInstructions(instructions.text))
314
315 const [{ turns }, at] = await Promise.all([read($, meta), $.clock.now()])
316 await push(
317 $,
318 fresh
319 .filter(f => f !== instructions)
320 .map(f => ({
321 id: `request:${f.key}`,
322 kind: 'context' as const,
323 role: 'user' as const,
324 source: 'request',
325 turn: turns,
326 at,
327 text: clip(f.text),
328 })),
329 )
330}
331
332async function push($: EngineInterface, added: TrajectoryRow[]) {
333 if (added.length === 0) return
334 await update($, rows, list => [...list, ...added].slice(-MAX_ROWS))
335}
336
337async function reset($: EngineInterface) {
338 const [now, prompt, context] = await Promise.all([$.clock.now(), read($, systemPrompt), read($, userContext)])
339 await update($, rows, () => openingRows(now, prompt, context))
340 await update($, steps, () => [])
341 await update($, seenReminders, () => [])
342 await update($, meta, () => ({ startAt: now, turns: 0, turnStartAt: now }))
343 await update($, selected, () => '')
344 await update($, listTop, () => -1)
345 await update($, detailTop, () => 0)
346}
347
348function matchesOf(list: TrajectoryRow[], q: string, role: TrajectoryRoleFilter) {
349 const needle = q.trim().toLowerCase()
350 const byRole = role === 'all' ? list : list.filter(row => row.role === role)
351 if (!needle) return byRole
352
353 return byRole.filter(row =>
354 [row.kind, row.tool ?? '', row.text, row.result ?? ''].some(s => s.toLowerCase().includes(needle)),
355 )
356}
357
358// Breaks text into lines of at most `width` cells, keeping its own newlines.
359function wrapLines(text: string, width: number) {
360 const out: string[] = []
361 for (const para of text.split('\n')) {
362 let line = ''
363 let used = 0
364 for (const char of para.replace(/\t/g, ' ')) {
365 const cells = cellsOf(char)
366 if (used + cells > width) {
367 out.push(line)
368 line = ''
369 used = 0
370 }
371 line += char
372 used += cells
373 }
374 out.push(line)
375 }
376
377 return out
378}
379
380const topOf = (top: number, count: number, room: number) =>
381 top < 0 ? Math.max(0, count - room) : Math.min(top, Math.max(0, count - room))
382
383async function moveSelection($: EngineInterface, delta: number, room: number) {
384 const [list, q, role, current, top] = await Promise.all([
385 read($, rows),
386 read($, query),
387 read($, roleFilter),
388 read($, selected),
389 read($, listTop),
390 ])
391 const matches = matchesOf(list, q, role)
392 if (matches.length === 0) return
393 const at = matches.findIndex(row => row.id === current)
394 const index = at < 0 ? matches.length - 1 : Math.min(matches.length - 1, Math.max(0, at + delta))
395 const shownTop = topOf(top, matches.length, room)
396 const nextTop = index < shownTop ? index : index >= shownTop + room ? index - room + 1 : shownTop
397 await update($, selected, () => matches[index]?.id ?? '')
398 await update($, detailTop, () => 0)
399 await update($, listTop, () => (nextTop >= matches.length - room ? -1 : nextTop))
400}
401
402// Rows a wheel tick moves the list or the detail.
403const WHEEL_STEP = 3
404
405// The halves' sizes as last drawn, for the wheel to tell which one it is over.
406// Only a hint: a reload starts it over and the next draw sets it again.
407let drawn = { listRows: 0, detailRows: 0, maxLineTop: 0 }
408
409async function scrollDetail($: EngineInterface, delta: number, maxLineTop: number) {
410 await update($, detailTop, t => Math.min(maxLineTop, Math.max(0, Math.min(t, maxLineTop) + delta)))
411}
412
413async function scrollList($: EngineInterface, delta: number, room: number) {
414 const [list, q, role, top] = await Promise.all([read($, rows), read($, query), read($, roleFilter), read($, listTop)])
415 const count = matchesOf(list, q, role).length
416 const next = Math.max(0, topOf(top, count, room) + delta)
417 await update($, listTop, () => (next >= count - room ? -1 : next))
418}
419
420async function cycleRoleFilter($: EngineInterface) {
421 await update($, roleFilter, was => ROLE_FILTERS[(ROLE_FILTERS.indexOf(was) + 1) % ROLE_FILTERS.length] ?? 'all')
422 await update($, listTop, () => -1)
423}
424
425// JSON indented for reading; anything else, a clipped JSON among it, as it is.
426const prettyOf = (text: string) => {
427 const trimmed = text.trim()
428 if (!/^[[{]/.test(trimmed)) return text
429 try {
430 return JSON.stringify(JSON.parse(trimmed), null, 2)
431 } catch {
432 return text
433 }
434}
435
436// A row's text and result as the detail half shows them: a tool's pretty-printed.
437const shownOf = (row: TrajectoryRow) =>
438 row.kind === 'tool'
439 ? { text: prettyOf(row.text), result: row.result !== undefined ? prettyOf(row.result) : undefined }
440 : { text: row.text, result: row.result }
441
442// What the copy key puts on the clipboard: the row as the detail half shows it.
443const copyTextOf = (row: TrajectoryRow) => {
444 const { text, result } = shownOf(row)
445 const head = row.tool ? `${row.tool} ${text}` : text
446
447 return result !== undefined ? `${head}\n\n→ ${result}` : head
448}
449
450async function copyRow($: EngineInterface, row: TrajectoryRow, surface: RenderSurface) {
451 const text = copyTextOf(row)
452 const copied = await $.ui.copy({ text, surface })
453 await $.ui.toast(
454 copied.isCopied
455 ? `Copied ${row.kind.toUpperCase()} (${formatTokens(text.length)} chars)`
456 : `Could not copy: ${copied.reason}`,
457 )
458}
459
460// A local estimate, as no token-count API reaches a mod: about four ASCII
461// characters to a token, and about one token for each other character.
462const estimateTokens = (text: string) => {
463 let ascii = 0
464 let other = 0
465 for (const char of text) {
466 if ((char.codePointAt(0) ?? 0) < 0x80) ascii++
467 else other++
468 }
469
470 return Math.ceil(ascii / 4 + other)
471}
472
473const clockOf = (ms: number) => {
474 const d = new Date(ms)
475
476 return [d.getHours(), d.getMinutes(), d.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
477}
478
479export const register: Register = on => {
480 on('session.start', async ($, e, next) => {
481 await $.command.register({
482 name: 'trajectory',
483 description: 'Show the trajectory of this session (every prompt, context, reply and tool call) in a pane',
484 })
485 // A reload runs session.start again: keep what the session already recorded.
486 if ((await read($, rows)).length === 0) await reset($)
487
488 return next(e)
489 })
490
491 on('session.end', async ($, e, next) => {
492 if (e.reason === 'clear') await reset($)
493
494 return next(e)
495 })
496
497 on('command.run', { command: 'trajectory' }, async $ => {
498 // Where an organization manages Claude Code, a plugin the person installs
499 // is passed over at prompt.compose: ask the engine for the prompt instead.
500 if ((await read($, systemPrompt)) === null) {
501 const [composed, model] = await Promise.all([$.prompt.compose(), $.session.model()])
502 await recordSystemPrompt($, composed.sections, model)
503 }
504 // The same holds for prompt.context and prompt.attachment: read the
505 // conversation as sent, and before any request the memory files on disk.
506 await scanRequest($, true).catch(() => undefined)
507 if ((await read($, userContext)) === null) {
508 const context = await contextFromDisk($).catch(() => null)
509 if (context) await recordUserContext($, context)
510 }
511 await $.ui.open({ id: PANE, title: 'Trajectory' })
512
513 return { text: 'Trajectory pane opened.' }
514 })
515
516 on('prompt.compose', async ($, e, next) => {
517 const composed = await next(e)
518 await recordSystemPrompt($, composed.sections, e.model)
519
520 return composed
521 })
522
523 on('prompt.context', async ($, e, next) => {
524 const result = await next(e)
525 await recordUserContext($, contextOfBlocks(result.blocks, result.instructionFiles ?? e.instructionFiles))
526
527 return result
528 })
529
530 on('turn.start', async ($, e, next) => {
531 if (e.agentId === undefined) {
532 const now = await $.clock.now()
533 await update($, meta, m => ({ ...m, turns: m.turns + 1, turnStartAt: now }))
534 }
535
536 return next(e)
537 })
538
539 on('turn.step', async function* ($, e, next) {
540 if (e.agentId !== undefined) return yield* next(e)
541
542 await scanRequest($, false).catch(() => undefined)
543 const { turns } = await read($, meta)
544 const startAt = await $.clock.now()
545 const step: TrajectoryStep = {
546 turn: turns,
547 startAt,
548 model: e.model,
549 input: 0,
550 output: 0,
551 cacheRead: 0,
552 cacheWrite: 0,
553 }
554 await update($, steps, list => [...list, step].slice(-MAX_ROWS))
555
556 const result = yield* next(e)
557
558 const endAt = await $.clock.now()
559 const usage = result.usage
560 await update($, steps, list =>
561 list.map(one =>
562 one.startAt === startAt && one.turn === turns
563 ? {
564 ...one,
565 endAt,
566 model: usage?.model ?? one.model,
567 input: usage?.input_tokens ?? 0,
568 output: usage?.output_tokens ?? 0,
569 cacheRead: usage?.cache_read_input_tokens ?? 0,
570 cacheWrite: usage?.cache_creation_input_tokens ?? 0,
571 }
572 : one,
573 ),
574 )
575
576 return result
577 })
578
579 on('session.append', async ($, e, next) => {
580 const stored = await next(e)
581 if (e.agentId !== undefined || e.door === 'queue' || stored.deny !== undefined) return stored
582
583 const { message } = stored
584 const [{ turns }, at] = await Promise.all([read($, meta), $.clock.now()])
585 const base = {
586 turn: turns,
587 at,
588 role: message.role ?? ('none' as const),
589 source: message.name ? `${e.door}:${message.name}` : e.door,
590 }
591 const blocks = message.content as Block[]
592
593 if (e.door === 'tool-result') {
594 const results = blocks.filter(b => b.type === 'tool_result')
595 if (results.length > 0) {
596 const byId = new Map(results.map(b => [String(b.tool_use_id), b]))
597 await update($, rows, list =>
598 list.map(row => {
599 const b = byId.get(row.id)
600 if (!b) return row
601
602 return {
603 ...row,
604 endAt: row.endAt ?? at,
605 result: clip(textOf(b.content)),
606 isError: b.is_error === true,
607 }
608 }),
609 )
610
611 return stored
612 }
613 }
614
615 if (message.type === 'assistant') {
616 const added: TrajectoryRow[] = []
617 blocks.forEach((b, i) => {
618 if (b.type === 'text' && String(b.text ?? '').trim() !== '') {
619 added.push({ ...base, id: `${stored.uuid}:${i}`, kind: 'assistant', text: clip(String(b.text)) })
620 } else if (b.type === 'thinking' && String(b.thinking ?? '').trim() !== '') {
621 added.push({ ...base, id: `${stored.uuid}:${i}`, kind: 'thinking', text: clip(String(b.thinking)) })
622 } else if (b.type === 'tool_use') {
623 added.push({
624 ...base,
625 id: String(b.id),
626 kind: 'tool',
627 tool: String(b.name),
628 text: clip(JSON.stringify(b.input ?? {})),
629 })
630 }
631 })
632 await push($, added)
633
634 return stored
635 }
636
637 const text = textOf(message.content)
638 if (text.trim() === '') return stored
639
640 const isUser = (e.door === 'prompt' || e.door === 'command') && message.isMeta !== true
641 const kind: TrajectoryKind = isUser
642 ? 'user'
643 : e.door === 'notice' || e.door === 'compaction'
644 ? 'system'
645 : 'context'
646 const label = message.name && !isUser ? `[${message.name}] ` : ''
647 await push($, [{ ...base, id: stored.uuid, kind, text: clip(label + text) }])
648
649 return stored
650 })
651
652 on('tool.call', async ($, e, next) => {
653 const startAt = await $.clock.now()
654 await update($, rows, list => list.map(row => (row.id === e.tool_use_id ? { ...row, at: startAt } : row)))
655 const ran = await next(e)
656 const endAt = await $.clock.now()
657 await update($, rows, list => list.map(row => (row.id === e.tool_use_id ? { ...row, endAt } : row)))
658
659 return ran
660 })
661
662 // The pane draws its own two windows, so the wheel moves them, never the body.
663 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
664 const { listRows, detailRows, maxLineTop } = drawn
665 if (listRows === 0) return {}
666 // The search field's height is the surface's: what the tree has beyond the rest.
667 const searchRows = Math.max(0, e.contentRows - (CHROME_ROWS - 2 + listRows + detailRows))
668 const detailStart = 1 + searchRows + 5 + listRows + 1
669 const delta = e.pointer ? e.by * WHEEL_STEP : e.by
670 if (e.pointer && e.pointer.row >= detailStart) await scrollDetail($, delta, maxLineTop)
671 else await scrollList($, delta, listRows)
672
673 return {}
674 })
675
676 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
677 const elements = $.ui.resolve(e)
678 const { Box, Text, Button } = elements
679 // Mobile draws no text field: search is left out there.
680 const Input = 'Input' in elements ? elements.Input : undefined
681 const [list, stepList, m, currentMode, q, currentRole, current, top, lineTop, now] = await Promise.all([
682 read($, rows),
683 read($, steps),
684 read($, meta),
685 read($, mode),
686 read($, query),
687 read($, roleFilter),
688 read($, selected),
689 read($, listTop),
690 read($, detailTop),
691 $.clock.now(),
692 ])
693 const width = Math.max(40, e.props.bodyColumns)
694
695 // Minimap: each lane is a row of cells, each cell the color of what sits there.
696 const lanes = { Input: [] as (ThemeKey | undefined)[], Model: [] as (ThemeKey | undefined)[], Tools: [] as (ThemeKey | undefined)[] }
697 const cols = Math.max(10, width - MINIMAP_LABEL)
698 for (const lane of Object.values(lanes)) lane.length = cols
699 const laneOf = (kind: TrajectoryKind) =>
700 kind === 'tool' ? lanes.Tools : kind === 'assistant' || kind === 'thinking' ? lanes.Model : lanes.Input
701 const paint = (lane: (ThemeKey | undefined)[], from: number, to: number, color: ThemeKey) => {
702 const a = Math.min(cols - 1, Math.max(0, Math.floor(from * cols)))
703 const b = Math.min(cols - 1, Math.max(a, Math.ceil(to * cols) - 1))
704 for (let i = a; i <= b; i++) lane[i] = color
705 }
706
707 if (currentMode === 'duration') {
708 const span = Math.max(1, now - m.startAt)
709 const x = (t: number) => (t - m.startAt) / span
710 for (const row of list) {
711 if (row.kind === 'assistant' || row.kind === 'thinking') continue
712 paint(laneOf(row.kind), x(row.at), x(row.endAt ?? row.at), COLORS[row.kind])
713 }
714 for (const step of stepList) paint(lanes.Model, x(step.startAt), x(step.endAt ?? now), COLORS.assistant)
715 } else if (currentMode === 'turns') {
716 const buckets = m.turns + 1
717 const perTurn = new Map<number, TrajectoryRow[]>()
718 for (const row of list) perTurn.set(row.turn, [...(perTurn.get(row.turn) ?? []), row])
719 for (const [turn, inTurn] of perTurn) {
720 inTurn.forEach((row, j) => {
721 const from = (turn + j / inTurn.length) / buckets
722 paint(laneOf(row.kind), from, from + 0.5 / (buckets * inTurn.length), COLORS[row.kind])
723 })
724 }
725 } else {
726 // Calls: one slot per row, with a gap between slots when there is room.
727 const slot = cols / Math.max(1, list.length)
728 list.forEach((row, i) => {
729 const from = (i * slot) / cols
730 paint(laneOf(row.kind), from, from + Math.max(1, slot - 1) / cols, COLORS[row.kind])
731 })
732 }
733
734 const drawLane = (name: string, lane: (ThemeKey | undefined)[]) => {
735 const runs: { color?: ThemeKey; text: string }[] = []
736 for (const color of lane) {
737 const last = runs[runs.length - 1]
738 const char = color ? MARK : ' '
739 if (last && last.color === color) last.text += char
740 else runs.push({ color, text: char })
741 }
742
743 return (
744 <Box key={`lane-${name}`} flexDirection="row">
745 <Text dimColor>{name.padEnd(MINIMAP_LABEL)}</Text>
746 {runs.map(run => (run.color ? <Text color={run.color}>{run.text}</Text> : <Text>{run.text}</Text>))}
747 </Box>
748 )
749 }
750
751 // The body splits in two: the list of rows above, the selected row's detail below.
752 const room = Math.max(6, (e.props.scroll?.bodyRows ?? 30) - CHROME_ROWS)
753 const listRows = Math.ceil(room / 2)
754 const detailRows = room - listRows
755
756 const needle = q.trim().toLowerCase()
757 const matches = matchesOf(list, q, currentRole)
758 const shownTop = topOf(top, matches.length, listRows)
759 const shown = matches.slice(shownTop, shownTop + listRows)
760 const textWidth = width - GUTTER - BADGE - ROLE_WIDTH - 1
761
762 const summaryOf = (row: TrajectoryRow) => {
763 if (row.title !== undefined) return row.title
764 if (row.kind !== 'tool') return oneLine(row.text)
765 const input = fit(oneLine(row.text), Math.max(12, Math.floor(textWidth / 3)))
766 const took = row.endAt !== undefined ? ` ${formatMs(row.endAt - row.at)}` : ' …'
767 const result = row.result !== undefined ? ` → ${oneLine(row.result)}` : ''
768
769 return `${row.tool} ${input}${took}${result}`
770 }
771 const badgeOf = (row: TrajectoryRow) => (row.kind === 'tool' && row.isError ? 'TOOL ✗' : row.kind.toUpperCase())
772 const colorOf = (row: TrajectoryRow): ThemeKey => (row.kind === 'tool' && row.isError ? 'error' : COLORS[row.kind])
773
774 let lastTurn = shownTop > 0 ? (matches[shownTop - 1]?.turn ?? -1) : -1
775 const lines = shown.map(row => {
776 const gutter = row.turn !== lastTurn && row.turn > 0 ? `T${row.turn}` : ''
777 lastTurn = row.turn
778 const isSelected = current === row.id
779
780 return (
781 <Button
782 key={`press-${row.id}`}
783 plain
784 onPress={async () => {
785 await update($, selected, was => (was === row.id ? '' : row.id))
786 await update($, detailTop, () => 0)
787 }}
788 >
789 <Text dimColor>{(isSelected ? '▶' : gutter).padEnd(GUTTER)}</Text>
790 <Text color={colorOf(row)} bold>
791 {badgeOf(row).padStart(BADGE - 1)}
792 </Text>{' '}
793 <Text dimColor>{roleLabelOf(row.role).padEnd(ROLE_WIDTH)}</Text>
794 <Text dimColor={row.kind === 'thinking' || row.role === 'none'} inverse={isSelected}>
795 {fit(summaryOf(row), textWidth)}
796 </Text>
797 </Button>
798 )
799 })
800 const listFiller = Array.from({ length: listRows - lines.length }, () => <Text> </Text>)
801
802 // Detail: the selected row whole, wrapped to the pane and windowed.
803 const picked = matches.find(row => row.id === current) ?? list.find(row => row.id === current)
804 const bodyWidth = Math.max(10, width - 1)
805 const detail: { text: string; tone?: 'result' | 'error' }[] = []
806 if (picked) {
807 const { text: body, result } = shownOf(picked)
808 for (const text of wrapLines(body, bodyWidth)) detail.push({ text })
809 if (result !== undefined) {
810 detail.push({ text: '' })
811 const tone = picked.isError ? 'error' : 'result'
812 for (const text of wrapLines(`→ ${result}`, bodyWidth)) detail.push({ text, tone })
813 }
814 }
815 const maxLineTop = Math.max(0, detail.length - detailRows)
816 const firstLine = Math.min(lineTop, maxLineTop)
817 const detailShown = detail.slice(firstLine, firstLine + detailRows)
818 const pickedInfo = picked
819 ? [
820 picked.tool,
821 picked.role === undefined
822 ? 'role unknown'
823 : picked.role === 'none'
824 ? 'not sent to the model'
825 : `role: ${picked.role}`,
826 picked.source,
827 picked.turn > 0 ? `Turn ${picked.turn}` : '',
828 clockOf(picked.at),
829 picked.endAt !== undefined ? formatMs(picked.endAt - picked.at) : '',
830 detail.length > detailRows
831 ? `${firstLine + 1}-${firstLine + detailShown.length}/${detail.length} lines`
832 : `${detail.length} lines`,
833 `≈${formatTokens(estimateTokens(picked.text + (picked.result ?? '')))} tok`,
834 ]
835 .filter(Boolean)
836 .join(' · ')
837 : 'Select a row to see it whole here'
838 const detailFiller = Array.from({ length: detailRows - detailShown.length }, () => <Text> </Text>)
839 drawn = { listRows, detailRows, maxLineTop }
840
841 // Footer: what the session cost, as the steps report it.
842 const tokens = stepList.reduce((n, s) => n + s.input + s.output + s.cacheRead + s.cacheWrite, 0)
843 const inputSide = stepList.reduce((n, s) => n + s.input + s.cacheRead + s.cacheWrite, 0)
844 const cacheRead = stepList.reduce((n, s) => n + s.cacheRead, 0)
845 const output = stepList.reduce((n, s) => n + s.output, 0)
846 const modelMs = stepList.reduce((n, s) => n + (s.endAt !== undefined ? s.endAt - s.startAt : 0), 0)
847 const toolCalls = list.filter(row => row.kind === 'tool').length
848 const footer = [
849 `${m.turns} turns`,
850 `${stepList.length} steps`,
851 `${toolCalls} calls`,
852 modelMs > 0 ? `${Math.round(output / (modelMs / 1000))} tok/s` : '',
853 `${formatTokens(tokens)} tok`,
854 inputSide > 0 ? `cache hit ${Math.round((cacheRead / inputSide) * 100)}%` : '',
855 formatMs(now - m.startAt),
856 ]
857 .filter(Boolean)
858 .join(' · ')
859
860 const modeButton = (value: TrajectoryMode, label: string, hotkey: string) => (
861 <Button key={`mode-${value}`} plain hotkey={hotkey} onPress={() => update($, mode, () => value)}>
862 <Text bold={currentMode === value} color={currentMode === value ? 'suggestion' : undefined} dimColor={currentMode !== value}>
863 {label}
864 </Text>
865 </Button>
866 )
867
868 return (
869 <Box flexDirection="column">
870 <Box flexDirection="row" gap={2}>
871 {modeButton('duration', 'Duration', 'd')}
872 {modeButton('turns', 'Turns', 't')}
873 {modeButton('calls', 'Calls', 'c')}
874 <Button key="role-filter" plain hotkey="r" onPress={() => cycleRoleFilter($)}>
875 <Text dimColor={currentRole === 'all'} color={currentRole === 'all' ? undefined : 'suggestion'} bold={currentRole !== 'all'}>
876 role: {currentRole}
877 </Text>
878 </Button>
879 <Text dimColor>
880 {needle || currentRole !== 'all' ? `${matches.length}/${list.length}` : `${list.length}`} rows
881 </Text>
882 <Box flexGrow={1} />
883 {shownTop > 0 && (
884 <Button key="list-up" plain onPress={() => scrollList($, -(listRows - 1), listRows)}>
885 <Text dimColor>▲ {shownTop} earlier</Text>
886 </Button>
887 )}
888 {top >= 0 && (
889 <Button key="list-latest" plain hotkey="g" onPress={() => scrollList($, matches.length, listRows)}>
890 <Text dimColor>latest ▼</Text>
891 </Button>
892 )}
893 </Box>
894 {Input && (
895 <Input
896 key="search"
897 placeholder="Search"
898 value={q}
899 onInput={(value: string) => update($, query, () => value)}
900 onSubmit={(value: string) => update($, query, () => value)}
901 />
902 )}
903 <Box flexDirection="column" marginY={1}>
904 {drawLane('Input', lanes.Input)}
905 {drawLane('Model', lanes.Model)}
906 {drawLane('Tools', lanes.Tools)}
907 </Box>
908 <Box key="list" flexDirection="column">
909 {shown.length === 0 && <Text dimColor>{needle ? 'No rows match.' : 'Nothing recorded yet.'}</Text>}
910 {lines}
911 {listFiller}
912 </Box>
913 <Box key="divider" flexDirection="row" gap={1}>
914 {picked && (
915 <Text color={colorOf(picked)} bold>
916 {badgeOf(picked)}
917 </Text>
918 )}
919 <Text dimColor>{pickedInfo}</Text>
920 <Box flexGrow={1} />
921 {picked && (
922 <Button key="copy" plain hotkey="y" onPress={press => copyRow($, picked, press.surface)}>
923 <Text dimColor>copy</Text>
924 </Button>
925 )}
926 <Button key="prev" plain hotkey="k" onPress={() => moveSelection($, -1, listRows)}>
927 <Text dimColor>prev</Text>
928 </Button>
929 <Button key="next" plain hotkey="j" onPress={() => moveSelection($, 1, listRows)}>
930 <Text dimColor>next</Text>
931 </Button>
932 <Button key="detail-up" plain hotkey="p" onPress={() => scrollDetail($, -(detailRows - 1), maxLineTop)}>
933 <Text dimColor>▲</Text>
934 </Button>
935 <Button key="detail-down" plain hotkey="n" onPress={() => scrollDetail($, detailRows - 1, maxLineTop)}>
936 <Text dimColor>▼</Text>
937 </Button>
938 </Box>
939 <Box key="detail" flexDirection="column">
940 {detailShown.map(line => (
941 <Text
942 color={line.tone === 'error' ? 'error' : undefined}
943 dimColor={line.tone === 'result'}
944 >
945 {line.text === '' ? ' ' : line.text}
946 </Text>
947 ))}
948 {detailFiller}
949 </Box>
950 <Box>
951 <Text dimColor>{footer}</Text>
952 </Box>
953 </Box>
954 )
955 })
956}
957types/index.d.ts 90 lines1export type TrajectoryKind = 'system' | 'user' | 'context' | 'assistant' | 'thinking' | 'tool'
2
3/**
4 * Under which role a request carries the row: `system` for the system prompt,
5 * `none` for a row no request carries (a notice, a record the model never reads).
6 */
7export type TrajectoryRole = 'system' | 'user' | 'assistant' | 'none'
8
9export type TrajectoryRoleFilter = TrajectoryRole | 'all'
10
11export type TrajectoryRow = {
12 id: string
13 kind: TrajectoryKind
14 /** The main-loop turn the row belongs to; 0 before the first turn. */
15 turn: number
16 /** When the row was appended, in ms since the epoch. */
17 at: number
18 /** When a tool call finished, in ms since the epoch. */
19 endAt?: number
20 /** The tool's name, on a tool row. */
21 tool?: string
22 /** Absent on rows recorded before roles were kept. */
23 role?: TrajectoryRole
24 /** The door the row came in by, and its attachment or notice name. */
25 source?: string
26 /** The one-line summary drawn in place of `text`, when the row has one. */
27 title?: string
28 /** The row's text: a message, a tool call's input as JSON, the system prompt. */
29 text: string
30 /** A tool call's result. */
31 result?: string
32 isError?: boolean
33}
34
35export type TrajectoryStep = {
36 turn: number
37 startAt: number
38 endAt?: number
39 model: string
40 input: number
41 output: number
42 cacheRead: number
43 cacheWrite: number
44}
45
46export type TrajectoryMeta = {
47 startAt: number
48 turns: number
49 turnStartAt: number
50}
51
52/** The first system prompt the session composed, as the system row shows it. */
53export type TrajectorySystemPrompt = {
54 title: string
55 text: string
56}
57
58/** The context blocks of the first user message, as the context row shows them. */
59export type TrajectoryUserContext = {
60 title: string
61 text: string
62}
63
64export type TrajectoryMode ='duration' | 'turns' | 'calls'
65
66declare module 'claude-code' {
67 interface PluginState {
68 trajectory: {
69 rows: TrajectoryRow[]
70 steps: TrajectoryStep[]
71 meta: TrajectoryMeta
72 mode: TrajectoryMode
73 query: string
74 roleFilter: TrajectoryRoleFilter
75 /** The row the detail half shows; '' for none. */
76 selected: string
77 /** The first row the list half shows; -1 follows the newest. */
78 listTop: number
79 /** The first line the detail half shows. */
80 detailTop: number
81 /** null until the engine composes a system prompt. */
82 systemPrompt: TrajectorySystemPrompt | null
83 /** null until the engine computes the first message's context blocks. */
84 userContext: TrajectoryUserContext | null
85 /** Where each reminder already made a row stands: replies before it, hash, occurrence. */
86 seenReminders: string[]
87 }
88 }
89}
90