A pane with one card per turn of the session: what you asked, what Claude did, what it touched. Click a card to jump to that turn.

A Claude Code mod that puts a timeline beside the transcript: one card per prompt, showing what you asked, what Claude did, and what it actually touched. Built for long sessions.

https://github.com/user-attachments/assets/6dc32523-1bf8-4dd6-8b6d-c505f1e4daf9
Inside Claude Code (v2.1.287 or later):
/plugin marketplace add yunjiaz7/claude-timeline-mod
/plugin install timeline@claude-timeline-mod
Choose Install for you, then Save configuration. Then type /timeline to open the pane, and again to close it.
The pane sits beside the transcript with the fullscreen renderer (/tui fullscreen); without it, it opens below.
Other ways to install, from a terminal or a clone: see the guide.
→ what Claude did, a grey line of the files and commands it touched (counted, no model involved), and ⚠ errors word for word.| Command | What it does |
|---|---|
/timeline | Open the timeline pane. Run it again to close it. |
/timeline find <words> | Search your past prompts by meaning, e.g. /timeline find where we added tests. |
/timeline find | Show or hide the search box at the top of the pane. |
/timeline lang <language> | Write summaries in another language, e.g. /timeline lang Chinese. Without a language, shows the current one. |
/timeline replies off | Stop writing the "what Claude did" line, to save tokens. /timeline replies on brings it back. |
/timeline cost | Show how many tokens the summaries have used, with an estimate in dollars. |
/timeline help | List every command and your current settings. |
All commands, settings and where the pane draws: see the guide.
Summaries are written by Haiku on your own Claude account. Nothing is spent while the pane is closed, and each summary is written once. In one session, 118 prompts took 3.8k input and 1.9k output tokens, too few to move the 5-hour usage meter on a Max 20x plan.
Everything goes through your own Claude Code session; the mod has no network, file or process access of its own. Exactly what is sent and stored: see the guide.
Scrolling the transcript is where the pane costs the most. Claude Code has no event for "the transcript scrolled", so to keep the marked card in step the mod reads the position each message reports as it is drawn, and checks once more shortly after a scroll stops. While the newest prompt is on screen it skips those checks.
In the first version, knowing which message you are reading and showing it on its card were one blunt action: redraw every message on screen, five times a second, so that each one would report where it was. The mods API offers two finer tools, and splitting the job between them is what made the difference:
It also skips checking while the newest prompt is on screen, since its card is then the marked one.
Extra CPU cost of using the mod with the pane open, on an Apple M4 with Claude Code 2.1.289 (percent of one core, on top of what Claude Code uses without it):
| First version | Now | |
|---|---|---|
| Scrolling the transcript | 11% | 5.3% |
| Sending a short prompt | 4.4% | 2.3% |
About half, in both cases. The first version was measured in an earlier session, so compare these extra costs rather than raw totals.
A scripted terminal (200×50) drives the same session with and without the mod, alternating between them: 30 s untouched, 20 s of mouse-wheel scrolling at five ticks a second, and one short prompt. CPU time is read from ps before and after each step. Identical runs differ by up to about 1–2%, so smaller differences are noise.
Unknown command: /timeline after installing from a terminal: run /reload-plugins, or start a new session.timeline: ui.render … line in the transcript says why. Please open an issue with it.More in the guide.
See the guide.
hooks/register.tsx 2206 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, SessionMessage } from 'claude-code'
3
4const PANE = 'timeline'
5const LANGUAGES = ['English', 'Chinese', 'Japanese', 'Spanish', 'French', 'German'] as const
6// Codes, and each language's own name — what the options were called before
7// they were English, so a setting saved then still resolves.
8const LANG_ALIAS: Record<string, string> = {
9 en: 'English', zh: 'Chinese', cn: 'Chinese', '\u4e2d\u6587': 'Chinese',
10 ja: 'Japanese', jp: 'Japanese', '\u65e5\u672c\u8a9e': 'Japanese',
11 es: 'Spanish', 'espa\u00f1ol': 'Spanish',
12 fr: 'French', 'fran\u00e7ais': 'French',
13 de: 'German', deutsch: 'German',
14}
15
16/** A language name, its code, or the start of either. */
17export function resolveLanguage(want: string): string | null {
18 const w = want.trim().toLowerCase()
19 if (w === '') {
20 return null
21 }
22 const alias = LANG_ALIAS[w]
23 if (alias !== undefined) {
24 return alias
25 }
26 const exact = LANGUAGES.find(l => l.toLowerCase() === w)
27 if (exact !== undefined) {
28 return exact
29 }
30 // The start of a name, English or the language's own (`esp`, `deu`).
31 const byPrefix = new Set([
32 ...LANGUAGES.filter(l => l.toLowerCase().startsWith(w)),
33 ...Object.entries(LANG_ALIAS).filter(([k]) => k.length > 2 && k.startsWith(w)).map(([, v]) => v),
34 ])
35
36 return byPrefix.size === 1 ? [...byPrefix][0] ?? null : null
37}
38
39// A message row's own render id, seen only while that row is drawn. Preferred
40// as a jump target because it lands on the ask; `anchor` (the turn's first tool
41// row, always in the transcript) is the fallback that also covers history.
42const askIds = new Map<string, string>()
43
44/**
45 * Which turn the transcript is showing. Every kind of row reports `onScreen` —
46 * the ask, each block of the reply, each tool call — so each is mapped to its
47 * turn and the earliest turn in the latest burst of reports is the one marked.
48 *
49 * All of it is plain module state. A render hook may not write `$.state` — the
50 * engine denies it, "drawing is pure" — so the marked turn cannot live there;
51 * the pane reads the module's value and is redrawn by the snapshot loop below.
52 */
53/**
54 * Rows in the viewport, by their own render id, each holding what it can be
55 * looked up by rather than a turn number. A row reports the moment it is
56 * drawn, which for the turn in flight is before the maps below have met it;
57 * resolving at report time dropped those rows for good and left the marker
58 * one turn behind. They are resolved each time the marker is computed instead.
59 */
60type Seen = { tool?: string; text?: string; isAsk?: boolean; at: number }
61const visible = new Map<string, Seen>()
62let markedN: number | null = null
63/**
64 * A counter the pane reads, so that bumping it redraws the pane and nothing
65 * else. Invalidating `ui.render` redraws every transcript row as well — and
66 * empties the engine's cache of them — so it is kept for the one thing that
67 * needs it: making the rows report where they are.
68 */
69const drawAtom = atom({ plugin: 'timeline', key: 'draw' } as const, 0)
70/** The mark as the pane last drew it. */
71let drawnMark: number | null = null
72/** Whether the pane is open: set by its own draw, cleared by `ui.close`. */
73let isOpen = false
74
75function redrawPane($: EngineInterface): void {
76 // A write the engine refuses (one made while a drawing is running) falls
77 // back to the broad redraw, so the pane is never left stale.
78 void update($, drawAtom, n => n + 1).catch(() => $.ui.invalidate('ui.render'))
79}
80/**
81 * Each row's last `onScreen`, as text. A redraw the loop asked for reports
82 * the same value again; a different one means the transcript moved.
83 */
84const lastSeen = new Map<string, string>()
85/** Whether a row reported a move since the loop's last tick. */
86let hasMoved = false
87/**
88 * Whether a message row reported text the rows do not hold: the transcript
89 * grew since they were read. At `turn.start` the new prompt may not be stored
90 * yet, so a pane drawn then lacks it; the loop rereads on its next tick.
91 */
92let isStale = false
93/**
94 * Whether the newest turn was among the rows last reported. Then the mark is
95 * the newest card whatever else is on screen, so a sweep could not change it:
96 * the loop skips them, which is most of the time — sitting at the bottom, and
97 * while a reply streams in. A scroll away reports by itself and clears this.
98 */
99let isAtEnd = false
100let turnOfTool = new Map<string, number>()
101let turnOfText = new Map<string, number>()
102/** Texts more than one turn carries, with the turns that carry each. */
103let sharedKeys = new Map<string, number[]>()
104/** The summary key of each turn, by number. */
105let keyByN: string[] = []
106/**
107 * Where a duplicate ask's own row is, by its summary key. Its text names
108 * several turns; the rows reporting in the same burst say which of them it is.
109 */
110const askIdByKey = new Map<string, string>()
111
112/** Of `candidates`, the turn nearest the middle of `around`, or undefined with no evidence. */
113export function nearest(candidates: readonly number[], around: readonly number[]): number | undefined {
114 if (around.length === 0) {
115 return undefined
116 }
117 const sorted = [...around].sort((a, b) => a - b)
118 const mid = sorted[Math.floor(sorted.length / 2)]!
119 let best: number | undefined
120 for (const n of candidates) {
121 if (best === undefined || Math.abs(n - mid) < Math.abs(best - mid)) {
122 best = n
123 }
124 }
125
126 return best
127}
128let latestN = 0
129/**
130 * How long a report stays evidence, measured back from the newest one. A fast
131 * scroll unmounts the rows it leaves without ever reporting them off, so "is
132 * still in the map" cannot mean "is still on screen" — after a jump to the
133 * bottom the marker sat on a card from where the scroll began. Only the latest
134 * burst of reports is trusted: a scroll step reports both edges together, and
135 * a jump reports the whole new viewport, so the burst is always the truth.
136 */
137const FRESH_MS = 400
138
139function turnOf(seen: Seen): number | undefined {
140 if (seen.tool !== undefined) {
141 // A tool id the maps have not met can only be newer than they are.
142 return turnOfTool.get(seen.tool) ?? (latestN > 0 ? latestN : undefined)
143 }
144
145 return seen.text === undefined ? undefined : turnOfText.get(seen.text)
146}
147
148function recompute(): void {
149 let latest = 0
150 for (const seen of visible.values()) {
151 if (seen.at > latest) {
152 latest = seen.at
153 }
154 }
155 let top: number | undefined
156 let atEnd = false
157 const known: number[] = []
158 const unsure: [string, Seen][] = []
159 for (const [id, seen] of visible) {
160 if (seen.at < latest - FRESH_MS) {
161 visible.delete(id)
162 continue
163 }
164 const n = turnOf(seen)
165 if (n !== undefined) {
166 known.push(n)
167 } else if (seen.text !== undefined && sharedKeys.has(seen.text)) {
168 unsure.push([id, seen])
169 }
170 }
171 // Placed by the rows around them; with nothing around them that can be
172 // placed, the latest turn with that text, as it was before duplicates were
173 // told apart — but that guess is not kept as a jump target.
174 const placed = [...known]
175 for (const [id, seen] of unsure) {
176 const candidates = sharedKeys.get(seen.text!)!
177 const n = nearest(candidates, placed)
178 if (n === undefined) {
179 known.push(Math.max(...candidates))
180 continue
181 }
182 known.push(n)
183 const key = keyByN[n - 1]
184 if (seen.isAsk && key !== undefined) {
185 askIdByKey.set(key, id)
186 }
187 }
188 for (const n of known) {
189 if (n === latestN) {
190 atEnd = true
191 }
192 if (top === undefined || n < top) {
193 top = n
194 }
195 }
196 // With the newest turn on screen you are following the live end, and that is
197 // the turn to mark — not the tail of the one before it, which is all that
198 // "earliest in the viewport" finds the moment a new prompt lands.
199 isAtEnd = atEnd && latestN > 0
200 if (isAtEnd) {
201 top = latestN
202 }
203 if (top === undefined || top === markedN) {
204 return
205 }
206 markedN = top
207}
208
209function track($: EngineInterface, id: string, os: unknown, by: { tool?: string; text?: string; isAsk?: boolean }): void {
210 if (os === undefined) {
211 return
212 }
213 const at = Date.now()
214 const was = lastSeen.get(id)
215 const now = JSON.stringify(os)
216 if (lastSeen.size > 5000) {
217 lastSeen.clear()
218 }
219 lastSeen.set(id, now)
220 if (was !== now) {
221 // A row that reports a new place means the transcript is moving.
222 hasMoved = true
223 ticksLeft = FAST_TICKS
224 if (waiting !== null) {
225 waiting.cancel()
226 waiting = null
227 step($)
228 }
229 }
230 if (os === null) {
231 visible.delete(id)
232 } else {
233 visible.set(id, { ...by, at })
234 }
235 recompute()
236}
237
238/**
239 * The engine answers a row's draw from memory when its props are ones it has
240 * seen, so a row coming back to where it was — the bottom of the transcript,
241 * after a scroll up and a quick one down — calls no hook and reports nothing.
242 * Reports alone therefore cannot say what is on screen now. Invalidating makes
243 * every mounted row report afresh, which is the whole truth in one burst; the
244 * rows that are gone simply do not answer and age out.
245 *
246 * It runs only while the pane is open: quickly for a few seconds after the
247 * transcript last moved, then once every few seconds as a net for a move that
248 * raised no report at all.
249 */
250const FAST_TICKS = 3
251const FAST_MS = 200
252const SLOW_MS = 3000
253let ticksLeft = 0
254let looping = false
255let waiting: { cancel: () => void } | null = null
256/** The card the pane was last scrolled to, so it is moved only on a change. */
257let scrolledTo: number | null = null
258
259function step($: EngineInterface): void {
260 {
261 if (!isOpen) {
262 looping = false
263 return
264 }
265 const before = markedN
266 // While the transcript moves the rows report on their own, so the loop
267 // only asks for a full redraw when none did: after a move stops, and as
268 // the slow net for a move that raised no report at all — and never while
269 // the newest turn is on screen, where the mark cannot be anything else.
270 if (!hasMoved && !isAtEnd) {
271 $.ui.invalidate('ui.render')
272 }
273 hasMoved = false
274 $.clock.after(FAST_MS, () => {
275 if (markedN !== before) {
276 ticksLeft = FAST_TICKS
277 }
278 if (isStale) {
279 isStale = false
280 cache = null
281 redrawPane($)
282 }
283 // Out here, not in the draw: keep the marked card inside the pane's own
284 // window as well. Moving the window redraws the pane by itself, so the
285 // pane is asked for a redraw only if that did not already show the mark.
286 if (markedN !== null && markedN !== scrolledTo && !isFinding) {
287 scrolledTo = markedN
288 void $.ui.scroll({ to: { key: `t${markedN}` }, in: PANE, block: 'nearest' }).catch(() => undefined)
289 } else if (markedN !== null && markedN !== scrolledTo && matches === null) {
290 // Under the box the cards are scrolled here, so following is too.
291 scrolledTo = markedN
292 const to = fitTop(heights, findTop, markedN - 1, paneRows - HEAD_ROWS)
293 if (to !== findTop) {
294 findTop = to
295 redrawPane($)
296 keepRing($)
297 }
298 }
299 $.clock.after(60, () => {
300 if (drawnMark !== markedN) {
301 redrawPane($)
302 }
303 })
304 if (ticksLeft > 0) {
305 ticksLeft -= 1
306 step($)
307 } else {
308 waiting = $.clock.after(SLOW_MS, () => {
309 waiting = null
310 step($)
311 })
312 }
313 })
314 }
315}
316
317function loop($: EngineInterface): void {
318 if (!looping) {
319 looping = true
320 step($)
321 }
322}
323
324export function keyOf(text: string): string {
325 // Called on every report of a message row, so a long paste is cut before it
326 // is normalised; the full text is used only when its head is mostly space.
327 const key = text.slice(0, 600).replace(/\s+/g, ' ').trim().slice(0, 61)
328 if (key.length > 60 || text.length <= 600) {
329 return key.slice(0, 60)
330 }
331
332 return text.replace(/\s+/g, ' ').trim().slice(0, 60)
333}
334
335// Rows are derived from the whole transcript. The walk is linear in the
336// session, so it is cached and redone only when a turn or a fill changes it.
337let cache: { tail: string; rows: Row[] } | null = null
338
339/**
340 * What the transcript looks like at its two ends. The session hands over at
341 * most its newest 4096 messages, so past that the count stops changing while
342 * the transcript still grows: a gate on the count stopped every fill for good.
343 * Both ends move as the window slides; a false "changed" costs a no-op fill.
344 */
345export function tailOf(messages: readonly SessionMessage[]): string {
346 const first = messages[0]
347 const last = messages[messages.length - 1]
348
349 return [
350 messages.length,
351 first?.text.length ?? 0,
352 last?.role ?? '',
353 last?.text.length ?? 0,
354 last?.toolUses.length ?? 0,
355 last?.toolUses.at(-1)?.tool_use_id ?? '',
356 ].join('|')
357}
358
359// Summaries, by anchor. Written once, read from the store on every load — a
360// summarising is the paid part of this mod, and nothing is recomputed.
361type Summary = { ask: string; did: string }
362let summaries: Record<string, Summary> = {}
363let loaded = false
364
365// Auto-fill runs only while the pane is open, so nothing is spent on summaries
366// nobody is looking at. Turns arrive one per prompt, already spaced, so each is
367// filled as it lands — there is no burst to debounce.
368let filling = false
369// Only a turn that is running can still produce a reply. Without this the
370// newest row said `waiting…` after a slash command, which never gets one.
371let isRunning = false
372/** Rows per summarising call, so a reply never runs past its own cap. */
373const BATCH = 25
374/**
375 * How many times a fill tried a row and wrote nothing. A row is given up on
376 * after GIVE_UP_AFTER, so a model that keeps declining one is not paid for on
377 * every draw — but a single failure no longer condemns it, since most are
378 * transient: a rate limit, an interrupted turn, a reply that parsed badly.
379 */
380const tries = new Map<string, number>()
381const GIVE_UP_AFTER = 3
382/** Calls that failed in a row; automatic fills stop at the max until a turn starts. */
383let callFailures = 0
384const CALL_FAILURES_MAX = 3
385
386function isSpent(key: string): boolean {
387 return (tries.get(key) ?? 0) >= GIVE_UP_AFTER
388}
389
390function missed(key: string): void {
391 tries.set(key, (tries.get(key) ?? 0) + 1)
392}
393/**
394 * The transcript size at the last attempt. A draw happens for many reasons and
395 * most change nothing, so a fill that failed must not be retried until there is
396 * something new to try it on, rather than on every redraw.
397 */
398let triedAt = ''
399// `input` counts every input token; `cached` and `written` are the parts read
400// from and written to the prompt cache, priced apart. Totals stored before the
401// split lack them, so those are priced as uncached.
402type Side = { calls: number; input: number; out: number; cached?: number; written?: number }
403// `asks` and `replies` came later than the totals: a session summarised before
404// them has totals larger than the two sides, and the rest is shown as earlier.
405type Spent = Side & { asks?: Side; replies?: Side; finds?: Side }
406
407/** One call's usage as a Side. */
408function sideOf(u: ModelUsage): Side {
409 return {
410 calls: 1,
411 input: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
412 out: u.output_tokens,
413 cached: u.cache_read_input_tokens,
414 written: u.cache_creation_input_tokens,
415 }
416}
417
418function addSide(to: Side, by: Side): Side {
419 return {
420 calls: to.calls + by.calls,
421 input: to.input + by.input,
422 out: to.out + by.out,
423 cached: (to.cached ?? 0) + (by.cached ?? 0),
424 written: (to.written ?? 0) + (by.written ?? 0),
425 }
426}
427
428/**
429 * What this session's fills have cost. Kept in the store, not just in memory:
430 * a reload empties the module and a running total that resets on every reload
431 * is not a running total.
432 */
433let spent: Spent = { calls: 0, input: 0, out: 0 }
434
435function spentLine(): string {
436 return spent.calls === 0
437 ? ''
438 : `${k(spent.input)} in / ${k(spent.out)} out · ${spent.calls} call${spent.calls > 1 ? 's' : ''}`
439}
440
441// Haiku's API list price per million tokens: input, output, cache reads and
442// cache writes. A subscription is not billed in dollars, so this is an
443// estimate, a scale for comparing the sides.
444const USD_IN = 1
445const USD_OUT = 5
446const USD_CACHED = 0.1
447const USD_WRITTEN = 1.25
448
449export function usdOf(side: Side): number {
450 const cached = side.cached ?? 0
451 const written = side.written ?? 0
452 const plain = Math.max(0, side.input - cached - written)
453
454 return (plain * USD_IN + cached * USD_CACHED + written * USD_WRITTEN + side.out * USD_OUT) / 1e6
455}
456
457/** The answer to `/timeline cost`: each side of the summaries, then how to stop the larger one. */
458export function costText(total: Spent, doReplies: boolean): string {
459 if (total.calls === 0) {
460 return 'nothing spent on summaries in this session yet.'
461 }
462 const none: Side = { calls: 0, input: 0, out: 0 }
463 const asks = total.asks ?? none
464 const replies = total.replies ?? none
465 const finds = total.finds ?? none
466 const earlier: Side = {
467 calls: total.calls - asks.calls - replies.calls - finds.calls,
468 input: total.input - asks.input - replies.input - finds.input,
469 out: total.out - asks.out - replies.out - finds.out,
470 cached: Math.max(0, (total.cached ?? 0) - (asks.cached ?? 0) - (replies.cached ?? 0) - (finds.cached ?? 0)),
471 written: Math.max(0, (total.written ?? 0) - (asks.written ?? 0) - (replies.written ?? 0) - (finds.written ?? 0)),
472 }
473 const line = (name: string, side: Side, note = '') =>
474 ` ${name.padEnd(9)}${String(side.calls).padStart(4)} call${side.calls === 1 ? ' ' : 's'} · ${k(side.input)} in / ${k(side.out)} out`
475 + ` · ≈ $${usdOf(side).toFixed(3)}${note}`
476
477 return [
478 'cost of the summaries in this session (Haiku)',
479 '',
480 line('prompts', asks, ' summarising what you asked'),
481 line('replies', replies, ' summarising what Claude did'),
482 ...(finds.calls > 0 ? [line('searches', finds, ' /timeline find')] : []),
483 ...(earlier.calls > 0 ? [line('earlier', earlier, ' before the two were counted apart')] : []),
484 '',
485 ' Dollars are an estimate at Haiku\'s API list price, for scale: a subscription is',
486 ' not billed in dollars. No share of the 5-hour limit is shown: Claude Code\'s',
487 ' usage meter moves in whole percents, too coarse to measure summaries by.',
488 '',
489 doReplies
490 ? ' Replies cost more because they read Claude\'s output. `/timeline replies off`\n stops them — nothing is spent reading output, and prompts are still summarised.'
491 : ' Replies are off: nothing is spent reading Claude\'s output. `/timeline replies on` brings them back.',
492 ].join('\n')
493}
494
495const WRITES = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit'])
496
497// `cd x && python train.py` really ran `python train.py`, not `cd`.
498// Found the hard way: without stripping the preamble every tally read `cd×N`.
499const NOISE = new Set(['cd', 'export', 'source', 'set', 'unset', 'echo'])
500const PREFIX = new Set(['timeout', 'nohup', 'sudo', 'env', 'time', 'nice', 'xargs', 'command'])
501const KEEP_ARG = new Set(['python', 'python3', 'uv', 'npm', 'npx', 'git', 'gh', 'bash', 'sh', 'cargo', 'go'])
502
503export function verbOf(cmd: string): string | null {
504 // Only the first line, and nothing past a heredoc marker: a `python3 - <<EOF`
505 // body is data, and tallying words out of it is noise, not signal.
506 const line = (cmd.split('\n')[0] ?? '').split('<<')[0] ?? ''
507 for (const part of line.split(/&&|\|\||;/)) {
508 const words = part.trim().split(/\s+/).filter(Boolean)
509 while (words.length > 0 && (PREFIX.has(words[0]!) || words[0]!.includes('=') || /^\d+$/.test(words[0]!))) {
510 words.shift()
511 }
512 const first = words[0]
513 if (first === undefined || NOISE.has(first)) {
514 continue
515 }
516 const verb = (first.split('/').pop() ?? first).replace(/^['"`(]+/, '')
517 if (verb === '' || !/^[\w.-]+$/.test(verb)) {
518 continue
519 }
520 if (KEEP_ARG.has(verb)) {
521 // `git commit` beats `git`; skip flags and their values to find the subcommand.
522 const arg = words.slice(1).find(w => !w.startsWith('-') && !w.includes('='))
523 if (arg !== undefined) {
524 return `${verb} ${arg.split('/').pop()}`
525 }
526 }
527
528 return verb
529 }
530
531 return null
532}
533
534// A turn the person did not type: a background task reporting, a slash command,
535// the engine's own framing. They open a segment too, but read differently.
536const INJECTED = /^\s*<(task-notification|command-name|local-command|system-reminder)/
537
538// A terminal lays out in cells, not code units: CJK, fullwidth forms and most
539// emoji take two. Truncating by length overflowed every Chinese headline by
540// about double and wrapped it under its own number.
541const WIDE = /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE6F\uFF00-\uFF60\uFFE0-\uFFE6]|[\u{1F300}-\u{1FAFF}]/u
542
543export function cells(text: string): number {
544 let n = 0
545 for (const ch of text) {
546 n += WIDE.test(ch) ? 2 : 1
547 }
548
549 return n
550}
551
552// The search box. Module state: a reload closes it, which is what a reload
553// of a search nobody is typing into should do.
554// Open by default: the box is part of the pane, and `/timeline find` puts it
555// away for the session.
556let isFinding = true
557let query = ''
558/** Row numbers, best first; null before a search has answered. */
559let matches: number[] | null = null
560let isSearching = false
561// While the box is open the pane's window never moves: the box is the head
562// of the tree and the cards under it are scrolled here, by leaving out the
563// ones above `findTop`. A box that chased the window's offset was drawn one
564// frame late on every tick and flickered.
565let findTop = 0
566// The keyboard's ring. Every line of a card is a Button, so the ring would
567// stop on each line; it is steered to stop once per card, on the title.
568let focusedKey: string | null = null
569/** The card the keys have chosen. Set as the key is pressed: the ring itself
570 * lands a drawing later, and a second press must not start from the old card. */
571let selected: number | null = null
572/** The numbers of the cards shown, in the order drawn. */
573let shownOrder: number[] = []
574let isResetting = false
575let isPaneFocused = false
576
577/**
578 * The engine's ring keeps its place in the list, not its card: when the cards
579 * above it are scrolled away it ends up on a different one. After the cards
580 * move it is put back on the chosen card, or on the search box once that card
581 * is no longer drawn. Only while the pane holds the keyboard — asking for the
582 * ring otherwise would take the keyboard from the prompt.
583 */
584function keepRing($: EngineInterface): void {
585 if (!isPaneFocused || selected === null) {
586 return
587 }
588 const at = shownOrder.indexOf(selected)
589 const isDrawn = at >= findTop && at < findTop + drawnCount
590 const key = isDrawn ? `j${selected}` : 'find'
591 if (!isDrawn) {
592 selected = null
593 }
594 $.clock.after(60, () => {
595 void $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
596 })
597}
598
599/** The card a line's key belongs to: `j12`, `d12.0` and `f12.1` are card 12's. */
600export function cardOf(key: string | null | undefined): number | null {
601 const match = /^[jdf](\d+)(\.\d+)?$/.exec(key ?? '')
602
603 return match === null ? null : Number(match[1])
604}
605
606/** Rows each drawn card takes, and the rows the pane shows: what following needs. */
607let heights: number[] = []
608let paneRows = 40
609/**
610 * How many cards are drawn under the box: the ones that fit, and one more so
611 * the last row of the window is never empty. The window does not move while
612 * the box is up, so a card past these could not be seen.
613 */
614let drawnCount = 40
615
616/**
617 * The furthest the first drawn card may go: the one from which the cards to
618 * the end just fill the window, so the last card stays at the bottom as it
619 * does when the pane scrolls by itself, instead of rising to the top over
620 * empty space.
621 */
622export function lastTop(rowsOf: readonly number[], room: number): number {
623 let used = 0
624 for (let i = rowsOf.length - 1; i >= 0; i -= 1) {
625 used += rowsOf[i] ?? 0
626 if (used > room) {
627 return Math.min(rowsOf.length - 1, i + 1)
628 }
629 }
630
631 return 0
632}
633
634export function fitCount(rowsOf: readonly number[], top: number, room: number): number {
635 let used = 0
636 let n = 0
637 for (let i = top; i < rowsOf.length && used <= room; i += 1) {
638 used += rowsOf[i] ?? 0
639 n += 1
640 }
641
642 return n + 1
643}
644
645/** The usage line, the box, its status, its hint and the gap above the first card. */
646const HEAD_ROWS = 7
647
648/**
649 * The first card to draw so that card `at` is inside `room` rows: unchanged
650 * when it already is, as a window's `nearest` scroll would leave it.
651 */
652export function fitTop(rowsOf: readonly number[], top: number, at: number, room: number): number {
653 if (at < top) {
654 return at
655 }
656 let from = top
657 let used = 0
658 for (let i = from; i <= at; i += 1) {
659 used += rowsOf[i] ?? 0
660 }
661 while (used > room && from < at) {
662 used -= rowsOf[from] ?? 0
663 from += 1
664 }
665
666 return from
667}
668
669/**
670 * One call: every turn as a line, and the thing being looked for. The model
671 * matches on meaning, which is the point — a summary rarely holds the word
672 * the person remembers.
673 */
674/** About 100k tokens of turns: past that each excerpt is cut to fit. */
675const FIND_BUDGET = 300_000
676
677function findPrompt(rows: Row[], store: Record<string, Summary>, wanted: string): string {
678 // A normal session fits as it is; only a very long one has its excerpts cut.
679 const each = Math.max(40, Math.min(200, Math.floor(FIND_BUDGET / Math.max(1, rows.length)) - 80))
680 return [
681 'Below are the turns of a coding session, one per line: its number, what',
682 'the user asked, and what the assistant did.',
683 '',
684 'Someone is looking for a turn and describes it from memory. Pick the turns',
685 'that match what they mean, even when the words differ or the language does.',
686 '',
687 'Answer with the numbers only, best match first, comma-separated, at most 8.',
688 'If nothing matches, answer: none',
689 '',
690 `LOOKING FOR: ${wanted.slice(0, 400)}`,
691 '',
692 ...rows.map(r => {
693 const summary = store[r.key]
694 const did = summary?.did ? ` => ${summary.did}` : ''
695
696 return `${r.n}. ${summary?.ask ?? ''} | ${excerpt(r.ask, Math.ceil(each * 0.4), Math.floor(each * 0.6))}${did}`
697 }),
698 ].join('\n')
699}
700
701/** The numbers in a find reply, in the order given, each once and within `1..max`. */
702export function parseFind(text: string, max: number): number[] {
703 const out: number[] = []
704 for (const found of text.match(/\d+/g) ?? []) {
705 const n = Number(found)
706 if (n >= 1 && n <= max && !out.includes(n)) {
707 out.push(n)
708 }
709 }
710
711 return out.slice(0, 8)
712}
713
714function closeFind($: EngineInterface): void {
715 isFinding = false
716 query = ''
717 matches = null
718 findTop = 0
719 redrawPane($)
720}
721
722let findAbort: AbortController | null = null
723
724async function runFind($: EngineInterface, rows: Row[], wanted: string): Promise<void> {
725 query = wanted.trim()
726 findTop = 0
727 if (query === '') {
728 matches = null
729 redrawPane($)
730
731 return
732 }
733 // The latest search wins: an earlier one still running is cut off, so its
734 // answer cannot land over the newer one.
735 findAbort?.abort()
736 const abort = new AbortController()
737 findAbort = abort
738 isSearching = true
739 redrawPane($)
740 try {
741 const reply = await $.model.complete({
742 model: 'haiku',
743 effort: 'low',
744 maxTokens: 100,
745 prompt: findPrompt(rows, summaries, query),
746 }, { signal: abort.signal })
747 if (findAbort !== abort) {
748 return
749 }
750 matches = reply.isAnswered ? parseFind(reply.text, rows.length) : []
751 if (reply.isAnswered && reply.usage !== undefined && sessionKey !== null) {
752 const by = sideOf(reply.usage)
753 spent = {
754 ...spent,
755 ...addSide(spent, by),
756 finds: addSide(spent.finds ?? { calls: 0, input: 0, out: 0 }, by),
757 }
758 await put($, `${sessionKey}:spent`, spent)
759 }
760 } finally {
761 if (findAbort === abort) {
762 findAbort = null
763 isSearching = false
764 redrawPane($)
765 }
766 }
767}
768
769/** `text` followed by the spaces that bring it to `n` cells. */
770function pad(text: string, n: number): string {
771 return text + ' '.repeat(Math.max(0, n - cells(text)))
772}
773
774/**
775 * `text` as lines of at most `n` cells, broken at a space where the line has
776 * one late enough and mid-word otherwise (CJK has no spaces to break at).
777 */
778export function wrapCells(raw: string, n: number): string[] {
779 const text = clean(raw)
780 const lines: string[] = []
781 let line = ''
782 let used = 0
783 for (const ch of text) {
784 const w = WIDE.test(ch) ? 2 : 1
785 if (used + w > n) {
786 const at = line.lastIndexOf(' ')
787 if (ch !== ' ' && at > line.length / 2) {
788 lines.push(line.slice(0, at))
789 line = line.slice(at + 1)
790 used = cells(line)
791 } else {
792 lines.push(line)
793 line = ''
794 used = 0
795 if (ch === ' ') {
796 continue
797 }
798 }
799 }
800 line += ch
801 used += w
802 }
803 if (line !== '') {
804 lines.push(line)
805 }
806
807 return lines
808}
809
810// Colour codes and other control characters, which tool output carries (a
811// failing command's red error text) and the engine refuses in a tree: one
812// such string made it draw none of the pane.
813const CONTROL = /\x1b\[[0-9;?]*[ -\/]*[@-~]|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g
814
815export function clean(text: string): string {
816 return text.replace(CONTROL, '')
817}
818
819/** Flatten to one line and cut it to `n` terminal cells, not `n` characters. */
820function head(text: string, n: number): string {
821 const flat = clean(text).replace(/<[^>]+>/g, ' ').split(/\s+/).join(' ').trim()
822 if (cells(flat) <= n) {
823 return flat
824 }
825 let out = ''
826 let used = 0
827 for (const ch of flat) {
828 const w = WIDE.test(ch) ? 2 : 1
829 if (used + w > n - 1) {
830 break
831 }
832 out += ch
833 used += w
834 }
835
836 return `${out}…`
837}
838
839/**
840 * The opening and the close of a long prompt. A paste usually comes first and
841 * the request after it, so the head alone handed the summariser the pasted
842 * text and none of what was being asked — it had nothing to answer and the row
843 * stayed raw.
844 */
845function excerpt(text: string, open: number, close: number): string {
846 const flat = text.replace(/<[^>]+>/g, ' ').split(/\s+/).join(' ').trim()
847
848 return flat.length <= open + close ? flat : `${flat.slice(0, open)} … ${flat.slice(-close)}`
849}
850
851function tally(items: string[], top: number): string {
852 const counts = new Map<string, number>()
853 for (const item of items) {
854 counts.set(item, (counts.get(item) ?? 0) + 1)
855 }
856
857 return [...counts.entries()]
858 .sort((a, b) => b[1] - a[1])
859 .slice(0, top)
860 .map(([name, n]) => (n > 1 ? `${name}×${n}` : name))
861 .join(', ')
862}
863
864type Row = {
865 n: number
866 ask: string
867 isInjected: boolean
868 /** What the turn did, deterministically: files, commands, other tools. */
869 facts: string[]
870 /** Error text as the tool reported it. Never summarized — a model smooths
871 "tried four times and failed" into "addressed the issue". */
872 errors: string[]
873 /** The first tool row of this turn, whose requestId is its tool_use_id. */
874 anchor?: string
875 /** The stored summary's key: what the turn says, and which of its kind it is. */
876 key: string
877 /** The ask's first words, normalised: what its message row is known by. */
878 said: string
879 /** The ask on one line, cut long: a title before the summary lands. */
880 flat: string
881 /** Every tool call of the turn, and the opening of each reply block. */
882 toolIds: string[]
883 replyKeys: string[]
884 /** This turn alone, trimmed — what `complete` is given when only one is missing. */
885 body: string
886}
887
888/** One row per message you sent, holding what the turns after it actually did. */
889export function rowsOf(messages: readonly SessionMessage[]): Row[] {
890 const rows: Row[] = []
891 const seen = new Map<string, number>()
892 let uses: SessionMessage['toolUses'] = []
893
894 const close = () => {
895 const row = rows[rows.length - 1]
896 if (row === undefined) {
897 return
898 }
899 const files: string[] = []
900 const cmds: string[] = []
901 const other: string[] = []
902
903 row.anchor = uses[0]?.tool_use_id
904 for (const use of uses) {
905 if (use.isError === true && row.errors.length < 20) {
906 row.errors.push(use.text ?? 'failed')
907 }
908 if (WRITES.has(use.tool)) {
909 const path = (use.input.file_path ?? use.input.notebook_path) as string | undefined
910 if (path !== undefined && !files.includes(path)) {
911 files.push(path)
912 }
913 } else if (use.tool === 'Bash') {
914 const verb = verbOf((use.input.command as string) ?? '')
915 if (verb !== null) {
916 cmds.push(verb)
917 }
918 } else {
919 other.push(use.tool.replace(/^mcp__/, ''))
920 }
921 }
922
923 if (files.length > 0) {
924 row.facts.push(`${files.length} file${files.length > 1 ? 's' : ''}: ${files.slice(0, 4).map(f => f.split('/').pop()).join(', ')}`)
925 }
926 if (cmds.length > 0) {
927 row.facts.push(`${cmds.length} cmd: ${tally(cmds, 4)}`)
928 }
929 if (other.length > 0) {
930 row.facts.push(tally(other, 4))
931 }
932 uses = []
933 }
934
935 for (const m of messages) {
936 if (m.role === 'user' && m.text.trim() !== '') {
937 close()
938 // A summary is stored under what the turn says, not where it sits. The
939 // list a session hands over can lose its head — after a compaction and
940 // a resume it starts at the summary — and a key made of the position
941 // then put turn 39's summary on whatever was 39th now.
942 // ponytail: identical asks are told apart by their order alone, so a
943 // shifted list can swap the reply lines of two "continue"s; key on the
944 // message's own id if the API ever exposes one.
945 const said = keyOf(m.text)
946 const nth = (seen.get(said) ?? 0) + 1
947 seen.set(said, nth)
948 rows.push({
949 n: rows.length + 1,
950 key: `${said}#${nth}`,
951 said,
952 flat: excerpt(m.text.slice(0, 2000), 600, 0),
953 ask: m.text,
954 isInjected: INJECTED.test(m.text),
955 facts: [],
956 errors: [],
957 body: '',
958 toolIds: [],
959 replyKeys: [],
960 })
961 } else if (m.role === 'assistant') {
962 uses = [...uses, ...m.toolUses]
963 const row = rows[rows.length - 1]
964 if (row !== undefined) {
965 row.toolIds.push(...m.toolUses.map(u => u.tool_use_id))
966 if (m.text.trim() !== '') {
967 row.replyKeys.push(keyOf(m.text))
968 }
969 }
970 if (row !== undefined && row.body.length < 6000) {
971 // Enough of the turn to summarise it and no more: the reply, then each
972 // call by name with a short look at what it ran and whether it failed.
973 const calls = m.toolUses
974 .map(u => {
975 const arg = (u.input.command ?? u.input.file_path ?? u.input.pattern ?? '') as string
976 return ` [${u.tool}] ${String(arg).split('\n')[0]?.slice(0, 120) ?? ''}${u.isError === true ? ' → FAILED' : ''}`
977 })
978 .join('\n')
979 row.body += `${m.text.slice(0, 1500)}\n${calls}\n`
980 }
981 }
982 }
983 close()
984
985 return rows
986}
987
988function rowsCached(messages: readonly SessionMessage[]): Row[] {
989 const tail = tailOf(messages)
990 if (cache === null || cache.tail !== tail) {
991 // Every turn, including the ones that only talked: a trajectory with gaps
992 // in its numbering is not a trajectory, and a turn that decided something
993 // without touching a file is often the one that mattered.
994 cache = { tail, rows: rowsOf(messages) }
995 turnOfTool = new Map()
996 turnOfText = new Map()
997 sharedKeys = new Map()
998 keyByN = cache.rows.map(r => r.key)
999 latestN = cache.rows.length
1000 // A text two turns share ("continue", "Done.") cannot say which turn is on
1001 // screen, nor where a click should land; it is left out of both, and the
1002 // tool calls reported in the same burst decide instead.
1003 const claim = (k: string, n: number) => {
1004 const had = turnOfText.get(k)
1005 const shared = sharedKeys.get(k)
1006 if (shared !== undefined) {
1007 if (!shared.includes(n)) {
1008 shared.push(n)
1009 }
1010 } else if (had !== undefined && had !== n) {
1011 sharedKeys.set(k, [had, n])
1012 }
1013 turnOfText.set(k, n)
1014 }
1015 for (const row of cache.rows) {
1016 claim(row.said, row.n)
1017 for (const id of row.toolIds) {
1018 turnOfTool.set(id, row.n)
1019 }
1020 for (const k of row.replyKeys) {
1021 claim(k, row.n)
1022 }
1023 }
1024 for (const k of sharedKeys.keys()) {
1025 turnOfText.delete(k)
1026 }
1027 }
1028
1029 return cache.rows
1030}
1031
1032/** The single-turn prompt, for `complete`, which sees only what it is given. */
1033function onePrompt(row: Row, language: string): string {
1034 return [
1035 'Below is one turn of a coding session: what the user asked, then what the',
1036 'assistant replied and which tools it ran.',
1037 '',
1038 'Answer with one line and nothing else:',
1039 `${row.n}|<the ask in up to 10 words>|<what the assistant did, up to 16 words, past tense>`,
1040 '',
1041 'Name the concrete thing: the file, the fix, the finding, the number. If the',
1042 'turn failed or was abandoned, say so plainly — never smooth a failure into',
1043 'an accomplishment.',
1044 '',
1045 `Write both fields in ${language}, whatever language the turn itself is in.`,
1046 '',
1047 `ASKED: ${excerpt(row.ask, 160, 400)}`,
1048 `DID:\n${row.body.slice(0, 5000)}`,
1049 ].join('\n')
1050}
1051
1052/**
1053 * The asks alone. A prompt is there the moment it is sent, so a row can say
1054 * what it was for long before it can say what came of it.
1055 */
1056function asksPrompt(rows: Row[], language: string): string {
1057 return [
1058 'Below are things a user asked in a coding session. For each, say what they',
1059 'wanted — the point of the ask, not its wording.',
1060 '',
1061 'Output one line per item, nothing else. No preamble, no markdown:',
1062 '<number>|<up to 10 words>',
1063 '',
1064 `Write in ${language}, whatever language the ask itself is in.`,
1065 '',
1066 ...rows.map(r => `${r.n}. ${excerpt(r.ask, 90, 260)}`),
1067 ].join('\n')
1068}
1069
1070/**
1071 * Several turns with their replies, for `complete`, which sees only what it is
1072 * given. Each body is trimmed to share the call's budget.
1073 */
1074function batchPrompt(rows: Row[], language: string): string {
1075 const each = Math.max(300, Math.floor(60000 / Math.max(1, rows.length)))
1076
1077 return [
1078 'Below are turns of a coding session: what the user asked, then what the',
1079 'assistant replied and which tools it ran.',
1080 '',
1081 'Output one line per turn, nothing else. No preamble, no markdown:',
1082 '<number>|<the ask in up to 10 words>|<what the assistant did, up to 16 words, past tense>',
1083 '',
1084 'Name the concrete thing: the file, the fix, the finding, the number. If a',
1085 'turn failed or was abandoned, say so plainly — never smooth a failure into',
1086 'an accomplishment.',
1087 '',
1088 `Write both fields in ${language}, whatever language the turn is in.`,
1089 '',
1090 ...rows.map(r => `--- ${r.n}\nASKED: ${excerpt(r.ask, 90, 260)}\nDID: ${r.body.slice(0, each)}`),
1091 ].join('\n')
1092}
1093
1094export const VERBS = ['help', 'find', 'fill', 'cost', 'lang', 'replies', 'close'] as const
1095
1096function distance(a: string, b: string): number {
1097 let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
1098 for (let i = 1; i <= a.length; i += 1) {
1099 const row = [i]
1100 for (let j = 1; j <= b.length; j += 1) {
1101 row[j] = Math.min(
1102 (prev[j] ?? 0) + 1,
1103 (row[j - 1] ?? 0) + 1,
1104 (prev[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1),
1105 )
1106 }
1107 prev = row
1108 }
1109
1110 return prev[b.length] ?? 0
1111}
1112
1113/**
1114 * The verb a word meant. Exact first, then an unambiguous prefix, then the
1115 * nearest within two edits — a command you can only reach by spelling it
1116 * exactly is a command you have to keep looking up.
1117 */
1118export function resolveVerb(word: string): string | null {
1119 const w = word.toLowerCase()
1120 if (w === '') {
1121 return null
1122 }
1123 if ((VERBS as readonly string[]).includes(w)) {
1124 return w
1125 }
1126 const byPrefix = VERBS.filter(v => v.startsWith(w))
1127 if (byPrefix.length === 1) {
1128 return byPrefix[0] ?? null
1129 }
1130 // The other direction too: a word that opens with a verb and then goes wrong
1131 // (`langauge`, `filll`) is further than two edits but perfectly clear.
1132 const opensWith = VERBS.find(v => w.startsWith(v))
1133 if (opensWith !== undefined) {
1134 return opensWith
1135 }
1136 let best: string | null = null
1137 let bestAt = 3
1138 for (const v of VERBS) {
1139 const d = distance(w, v)
1140 if (d < bestAt) {
1141 bestAt = d
1142 best = v
1143 }
1144 }
1145
1146 return best
1147}
1148
1149export function parseAsks(text: string): Record<number, string> {
1150 const out: Record<number, string> = {}
1151 for (const line of text.split('\n')) {
1152 const match = /^\s*(\d+)\s*\|\s*(.+?)[\s|]*$/.exec(line)
1153 if (match !== null) {
1154 out[Number(match[1])] = match[2]!
1155 }
1156 }
1157
1158 return out
1159}
1160
1161export function parseFill(text: string): Record<number, { ask: string; did: string }> {
1162 const out: Record<number, { ask: string; did: string }> = {}
1163 for (const line of text.split('\n')) {
1164 const match = /^\s*(\d+)\s*\|\s*(.+?)\s*\|\s*(.+?)[\s|]*$/.exec(line)
1165 if (match !== null) {
1166 out[Number(match[1])] = { ask: match[2]!, did: match[3]! }
1167 }
1168 }
1169
1170 return out
1171}
1172
1173const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
1174
1175/**
1176 * What a row without a reply summary says, or null to say nothing. Only the
1177 * newest row can still be waiting on a reply, and only a row with a reply can
1178 * be summarised: a slash command has neither and sat at `waiting…` for good.
1179 */
1180export function pendingOf(row: Row, isLast: boolean, isFilling: boolean): string | null {
1181 if (row.body.trim() !== '') return isFilling ? 'summarising…' : isLast ? 'waiting…' : null
1182 return isLast ? 'waiting…' : null
1183}
1184
1185/** The same rows as text, for a surface that draws no pane. */
1186function asText(rows: Row[], store: Record<string, Summary>, doReplies: boolean): string {
1187 return rows
1188 .map(r => {
1189 const summary = store[r.key]
1190 const lines = [`${r.isInjected ? '⏱' : '❯'} ${String(r.n).padStart(3)} ${head(summary?.ask ?? r.flat, 68)}`]
1191 if (!doReplies) {
1192 return lines.join('\n')
1193 }
1194 const did = summary?.did || (summary !== undefined ? pendingOf(r, isRunning && r.n === rows.length, false) : null)
1195 if (did) {
1196 lines.push(` → ${did}`)
1197 }
1198 if (r.facts.length > 0) {
1199 lines.push(` ${r.facts.join(' · ')}`)
1200 }types/index.d.ts 13 lines1/** Bumped to redraw the pane alone. */
2export type DrawCount = number
3
4declare module 'claude-code' {
5 interface PluginState {
6 timeline: {
7 /** The pane reads it, so bumping it redraws the pane and nothing else. */
8 draw: DrawCount
9 }
10 }
11}
12
13