SLOPSHOPPER

trajectory

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

paneguardcommandtoastprompt
★ 1v0.4.0MITupdated 2026-10-19him0/claude-trajectory
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trajectory
│ ┃ Trajectory ✕ › fix the failing auth test and add an audit log call │ ┃ d: mode-duration t: mode-turns c: mode-cal │ ┃ Search ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Input ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⏺ Update(src/auth.ts) │ ┃ ⣿⣿⣿⣿ ⎿ Added 2 lines, removed 1 line │ ┃ Model ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Tools │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ press-system ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /trajectory │ ┃ ⎿ trajectory: Trajectory pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ Select a row to see it whole here k: prev j │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Trajectory
d: mode-duration t: mode-turns c: mode-calls r: role-filt Search Input ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ Model Tools press-system Select a row to see it whole here k: prev j: next p: detail 1 turns · 0 steps · 0 calls · 0 tok · 580ms
README

claude-trajectory

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

Install

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.

Use

Run /trajectory to open the pane.

  • Top half: one row per event, with its turn (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.
  • Minimap: Input / Model / Tools lanes, laid out by Duration (d, wall-clock), Turns (t) or Calls (c, one slot per row).
  • Search: filters rows by kind, tool name, text or result.
  • Role filter: role: all cycles through system, user, assistant and none (not sent to the model).
  • Bottom half: click a row to read it whole: the system prompt's sections, a full prompt, a tool call's input and result (JSON pretty-printed, as 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.
  • Mouse wheel: scrolls the list over the top half and the detail over the bottom half.

Keys, once the pane has focus (ctrl+x tab):

KeyAction
k / jprevious / next row
p / nscroll the detail up / down
gback to the newest rows
ycopy the selected row to the clipboard
rcycle the role filter
d / t / cminimap by duration / turns / calls

Notes

  • Only the main conversation is recorded; subagents' rows are left out.
  • The CONTEXT row at turn 0 shows the CLAUDE.md files and rules the first user message carries, as sent.
  • Reminders the engine injects at request time (a nested CLAUDE.md or a path-scoped rule a Read brought, the date, listings) are read off each request before it is sent and get a CONTEXT row each, including those joined onto the end of a tool result.
  • Where an organization manages Claude Code, an installed plugin's 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).
  • The last 1000 rows are kept; each row's text up to 20,000 characters (80,000 for the system prompt).
  • Token counts in the detail header are a local estimate (about 4 ASCII characters, or 1 other character, per token).
  • /clear starts the timeline over.
Source 2 files
hooks/register.tsx 957 lines
1import { 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}
957
types/index.d.ts 90 lines
1export 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