SLOPSHOPPER

timeline

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.

newpanerowscommandmodeltimer
v0.2.0MITupdated 2026-10-06yunjiaz7/claude-timeline-mod
A shopper browsing a rack in a slop shop
README

Timeline for Claude Code

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.

Timeline demo: cards appear beside the transcript, each line is explained, a search jumps to a turn

https://github.com/user-attachments/assets/6dc32523-1bf8-4dd6-8b6d-c505f1e4daf9

How to install

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 you get

  • A card per prompt: your ask in one line, → what Claude did, a grey line of the files and commands it touched (counted, no model involved), and ⚠ errors word for word.
  • Click a card to jump to that turn.
  • Scroll the transcript and the card for what you are reading stays marked.
  • Search by meaning in the box at the top.
  • A usage line: context window, 5-hour and 7-day limits.

Commands

CommandWhat it does
/timelineOpen 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 findShow 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 offStop writing the "what Claude did" line, to save tokens. /timeline replies on brings it back.
/timeline costShow how many tokens the summaries have used, with an estimate in dollars.
/timeline helpList every command and your current settings.

All commands, settings and where the pane draws: see the guide.

Cost and privacy

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.

Performance

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.

How it got lighter

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:

  • Knowing where you are. Claude Code redraws messages as you scroll anyway, and each one reports its position when it is drawn. The mod listens instead of asking.
  • Showing it. The marked card lives in the mod's state, and a change to that state redraws only the pane that reads it, never the transcript.

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 versionNow
Scrolling the transcript11%5.3%
Sending a short prompt4.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.

How it was measured

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.

Troubleshooting

  • Unknown command: /timeline after installing from a terminal: run /reload-plugins, or start a new session.
  • The pane is blank: a dim timeline: ui.render … line in the transcript says why. Please open an issue with it.

More in the guide.

Develop

See the guide.

License

MIT

Source 2 files
hooks/register.tsx 2206 lines
1import { 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 lines
1/** 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