SLOPSHOPPER

tool-timeline

Folds each turn's tool calls into one line under the turn's closing line, with how long each took and the slowest highlighted.

newpanespinnerrowsguardcommand
v0.1.2MITupdated 2026-10-14wonwoo/tool-timeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-timeline
│ ┃ Tool timeline ✕ › fix the failing auth test and add an audit log call │ ┃ Last turn · 380ms · 9 tool calls · slowest … │ ┃ ⏺ Read(src/auth.ts) │ ┃ Bash ▣ ▁▁▁▁▁▁▁▁▁▁▁▁▁▆▆▆▆▁▆▆▆▆▁ ⎿ Read 6 lines │ ┃ Write ▣ ▁▁▁▁▁▁▁▁▆▆▆▆▆▁▁▁▁▁▁▁▁▁▁ ⏺ Update(src/auth.ts) │ ┃ Read ▣ ▁▆▆▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⎿ Added 2 lines, removed 1 line │ ┃ Grep ▣ ▁▁▁▆▆▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⏺ Bash(bun test) │ ┃ Edit ▣ ▁▁▁▁▁▁▆▆▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Read /work/app/src ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Grep refresh\( │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Edit /work/app/src ✻ Worked… │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Write /work/app/src ▣ client module ./summary.tsx │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Write /work/app/src │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ ✗ Bash Run the test › /tool-timeline │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Bash Show working │ ┃ ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Bash Clean build o │ ┃ └ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Bash Show environm │ ✻ Thinking · 9 tool calls · slowest Read 20ms… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Tool timeline
Last turn · 380ms · 9 tool calls · slowest Read 20ms Bash ▣ ▁▁▁▁▁▁▁▁▁▁▁▁▁▆▆▆▆▁▆▆▆▆▁ 80ms · 4 calls Write ▣ ▁▁▁▁▁▁▁▁▆▆▆▆▆▁▁▁▁▁▁▁▁▁▁ 40ms · 2 calls Read ▣ ▁▆▆▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 20ms · 1 call Grep ▣ ▁▁▁▆▆▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 20ms · 1 call Edit ▣ ▁▁▁▁▁▁▆▆▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 20ms · 1 call ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Read /work/app/src/auth.ts ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Grep refresh\( ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Edit /work/app/src/auth.ts ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Write /work/app/src/audit.ts ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Write /work/app/src/cache.ts ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ ✗ Bash Run the test suite ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Bash Show working tree status ├ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Bash Clean build output and push └ 20ms ▣ ▆▆▆▆▆▆▆▆▆▆ · Bash Show environment file
Turn line
✻ Worked… ▣ client module ./summary.tsx
README

tool-timeline

English | 한국어

See where each Claude Code turn spent its time.

When Claude takes a minute to answer, was it thinking, or waiting on npm test? tool-timeline adds one line under every turn that ran tools: how many it ran and which was slowest. Click it and the turn opens into a timeline, Claude's own requests and each tool on a lane of their own, then every call with how long it took.

<img src="docs/images/demo.gif" width="880" alt="A turn running npm test and a build: the spinner's gauge fills toward the build's usual 21s; the folded line under the finished turn unfolds into lanes and calls; a click on the slowest call brings Claude's answer why it took that long">

<sub>Recorded and screenshotted on a sample project, in Ghostty (through cmux) and in the desktop app.</sub>

What you get

One line under each turn that ran tools

Under the line Claude Code closes a turn with (✻ Cooked for 52s), one dim line: the number of tool calls, the slowest one, and a small bar per call in the order they ran (24 bars at most; past that, a bar stands for several calls, drawn as the slowest of them). Click it to unfold the turn, and again to fold it.

<img src="docs/images/folded.png" width="650" alt="A folded turn: 6 tool calls · slowest Bash 17.1s, with a small bar per call">

<img src="docs/images/unfolded.png" width="869" alt="An unfolded turn: lanes for Claude, Bash and Edit across the turn, then each call with its time, the slowest a 17.1s test run">

Unfolded, a turn shows:

  • Lanes across the turn's length: one for Claude's own model requests, one per tool, each with the time it kept busy. Claude's lane adds the tokens it wrote, and paints in violet a request that missed the prompt cache, which made it slower and dearer. A subagent's calls get lanes of their own (↳ Bash).
  • Calls, the ten slowest, slowest first. A Bash call shows its description rather than the raw command. Calls repeated on the same thing share a row (Bash ×3). ✗ marks a failed call, ⊘ one refused: by a rule, by you at the permission prompt, or by auto mode's classifier.
  • Usual times. A call that took far longer than that command usually does says so (usual 8.2s). Usual is the middle of its last 20 runs in this project, kept between sessions, and shows once three are kept. A call's time takes in any wait for your permission, so a command you approve each time counts that wait too.

Live, while Claude works

The spinner names the call running longest, its time ticking. When that command has a usual time, a gauge fills toward it, and far past it the spinner says by how much (2.3× usual).

<img src="docs/images/spinner.png" width="779" alt="The spinner while a build runs: Bash 10s, a gauge half full, usual 21s · 1 tool call">

Ask why a call was slow

In the terminal, click a call that took 5 seconds or more, or far longer than usual, and Claude says why it took that long. It answers from the command and the end of its output, kept as the call ran, and where they don't show where the time went, it says so and calls any guess a guess. The answer hangs under the row; click again to hide it.

<img src="docs/images/why.png" width="880" alt="Under the slowest row, Claude's answer: most of the 17.1s was the full suite, its e2e tests 11.4s of it; running only the failing file would have done">

A pane for the whole session

/tool-timeline opens a pane with the turn under way, drawn live, and below it your ten latest turns: how long each took, how long ago, its slowest call and what you asked. Click one to unfold it there. The turn under way shows its ten latest calls, an earlier one its ten slowest, and a line counts the rest. On the turn under way, click that line to list the calls before its latest ten too.

<img src="docs/images/pane.png" width="880" alt="The pane: this turn's lanes and calls, a build 14.1s into its usual 20s, then the earlier turn in columns">

On the desktop app

The desktop app draws no closing line, so the fold hangs under Claude's final reply, in the dim tone and with the chevron of the app's own folded rows.

<img src="docs/images/desktop.png" width="824" alt="On the desktop app, the fold under Claude's reply, unfolded: lanes as smooth bars, then the calls as dim lines">

And a few quiet helpers

  • A notification when a call that ran 30 seconds or more ends, through the terminal's notification channel. The desktop app has no such channel; there only your own Notification hooks get it.
  • A heads-up when Claude is going in circles. When the same Bash command fails three times in a row, a toast and a notification say so (Bash failed 3 times in a row · npm test), a good moment to step in.
  • Claude knows your slow commands. With your prompts, unseen by you, Claude is told how long this project's slow commands (10 seconds or more) usually take, so it can run a narrower one, or a long one in the background.
  • It survives a restart. A resumed session still folds the turns it had.

Install

Needs Claude Code 2.1.295 or later. At the prompt of a terminal session, type:

/plugin install tool-timeline --marketplace wonwoo/tool-timeline

Answer y to add the marketplace, then pick a scope (user is the first). It runs in that session at once, and in every session after. The desktop app's Code tab does not take this command; installed from a terminal at the user scope, it runs there too. In Ghostty and kitty the bars are drawn as pictures, as on this page; other terminals show the same bars in text.

What it costs and keeps

  • Tokens. Two things reach the model, the rest is drawn on your machine. Asking why is one model request over the session's own transcript, served from its prompt cache while that is warm, and the conversation keeps nothing of it. The usual-times note is a few lines with a prompt: on a session's first, then only once those times change or the conversation is compacted.
  • Stored. In Claude Code's plugin store: the last 20 times of each command and tool in each project (1,000 of them in all, the least lately run let go), and the finished turns of your latest five sessions. In a turn, a call that took a second or more also keeps what asking why answers from: the first 1,000 characters of its command and the last 1,000 of its output. The terminal's picture bars are files under /tmp/tool-timeline/<session>; a session removes, as it starts, the folders not written for a day.
Source 7 files
hooks/register.tsx 1772 lines
1import { atom, read, update } from 'claude-code'
2import type {
3  BoxProps,
4  ButtonProps,
5  ClientProps,
6  ElementConstructor,
7  EngineInterface,
8  ImageProps,
9  ImageSource,
10  Register,
11  RenderElement,
12  SvgProps,
13  TextProps,
14} from 'claude-code'
15
16import type { TimelineCall, TimelineOutcome, TimelineSpan, TimelineStep, TimelineTurn, TimelineWhy } from '../types'
17import { barPicture } from './bars'
18import type { HistoryProps } from './history'
19import type { RowProps } from './row'
20import { BAR_COLOR, SLOW_COLOR, sparkRuns, type SummaryProps } from './summary'
21
22const PANE = 'tool-timeline'
23const MAX_CALLS = 200
24const MAX_TURNS = 50
25// An unfolded turn lists its slowest calls only, so a long turn stays readable
26const MAX_UNFOLDED_ROWS = 10
27const BAR_CELLS = 10
28// The terminal's bars: blocks that join without a seam, standing on a baseline where nothing ran; six eighths
29// tall, their middle nearer the letters' beside them than five's, a gap left between rows that seven's closes
30const BAR = '▆'
31const TRACK = '▁'
32const SPARKS = '▁▂▃▄▅▆▇█'
33const MAX_SPARKS = 24
34// An unfolded turn draws one lane per tool, and one for Claude's own requests, across this many cells
35const LANE_CELLS = 30
36// A lane's name is cut past this many letters; `clens_left_off` fits
37const LANE_NAME_LETTERS = 14
38// The pane's lanes are as wide as it leaves beside a lane's name and the longest of its turn's totals, within these
39const MIN_PANE_LANE_CELLS = 12
40// Under the current turn the pane lists so many earlier ones, each by the start of what was asked, so many letters
41const MAX_PANE_TURNS = 10
42const MAX_PROMPT_LETTERS = 20
43// Claude's own lane in Claude's clay, fixed: the theme's `claude` key follows the person's theme
44const CLAUDE_COLOR = '#d97757'
45// The SVG is drawn as an image, so it takes fixed colors that read on a light and a dark page
46const TOOL_COLOR = '#74b3d6'
47// The gray the desktop draws its own folded tool rows in (`Showed current date and time ›`), measured
48const FOLDED_ROW_GRAY = '#898782'
49// The desktop's lane width in CSS pixels, and its name column in cells; a call row's bar on its pane, in pixels too
50const LANE_WIDTH = 280
51// In a narrow pane the lanes give way down to this
52const MIN_LANE_WIDTH = 80
53const DESKTOP_ROW_WIDTH = 80
54// A pane row's branch, and its mark, each take a column this wide
55const DESKTOP_MARK_CELLS = 2
56const DESKTOP_LABEL_CELLS = 12
57// The lane's time column fits `1m05s`; a letter of the desktop's font takes about this much of a cell
58const DESKTOP_TIME_CELLS = 5
59const DESKTOP_CELLS_PER_LETTER = 0.75
60// A cell of the desktop's pane is about this many CSS pixels wide, measured on the app
61const DESKTOP_PX_PER_CELL = 7.8
62const INK_COLOR = '#8a8a8a'
63// A picture bar's track in that gray, as strong as a dim glyph's on a dark terminal
64const PICTURE_TRACK_ALPHA = 0.5
65const MAX_STEPS = 200
66// The first of these a tool's arguments carry is shown beside its name: the model's own
67// one-line description before a raw command, which runs long
68const DETAIL_KEYS = ['description', 'file_path', 'pattern', 'url', 'query', 'path', 'command']
69const PATH_KEYS = new Set(['file_path', 'path'])
70const MAX_DETAIL = 60
71const MARKS: Record<TimelineOutcome, string> = { running: '…', ok: '·', error: '✗', denied: '⊘' }
72// The calls' times kept between sessions, under a key per project and kind of call: its latest times, for so many
73// kinds of all projects together
74const DURATIONS_KEY = 'durations'
75// A session's finished turns kept between runs, so a resumed one still folds its earlier turns: the latest so many
76// sessions, each in so much JSON at most, as the store holds 4 MiB in all
77const TURNS_KEY_PREFIX = 'turns:'
78const MAX_KEPT_SESSIONS = 5
79const MAX_KEPT_SESSION_CHARS = 400_000
80// An answer is kept by its end, all the desktop matches a reply against
81const MAX_KEPT_ANSWER = 4_000
82const MAX_SAMPLES = 20
83const MAX_KINDS = 1000
84// A call is slow against its usual time once it took twice that and a second more, with enough times to go by
85const MIN_SAMPLES = 3
86const SLOW_MARGIN_MS = 1000
87// The spinner fills a gauge of so many cells toward the running call's usual time; a call usually done within a
88// second has nothing to fill
89const PROGRESS_CELLS = 6
90const MIN_PROGRESS_MS = 1000
91// A row is worth asking Claude about once its call took this long, or far longer than it usually does
92const MIN_WHY_MS = 5000
93// What a call ran and gave back is kept from a second on, as calls like it can take that long together: the
94// command's start and the output's end, so much of each
95const MIN_EVIDENCE_MS = 1000
96const MAX_EVIDENCE_CHARS = 1000
97// A row of calls like it asks about its longest so many
98const MAX_ASKED_CALLS = 3
99// With the person's prompts Claude is told how long this project's slow commands usually take, those worth choosing
100// a narrower one over: so many at most, the slowest first
101const MIN_TOLD_MS = 10_000
102const MAX_TOLD = 8
103// What the model reads of a call refused at the permission prompt, from its start: by the person at the dialog, or by
104// auto mode's classifier
105const REFUSED_TEXTS = [
106  "The user doesn't want to proceed with this tool use.",
107  'Permission for this action was denied by the Claude Code auto mode classifier.',
108]
109// A call this long raises a notification when it ends
110const LONG_CALL_MS = 30_000
111// A Bash command failing this many times in a row, with no run of it going through between, says so once: Claude
112// is likely trying the same thing again, a moment for the person to step in. So many commands are counted at most
113const FAIL_STREAK = 3
114const MAX_FAIL_STREAKS = 100
115const FAIL_TOAST_MS = 8000
116
117const calls = atom({ plugin: 'tool-timeline', key: 'calls' } as const, [])
118// The current turn's model requests on the main loop, and when the turn began
119const steps = atom({ plugin: 'tool-timeline', key: 'steps' } as const, [])
120const turnStartedAt = atom({ plugin: 'tool-timeline', key: 'turnStartedAt' } as const, 0)
121// When the turn ended, none while it runs; and when the main loop's model request under way was sent, none between
122const turnEndedAt = atom({ plugin: 'tool-timeline', key: 'turnEndedAt' } as const, null)
123const stepStartedAt = atom({ plugin: 'tool-timeline', key: 'stepStartedAt' } as const, null)
124// The time the drawing measures running calls and the request under way against, moved once a second while either is
125const now = atom({ plugin: 'tool-timeline', key: 'now' } as const, 0)
126const turns = atom({ plugin: 'tool-timeline', key: 'turns' } as const, [])
127// The turns whose summary line is unfolded
128const expanded = atom({ plugin: 'tool-timeline', key: 'expanded' } as const, [])
129// Claude's answers to why a call took that long, asked from its unfolded row
130const whys = atom({ plugin: 'tool-timeline', key: 'whys' } as const, {})
131const toldUsual = atom({ plugin: 'tool-timeline', key: 'toldUsual' } as const, '')
132const failStreaks = atom({ plugin: 'tool-timeline', key: 'failStreaks' } as const, {})
133// The start of what the current turn was asked; the earlier turns the pane has unfolded; the turn whose calls before
134// its latest ten the pane lists too, by when it started
135const turnPrompt = atom({ plugin: 'tool-timeline', key: 'turnPrompt' } as const, '')
136const paneOpen = atom({ plugin: 'tool-timeline', key: 'paneOpen' } as const, [])
137const paneAllCalls = atom({ plugin: 'tool-timeline', key: 'paneAllCalls' } as const, null)
138// The calls under way the engine refused, from `tool.check` to the call's end: one dispatch of `tool.call` reads its
139// state as of one moment, so it would not see a refusal written there
140const refusals = new Set<string>()
141
142// A call, or in an unfolded turn the calls of one kind repeated: their time together and how many
143type Row = { call: TimelineCall; ms: number; count: number }
144// A lane: Claude's requests, or one tool's calls, as stretches of the turn; Claude's with the tokens it wrote and its
145// requests that missed the prompt cache
146type Lane = { name: string; spans: TimelineSpan[]; count: number; unit: string; outputTokens?: number; missed?: TimelineSpan[] }
147type TextElement = ElementConstructor<TextProps>
148// The elements a fold draws with; `Svg` only where the surface has one
149type Kit = {
150  Box: ElementConstructor<BoxProps>
151  Text: TextElement
152  Button: ElementConstructor<ButtonProps>
153  Svg: ElementConstructor<SvgProps> | undefined
154  Client?: ElementConstructor<ClientProps>
155  // Only where the terminal draws pictures: the bars then drawn as pictures, not glyphs
156  Image?: ElementConstructor<ImageProps>
157}
158// What the desktop draws its lanes and pane rows with
159type DesktopKit = { Box: ElementConstructor<BoxProps>; Text: TextElement; Svg: ElementConstructor<SvgProps> }
160// What the pane draws a turn with: glyphs, pictures where the terminal draws them, or the desktop's columns; in so many
161// columns
162type PaneDraw = { Text: TextElement; pictures?: Pictures; desktop?: DesktopKit; columns: number }
163// The current turn's calls before its latest ten: whether the pane lists them, and the press that lists or leaves them,
164// on a region of its own where the surface has one, a button elsewhere
165type EarlierCalls = {
166  Box: ElementConstructor<BoxProps>
167  Button: ElementConstructor<ButtonProps>
168  Client: ElementConstructor<ClientProps> | undefined
169  isShown: boolean
170  toggle: () => unknown
171}
172// What the pane draws its earlier turns' lines with: a region for each line's words where the surface has one, a
173// button elsewhere
174type HistoryKit = {
175  Box: ElementConstructor<BoxProps>
176  Text: TextElement
177  Button: ElementConstructor<ButtonProps>
178  Client: ElementConstructor<ClientProps> | undefined
179  draw: PaneDraw
180}
181// What a line draws its bar as a picture with
182type Pictures = { Box: ElementConstructor<BoxProps>; Image: ElementConstructor<ImageProps> }
183// Where a row's words are a region of their own, a press on them asking Claude why the call took that long
184// Where a lane line starts, under the line it hangs from, and how many cells its bars take
185type LaneLayout = { lead: string; cells: number }
186const UNFOLDED_LANE: LaneLayout = { lead: '     ', cells: LANE_CELLS }
187type Region = { Box: ElementConstructor<BoxProps>; Client: ElementConstructor<ClientProps>; key: string; turnId: string; isOpen: boolean }
188
189// Pictures as files the terminal reads itself: a drawing then carries their paths, not their pixels, which drawn
190// again with every press and every redraw kept a press from showing for most of a second. A session's files go under
191// a folder of its own; a folder left a day is let go when a session starts
192const PICTURE_DIR = '/tmp/tool-timeline'
193const pictureFiles = new WeakMap<ImageSource, ImageSource>()
194// Pictures drawn from their pixels since the last were filed; those being filed; how often each could not be, given
195// up on after so many. Names start with when this module first filed one, so a reload never reuses a name for other
196// pixels, which the terminal, going by the name, would not read again
197const unfiled = new Set<ImageSource>()
198const filing = new Map<ImageSource, Promise<void>>()
199const failures = new WeakMap<ImageSource, number>()
200const MAX_FILE_TRIES = 2
201let filedCount = 0
202let filedRun: number | undefined
203// Whether this module has let go of what a run before it left half made, cut off by a reload
204let isSwept = false
205
206// The picture as a file once it is one; until then its pixels, the picture noted to be filed
207function filed(source: ImageSource): ImageSource {
208  const file = pictureFiles.get(source)
209  if (file !== undefined) {
210    return file
211  }
212  if ('rgba' in source && (failures.get(source) ?? 0) < MAX_FILE_TRIES) {
213    unfiled.add(source)
214  }
215
216  return source
217}
218
219// Draws a tree; when it holds pictures not yet in files, files them and draws it again, with their paths
220async function drawFiled($: EngineInterface, draw: () => RenderElement | Promise<RenderElement>): Promise<RenderElement> {
221  const drawn = await draw()
222  if (unfiled.size === 0) {
223    return drawn
224  }
225  const fresh = [...unfiled].filter(source => !filing.has(source) && !pictureFiles.has(source))
226  unfiled.clear()
227  if (fresh.length > 0) {
228    const batch = fileBatch($, fresh).finally(() => fresh.forEach(source => filing.delete(source)))
229    fresh.forEach(source => filing.set(source, batch))
230  }
231  await Promise.all(filing.values())
232
233  return draw()
234}
235
236// The pictures' pixels written as text, then turned to bytes by one command for them all, each moved into place
237// whole and named back once it is; one not named back is drawn from its pixels and tried again on a later drawing
238async function fileBatch($: EngineInterface, sources: ImageSource[]) {
239  const done = new Set<string>()
240  const paths: string[] = []
241  try {
242    filedRun ??= await $.clock.now()
243    const dir = `${PICTURE_DIR}/${await $.session.id()}`
244    paths.push(...sources.map(() => `${dir}/${filedRun}-${filedCount++}.rgba`))
245    await Promise.all(sources.map((source, i) => ('rgba' in source ? $.fs.write(`${paths[i]}.b64`, source.rgba) : undefined)))
246    // The first of this module's runs also lets go of the text and part files of runs before it in the folder
247    const decode =
248      'd=$1; r=$2; shift 2; [ -n "$d" ] && find "$d" -maxdepth 1 \\( -name "*.b64" -o -name "*.part" \\) ! -name "$r-*" -delete 2>/dev/null; ' +
249      'for p; do if base64 -d < "$p.b64" > "$p.part" && [ -s "$p.part" ] && mv "$p.part" "$p"; then echo "$p"; else rm -f "$p.part"; fi; rm -f "$p.b64"; done'
250    const sweep = isSwept ? '' : dir
251    isSwept = true
252    const { stdout } = await $.process.run(['sh', '-c', decode, 'sh', sweep, String(filedRun), ...paths])
253    stdout.split('\n').forEach(line => done.add(line))
254  } catch {
255    // Those not named back are drawn from their pixels
256  }
257  sources.forEach((source, i) => {
258    const path = paths[i]
259    if ('rgba' in source && path !== undefined && done.has(path)) {
260      pictureFiles.set(source, { file: path, format: 'rgba', width: source.width, height: source.height })
261    } else {
262      failures.set(source, (failures.get(source) ?? 0) + 1)
263    }
264  })
265}
266
267export const register: Register = on => {
268  on('session.start', async ($, e, next) => {
269    await $.command.register({
270      name: 'tool-timeline',
271      description: "Show this turn's tool calls and how long each took",
272      immediate: true,
273    })
274    $.clock.every(1000, () => void tick($))
275    const old = ['find', PICTURE_DIR, '-mindepth', '1', '-maxdepth', '1', '-mmin', '+1440', '-exec', 'rm', '-rf', '{}', '+']
276    $.clock.after(0, () => void $.process.run(old).catch(() => undefined))
277    // A reload drops the timer an answer was coming on: those rows are asked again on their next press
278    await update($, whys, all => Object.fromEntries(Object.entries(all).filter(([, why]) => why.state !== 'asking')))
279    // Nor does the request a reload cut off end here
280    await update($, stepStartedAt, () => null)
281    // A resumed session's earlier turns, as it kept them when it last ran; a hot reload still holds its own
282    const kept = await keptTurnsOf($)
283    if (kept.length > 0) {
284      await update($, turns, all => (all.length > 0 ? all : kept))
285    }
286
287    return next(e)
288  })
289
290  on('command.run', { command: 'tool-timeline' }, async $ => {
291    await $.ui.open({ id: PANE, title: 'Tool timeline' })
292
293    return {}
294  })
295
296  // With the person's prompt, unseen by them, how long this project's slow commands usually take, so Claude can run
297  // a narrower one or a long one in the background; told again only once the times change
298  on('prompt.submit', async ($, e, next) => {
299    const note = await usualTimesNote($)
300    if (note === '' || note === (await read($, toldUsual))) {
301      return next(e)
302    }
303    await update($, toldUsual, () => note)
304
305    return next({ ...e, context: [...(e.context ?? []), note] })
306  })
307
308  // A compaction may drop what Claude was told: the next prompt tells it again
309  on('session.compact', async ($, e, next) => {
310    const compacted = await next(e)
311    await update($, toldUsual, () => '')
312
313    return compacted
314  })
315
316  // Each prompt's turn starts a new timeline; the last one stays until then
317  on('turn.start', async ($, e, next) => {
318    const time = await $.clock.now()
319    await update($, calls, () => [])
320    await update($, steps, () => [])
321    await update($, turnStartedAt, () => time)
322    await update($, turnEndedAt, () => null)
323    await update($, turnPrompt, () => cut(e.text.replace(/\s+/g, ' ').trim(), MAX_PROMPT_LETTERS))
324
325    return next(e)
326  })
327
328  // Each model request of the main loop, timed from its send to the end of its response, with what the response wrote
329  on('turn.step', async function* ($, e, next) {
330    if (e.agentId !== undefined) {
331      return yield* next(e)
332    }
333    const startedAt = await $.clock.now()
334    await update($, stepStartedAt, () => startedAt)
335    // None when no response came back, or it reported no usage
336    let outputTokens = 0
337    let promptTokens = 0
338    let cachedTokens = 0
339    try {
340      const response = yield* next(e)
341      const usage = response.usage
342      outputTokens = usage?.output_tokens ?? 0
343      if (usage !== null) {
344        promptTokens = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
345        cachedTokens = usage.cache_read_input_tokens
346      }
347
348      return response
349    } finally {
350      const endedAt = await $.clock.now()
351      const step = { startedAt, endedAt, outputTokens, ...(promptTokens > 0 ? { promptTokens, cachedTokens } : {}) }
352      await update($, steps, list => [...list, step].slice(-MAX_STEPS))
353      await update($, stepStartedAt, () => null)
354    }
355  })
356
357  // Keep each finished turn's timeline for the summary drawn under its closing line
358  on('turn.complete', async ($, e, next) => {
359    if (e.agentId === undefined) {
360      const time = await $.clock.now()
361      await update($, turnEndedAt, () => time)
362      const list = await read($, calls)
363      if (list.length > 0) {
364        const turn: TimelineTurn = {
365          turnId: e.turnId,
366          durationMs: e.durationMs,
367          startedAt: await read($, turnStartedAt),
368          endedAt: time,
369          calls: list,
370          steps: await read($, steps),
371          answer: e.answer,
372          prompt: await read($, turnPrompt),
373        }
374        await update($, turns, all => [...all, turn].slice(-MAX_TURNS))
375        await keepTurns($, await read($, turns))
376      }
377    }
378
379    return next(e)
380  })
381
382  // The engine's verdict before a call runs: one it refused (a rule, a hook) never ran, its error no failure of the call
383  on('tool.check', async (_, e, next) => {
384    const verdict = await next(e)
385    if (e.tool_use_id !== undefined && verdict.decision === 'deny') {
386      refusals.add(e.tool_use_id)
387    }
388
389    return verdict
390  })
391
392  on('tool.call', async ($, e, next) => {
393    const id = e.tool_use_id
394    const kind = kindOf(e)
395    // The project its times are kept under; one the session cannot name keeps them under none
396    const root = await $.session.root().catch(() => '')
397    const call: TimelineCall = {
398      id,
399      tool: toolName(e.tool),
400      detail: detailOf(e),
401      isSubagent: e.agentId !== undefined,
402      startedAt: await $.clock.now(),
403      endedAt: null,
404      outcome: 'running',
405      usualMs: kind === undefined ? undefined : usualOf((await durationsOf($))[usualKey(root, kind)]),
406    }
407    await update($, calls, list => [...list, call].slice(-MAX_CALLS))
408
409    let outcome: TimelineOutcome = 'error'
410    let output: string | undefined
411    // A command sent on to the background, by its own flag, by ctrl+b or at its timeout: the call ends, the command not
412    let isBackgrounded = false
413    try {
414      const ran = await next(e)
415      // As the model read it; a hook's own result carries no text, its result then read when it is one
416      output = ran.text ?? (typeof ran.result === 'string' ? ran.result : undefined)
417      isBackgrounded = typeof (ran.result as { backgroundTaskId?: unknown } | null | undefined)?.backgroundTaskId === 'string'
418      // Refused by a plugin, by a rule, by the person at the dialog, or by auto mode's classifier
419      const isRefused =
420        ran.deny !== undefined || refusals.has(id) || (ran.isError === true && REFUSED_TEXTS.some(text => output?.startsWith(text) === true))
421      outcome = isRefused ? 'denied' : ran.isError ? 'error' : 'ok'
422
423      return ran
424    } finally {
425      refusals.delete(id)
426      const endedAt = await $.clock.now()
427      const ms = endedAt - call.startedAt
428      const evidence = ms >= MIN_EVIDENCE_MS ? evidenceOf(e, output) : {}
429      await update($, calls, list =>
430        list.map(one => (one.id === id ? { ...one, endedAt, outcome, ...evidence } : one)),
431      )
432      // Only a call that ran through sets what is usual; a backgrounded one says nothing of how long its command takes
433      if (kind !== undefined && outcome === 'ok' && !isBackgrounded) {
434        await recordDuration($, usualKey(root, kind), ms)
435      }
436      // A long call's end is worth hearing from another window; a denied one ended at the person's own answer, a
437      // backgrounded one's command has not finished
438      if (ms >= LONG_CALL_MS && outcome !== 'denied' && !isBackgrounded) {
439        const verb = outcome === 'ok' ? 'finished in' : 'failed after'
440        void $.ui.notify(`${call.tool} ${verb} ${formatMs(ms)}${call.detail === '' ? '' : ` · ${call.detail}`}`).catch(() => {})
441      }
442      // A denied run says nothing of whether the command works
443      if (kind?.startsWith('Bash:') && outcome !== 'denied') {
444        const streak = await countFailure($, kind, outcome === 'error')
445        if (streak === FAIL_STREAK) {
446          const text = `${call.tool} failed ${FAIL_STREAK} times in a row · ${cut(kind.slice('Bash:'.length), MAX_DETAIL)}`
447          $.ui.toast(text, { timeoutMs: FAIL_TOAST_MS })
448          void $.ui.notify(text).catch(() => {})
449        }
450      }
451    }
452  })
453
454  // The current turn as an unfolded one is drawn, live: its lanes so far, then its calls in the order they ran, a
455  // running one with its gauge toward its usual time; under it the earlier turns, a line each, unfolded on a press
456  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
457    const kit = $.ui.resolve(e)
458    const { Box, Text, Button } = kit
459    const list = await read($, calls)
460    const at = await read($, now)
461    const rows = rowsOf(list, at)
462    const sent = await read($, stepStartedAt)
463    // The request under way grows up to now, as a running call does
464    const requests = [...(await read($, steps)), ...(sent === null ? [] : [{ startedAt: sent, endedAt: Math.max(at, sent) }])]
465    const Image = e.surface === 'terminal' ? await picturesImage($, kit) : undefined
466    const draw: PaneDraw = {
467      Text,
468      pictures: Image === undefined ? undefined : { Box, Image },
469      // The desktop's font is not one of even letters: there the lines are columns, their bars SVG, as its folds are
470      desktop: e.surface === 'desktop' && 'Svg' in kit ? { Box, Text, Svg: kit.Svg as ElementConstructor<SvgProps> } : undefined,
471      columns: e.props.bodyColumns,
472    }
473    const Client = e.surface === 'terminal' && 'Client' in kit ? (kit.Client as ElementConstructor<ClientProps>) : undefined
474    const startedAt = await read($, turnStartedAt)
475    // Once the turn is done, as long as it took; until then, up to its latest moment known
476    const ended = await read($, turnEndedAt)
477    const isDone = ended !== null
478    // The turns before, newest first; the current one, once done, is the one drawn above
479    const time = await $.clock.now()
480    const open = await read($, paneOpen)
481    const allCallsOf = await read($, paneAllCalls)
482    const earlier = (await read($, turns))
483      .filter(one => !(isDone && one.startedAt === startedAt))
484      .slice(-MAX_PANE_TURNS)
485      .reverse()
486    const history = () =>
487      earlier.length > 0 && [
488        <Text> </Text>,
489        <Text bold>Earlier</Text>,
490        ...historyLines({ Box, Text, Button, Client, draw }, earlier, open, time, turnId => togglePaneTurn($, turnId)),
491      ]
492
493    if (rows.length === 0 && requests.length === 0) {
494      return drawFiled($, () => (
495        <Box flexDirection="column">
496          <Text dimColor>No tool calls in this turn yet.</Text>
497          {history()}
498        </Box>
499      ))
500    }
501    const endedAt = ended ?? Math.max(at, ...list.map(call => call.endedAt ?? call.startedAt), ...requests.map(step => step.endedAt))
502    const turn: TimelineTurn = { turnId: 'live', durationMs: endedAt - startedAt, startedAt, endedAt, calls: list, steps: requests, answer: '' }
503    const running = rows.filter(row => row.call.outcome === 'running').length
504    const slowest = rows.length === 0 ? undefined : slowestOf(rows)
505    // While it runs its bars change every second, each a new picture: glyphs then, pictures once it is done
506    const current: PaneDraw = isDone ? draw : { ...draw, pictures: undefined }
507
508    return drawFiled($, () => (
509      <Box flexDirection="column">
510        <Text wrap="truncate">
511          <Text bold>{isDone ? 'Last turn' : 'This turn'}</Text>
512          <Text dimColor>{` · ${formatMs(turn.durationMs)}${rows.length === 0 ? '' : ` · ${countOf(rows)}`}`}</Text>
513          {running > 0 && <Text color={SLOW_COLOR}>{` · ${running} running`}</Text>}
514          {slowest !== undefined && <Text dimColor>{' · slowest '}</Text>}
515          {slowest !== undefined && <Text color={SLOW_COLOR}>{`${slowest.call.tool} ${formatMs(slowest.ms)}`}</Text>}
516        </Text>
517        <Text> </Text>
518        {paneTurnBody(current, turn, rows, true, { Box, Button, Client, isShown: allCallsOf === startedAt, toggle: () => togglePaneAllCalls($) })}
519        {history()}
520      </Box>
521    ))
522  })
523
524  // While a turn runs: after the spinner's word, the call running longest (its time ticking) and the count so far
525  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
526    const rows = rowsOf(await read($, calls), await read($, now))
527    if (rows.length === 0) {
528      return next(e)
529    }
530
531    return next({ ...e, props: { ...e.props, suffix: `${e.props.suffix}${spinnerNote(rows)}` } })
532  })
533
534  // Under a turn's closing line (`Baked for 3s`): one folded line, a click unfolds the turn
535  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
536    // The line carries no turn id; its duration is the one turn.complete reported
537    const turn = (await read($, turns)).find(one => one.durationMs === e.props.durationMs)
538    if (turn === undefined) {
539      return next(e)
540    }
541
542    const kit = $.ui.resolve(e)
543    const engine = await next(e)
544    const Image = await picturesImage($, kit)
545
546    return drawFiled($, () => foldedTurn($, { ...kit, Svg: undefined, Image }, turn, engine))
547  })
548
549  // The desktop draws no closing line: there the fold hangs under the turn's final reply, its lanes an SVG
550  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
551    if (e.surface !== 'desktop' || e.props.isSummary) {
552      return next(e)
553    }
554    const turn = turnOfReply(await read($, turns), e.props.text)
555    if (turn === undefined) {
556      return next(e)
557    }
558
559    return foldedTurn($, $.ui.resolve(e), turn, await next(e))
560  })
561
562  // A press on a turn's folded line on the terminal, on one of its unfolded rows, on an earlier turn's line in the pane
563  // or on the line for the current turn's earlier calls, which their regions post
564  on('ui.message', async ($, e, next) => {
565    const { toggle, why, open, earlier } = (e.data ?? {}) as {
566      toggle?: unknown
567      why?: { turnId?: unknown; callId?: unknown }
568      open?: unknown
569      earlier?: unknown
570    }
571    if (e.module === 'hooks/summary.tsx' && typeof toggle === 'string') {
572      await toggleTurn($, toggle)
573
574      return {}
575    }
576    if (e.module === 'hooks/history.tsx' && typeof open === 'string') {
577      await togglePaneTurn($, open)
578
579      return {}
580    }
581    if (e.module === 'hooks/row.tsx' && typeof why?.turnId === 'string' && typeof why.callId === 'string') {
582      await askWhy($, why.turnId, why.callId)
583
584      return {}
585    }
586    if (e.module === 'hooks/earlier.tsx' && earlier === true) {
587      await togglePaneAllCalls($)
588
589      return {}
590    }
591
592    return next(e)
593  })
594}
595
596// A press on a row: asks Claude why the call took that long, once; a press again hides the answer or shows it, and
597// asks again after a failure
598async function askWhy($: EngineInterface, turnId: string, callId: string) {
599  const key = `${turnId}:${callId}`
600  const known = (await read($, whys))[key]
601  if (known?.state === 'asking') {
602    return
603  }
604  if (known?.state === 'answered') {
605    await update($, whys, all => ({ ...all, [key]: { ...known, isShown: !known.isShown } }))
606    return
607  }
608  const turn = (await read($, turns)).find(one => one.turnId === turnId)
609  if (turn === undefined) {
610    return
611  }
612  const rows = rowsOf(turn.calls, 0)
613  const row = groupedRows(rows).find(one => one.call.id === callId)
614  if (row === undefined || !canAskWhy(row)) {
615    return
616  }
617  // The row's calls, the longest first
618  const members = rows
619    .filter(one => groupKeyOf(one.call) === groupKeyOf(row.call))
620    .sort((a, b) => b.ms - a.ms)
621  const asking: TimelineWhy = { state: 'asking', text: '', isShown: true }
622  await update($, whys, all => ({ ...all, [key]: asking }))
623  // The answer takes a model request: on a timer, it outlives the press that asked for it
624  $.clock.after(0, () => void forkWhy($, key, row, members))
625}
626
627// The question over the session's own transcript, so its cached prefix serves it and the conversation keeps nothing
628async function forkWhy($: EngineInterface, key: string, row: Row, members: Row[]) {
629  let why: TimelineWhy
630  try {
631    const reply = await $.model.fork({ prompt: whyPrompt(row, members) })
632    why = reply.isAnswered
633      ? { state: 'answered', text: reply.text.trim(), isShown: true }
634      : { state: 'failed', text: `no answer (${reply.reason === 'api-error' ? `${reply.reason} ${reply.status ?? reply.error}` : reply.reason}); click to ask again`, isShown: true }
635  } catch (error) {
636    why = { state: 'failed', text: `no answer (${String(error)}); click to ask again`, isShown: true }
637  }
638  await update($, whys, all => ({ ...all, [key]: why }))
639}
640
641function canAskWhy(row: Row): boolean {
642  return row.ms >= MIN_WHY_MS || usualNote(row) !== ''
643}
644
645// The question names the call and hands over what its calls ran and gave back, as kept when they ran, and holds the
646// answer to what those show: where they do not show where the time went, it is to say so rather than guess
647function whyPrompt(row: Row, members: Row[]): string {
648  const { call, ms, count } = row
649  const what = `${count > 1 ? `the ${count} ${call.tool} calls` : `the ${call.tool} call`}${call.detail === '' ? '' : ` on "${call.detail}"`} (tool_use_id ${call.id})`
650  const usual = call.usualMs === undefined ? '' : `, where calls like it usually take ${formatMs(call.usualMs)}`
651  const inside = call.isSubagent ? ' It ran inside a subagent, whose own steps are not in this conversation.' : ''
652  const records = members.slice(0, MAX_ASKED_CALLS).map(recordOf).filter(record => record !== '')
653  const kept =
654    records.length === 0
655      ? 'Nothing of what it ran or gave back was recorded.'
656      : `Recorded as it ran, ${count > 1 ? 'the longest of them, each' : 'its'} command and the end of its output:\n${records.join('\n')}`
657
658  return (
659    `In this conversation, ${what} took ${formatMs(ms)}${count > 1 ? ' together' : ''}${usual}.${inside}\n${kept}\n` +
660    'Answer only from what this record and this conversation show: where they do not show where the time went, ' +
661    'say plainly that they do not rather than guess, and suggest a change only where they point to one. ' +
662    'In at most three short sentences of plain text, without markdown: why did it take that long, and what would ' +
663    'make it faster next time? Answer in the language the user writes in, and call no tools.'
664  )
665}
666
667function recordOf({ call, ms }: Row): string {
668  if (call.command === undefined && call.output === undefined) {
669    return ''
670  }
671
672  return [
673    `<call took="${formatMs(ms)}"${call.outcome === 'ok' ? '' : ` outcome="${call.outcome}"`}>`,
674    ...(call.command === undefined ? [] : [`<command>${call.command}</command>`]),
675    ...(call.output === undefined ? [] : [`<output_end>${call.output}</output_end>`]),
676    '</call>',
677  ].join('\n')
678}
679
680// What a call ran, when it is a Bash command, by its start, and what it gave back by its end
681function evidenceOf(e: { tool: string }, output: string | undefined): Pick<TimelineCall, 'command' | 'output'> {
682  const command = bashCommandOf(e)
683
684  return {
685    ...(command === undefined ? {} : { command: command.slice(0, MAX_EVIDENCE_CHARS) }),
686    ...(output === undefined ? {} : { output: output.length > MAX_EVIDENCE_CHARS ? `…${output.slice(-MAX_EVIDENCE_CHARS)}` : output }),
687  }
688}
689
690function toggleTurn($: EngineInterface, turnId: string) {
691  return update($, expanded, ids => (ids.includes(turnId) ? ids.filter(id => id !== turnId) : [...ids, turnId]))
692}
693
694// The pane unfolds its earlier turns apart from the transcript's folds
695function togglePaneTurn($: EngineInterface, turnId: string) {
696  return update($, paneOpen, ids => (ids.includes(turnId) ? ids.filter(id => id !== turnId) : [...ids, turnId]))
697}
698
699// The pane's current turn listing its calls before its latest ten, or leaving them out again
700async function togglePaneAllCalls($: EngineInterface) {
701  const startedAt = await read($, turnStartedAt)
702
703  return update($, paneAllCalls, at => (at === startedAt ? null : startedAt))
704}
705
706// Ghostty (cmux, built on it, says so too) and kitty draw pictures; any other terminal keeps the glyphs, as does a
707// host that cannot read the environment, where the drawing would otherwise not draw at all, and a session over ssh,
708// whose picture files are on a machine the terminal cannot read
709async function picturesImage($: EngineInterface, kit: object): Promise<ElementConstructor<ImageProps> | undefined> {
710  const env = async (read: Promise<string | undefined>) => read.catch(() => undefined)
711  const drawsPictures =
712    ((await env($.env.get('TERM_PROGRAM'))) === 'ghostty' || (await env($.env.get('TERM'))) === 'xterm-kitty') &&
713    !(await env($.env.get('SSH_CONNECTION')))
714
715  return drawsPictures && 'Image' in kit ? (kit.Image as ElementConstructor<ImageProps>) : undefined
716}
717
718// The engine's own drawing, then the folded summary line and, unfolded, the lanes and the slowest calls
719async function foldedTurn($: EngineInterface, kit: Kit, turn: TimelineTurn, engine: RenderElement) {
720  const { Box, Text, Button, Svg, Client, Image } = kit
721  const pictures = Image === undefined ? undefined : { Box, Image }
722  const isOpen = (await read($, expanded)).includes(turn.turnId)
723  const rows = rowsOf(turn.calls, 0)
724  const slowest = slowestOf(rows)
725  // Unfolded, repeated calls share a row, and the rows longest together come first
726  const shown = groupedRows(rows)
727    .sort((a, b) => b.ms - a.ms)
728    .slice(0, MAX_UNFOLDED_ROWS)
729  const top = shown[0] ?? slowest
730  const hidden = rows.length - shown.reduce((sum, row) => sum + row.count, 0)
731  const hasMore = hidden > 0
732  const nameWidth = nameWidthOf(shown)
733  const lanes = lanesOf(turn)
734  const toggle = () => toggleTurn($, turn.turnId)
735  const asked = await read($, whys)
736
737  // The desktop: the folded summary in the gray of the app's own folded tool rows, so the reply stays the main
738  // thing; unfolded, the lanes' bars as SVG and the words dim
739  if (Svg !== undefined) {
740    const summary = `${countOf(rows)} · slowest ${slowest.call.tool} ${formatMs(slowest.ms)} · ${formatMs(turn.durationMs)} turn`
741
742    return (
743      <Box flexDirection="column">
744        {engine}
745        {/* Half a cell in, where the reply's own text starts */}
746        <Box flexDirection="column" marginTop={1} paddingLeft={0.5}>
747          {/* Drawn as the app's own folded tool rows are: light gray while folded, a step darker unfolded, an icon
748              chevron after. A desktop Button draws no lighter than dim, so the words are a Text in that gray and the
749              press lands on a Button of blank label laid over the line */}
750          <Box flexDirection="row" alignItems="center">
751            <Text {...(isOpen ? { dimColor: true } : { color: FOLDED_ROW_GRAY })}>{summary}</Text>
752            <Svg source={chevronSvg(isOpen)} width={14} height={10} alt={isOpen ? 'unfolded' : 'folded'} />
753            <Box position="absolute" top={0} left={0}>
754              {/* No-break spaces: a label of plain spaces is trimmed to nothing, and with it the place to press */}
755              <Button key={`turn-${turn.turnId}`} plain label={'\u00a0'.repeat(summary.length * 2 + 4)} onPress={toggle} />
756            </Box>
757          </Box>
758          {isOpen && lanes.length > 0 && (
759            // Unfolded content sits further in than the summary it hangs from
760            <Box flexDirection="column" marginTop={1} paddingLeft={2}>
761              {desktopLaneLines({ Box, Text, Svg }, lanes, turn)}
762            </Box>
763          )}
764          {isOpen && (
765            <Box flexDirection="column" marginTop={1} paddingLeft={2}>
766              {shown.map(row => (
767                <Text dimColor wrap="truncate">
768                  {callLine(row)}
769                </Text>
770              ))}
771              {hasMore && <Text dimColor>{`• … ${leftOut(hidden, 'faster')}`}</Text>}
772            </Box>
773          )}
774        </Box>
775      </Box>
776    )
777  }
778
779  return (
780    <Box flexDirection="column">
781      {engine}
782      {Client !== undefined ? (
783        // The whole line pressed, its dim words lit under the pointer (summary.tsx)
784        <Client
785          key={`turn-${turn.turnId}`}
786          module="./summary.tsx"
787          props={{
788            turnId: turn.turnId,
789            count: countOf(rows),
790            slowest: `${slowest.call.tool} ${formatMs(slowest.ms)}`,
791            sparkline: sparkline(rows),
792            isOpen,
793          } satisfies SummaryProps}
794        />
795      ) : (
796        // Where no region draws: the summary in its colors, then the toggle as dim words, as a Button inverts under
797        // the pointer and only these words should
798        <Box flexDirection="row">
799          <Text dimColor>{'  ⎿  '}</Text>
800          <Text bold>{countOf(rows)}</Text>
801          <Text dimColor>{' · slowest '}</Text>
802          <Text color={SLOW_COLOR}>{`${slowest.call.tool} ${formatMs(slowest.ms)}`}</Text>
803          <Text>{'  '}</Text>
804          {sparkRuns(Text, sparkline(rows))}
805          <Text>{'  '}</Text>
806          <Button key={`turn-${turn.turnId}`} plain dimColor label={isOpen ? '(click to fold)' : '(click to expand)'} onPress={toggle} />
807        </Box>
808      )}
809      {isOpen && lanes.map(lane => laneLine(Text, lane, turn, pictures))}
810      {isOpen && lanes.length > 0 && <Text> </Text>}
811      {isOpen &&
812        shown.map((row, i) => {
813          const isLast = i === shown.length - 1 && !hasMore
814          const why = asked[`${turn.turnId}:${row.call.id}`]
815          const isShown = why?.isShown === true
816          const region = Client === undefined ? undefined : { Box, Client, key: `row-${turn.turnId}-${i}`, turnId: turn.turnId, isOpen: isShown }
817
818          return [
819            rowLine(Text, row, top, nameWidth, `     ${isLast ? '└ ' : '├ '}`, pictures, region),
820            isShown && whyLine(Box, Text, why, isLast ? '         ' : '     │   '),
821          ]
822        })}
823      {isOpen && hasMore && <Text dimColor>{`     └ … ${leftOut(hidden, 'faster')}`}</Text>}
824    </Box>
825  )
826}
827
828// A turn as the pane draws it: its lanes, then its calls in the order they ran
829// At most ten of its calls under the lanes: the current turn's latest, in the order they ran, so the running one stays
830// in sight; an earlier one's slowest, repeated calls sharing a row, as its fold lists them; a line counts the rest. The
831// current turn's line is pressed to list its earlier calls too, and again to leave them out
832function paneTurnBody(draw: PaneDraw, turn: TimelineTurn, rows: Row[], isCurrent: boolean, more?: EarlierCalls) {
833  const { Text, pictures, desktop, columns } = draw
834  const lanes = lanesOf(turn)
835  // As wide as the pane leaves beside a lane's name and the longest totals, so no lane's are cut
836  const tail = Math.max(0, ...lanes.map(lane => laneTail(lane).join('').length))
837  const cells = Math.max(MIN_PANE_LANE_CELLS, Math.min(LANE_CELLS, columns - LANE_NAME_LETTERS - 1 - tail))
838  const laneLines = desktop !== undefined ? desktopLaneLines(desktop, lanes, turn, columns) : lanes.map(lane => laneLine(Text, lane, turn, pictures, { lead: '', cells }))
839  if (rows.length === 0) {
840    return laneLines
841  }
842  const shown = isCurrent
843    ? rows.slice(more?.isShown === true ? 0 : -MAX_UNFOLDED_ROWS)
844    : groupedRows(rows).sort((a, b) => b.ms - a.ms).slice(0, MAX_UNFOLDED_ROWS)
845  const hidden = rows.length - shown.reduce((sum, row) => sum + row.count, 0)
846  // The current turn's calls before its latest ten, listed or not
847  const earlier = isCurrent ? Math.max(0, rows.length - MAX_UNFOLDED_ROWS) : 0
848  // The bars against the slowest call; an earlier turn's against its first row, which may hold several
849  const slowest = isCurrent ? slowestOf(rows) : (shown[0] ?? slowestOf(rows))
850  const nameWidth = nameWidthOf(shown)
851  // Wide enough for the longest name, as the lanes' count column is, and a gap before what it ran on
852  const nameCells = Math.ceil(nameWidth * DESKTOP_CELLS_PER_LETTER) + 3
853  // The branch in its column on the desktop, as the rows' is; a line pressed for its calls lights under the pointer on
854  // the terminal, as an earlier turn's line does, where a button would invert
855  const note = (lead: string, words: string, onPress?: () => unknown) => {
856    const label = desktop !== undefined ? `… ${words}` : `${lead} … ${words}`
857    if (more?.Client !== undefined && desktop === undefined && onPress !== undefined) {
858      return (
859        <more.Box flexDirection="row">
860          <Text dimColor>{`${lead} `}</Text>
861          <more.Client key="pane-earlier" module="./earlier.tsx" props={{ words: `… ${words}` }} />
862        </more.Box>
863      )
864    }
865    const line =
866      more !== undefined && onPress !== undefined ? (
867        <more.Button key="pane-earlier" plain dimColor label={label} onPress={onPress} />
868      ) : (
869        <Text dimColor>{label}</Text>
870      )
871
872    return desktop !== undefined ? (
873      <desktop.Box flexDirection="row">
874        <desktop.Box width={DESKTOP_MARK_CELLS}>
875          <Text dimColor>{lead}</Text>
876        </desktop.Box>
877        {line}
878      </desktop.Box>
879    ) : (
880      line
881    )
882  }
883
884  return [
885    ...laneLines,
886    <Text> </Text>,
887    earlier > 0 && note('├', leftOut(earlier, 'earlier'), more?.toggle),
888    ...shown.map((row, i) => {
889      const lead = i === shown.length - 1 && (isCurrent || hidden === 0) ? '└ ' : '├ '
890
891      return desktop !== undefined
892        ? desktopRowLine(desktop, row, slowest, nameCells, lead.trim())
893        : rowLine(Text, row, slowest, nameWidth, lead, pictures)
894    }),
895    !isCurrent && hidden > 0 && note('└', leftOut(hidden, 'faster')),
896  ]
897}
898
899// The pane's earlier turns, a line each in columns: how long it took, a bar of that against the longest of them, how
900// long ago, how many calls, the slowest, the start of what was asked; unfolded under its line once pressed
901function historyLines(kit: HistoryKit, earlier: TimelineTurn[], open: string[], time: number, toggle: (turnId: string) => unknown) {
902  const { Box, Text, Button, Client, draw } = kit
903  const { pictures, desktop } = draw
904  const lines = earlier.map(turn => {
905    const rows = rowsOf(turn.calls, 0)
906    const slowest = slowestOf(rows)
907
908    return {
909      turn,
910      rows,
911      took: formatMs(turn.durationMs),
912      age: agoOf(time - turn.endedAt),
913      count: countOf(rows),
914      slowest: `${slowest.call.tool} ${formatMs(slowest.ms)}`,
915    }
916  })
917  const longest = Math.max(...earlier.map(turn => turn.durationMs))
918  const widest = (pick: (line: (typeof lines)[number]) => string) => Math.max(...lines.map(line => pick(line).length))
919  // Every column as wide as its widest and its words from its start, the time's too, so each line starts where the
920  // heading does
921  const [tookWidth, ageWidth, countWidth, slowestWidth] = [
922    widest(line => line.took),
923    widest(line => line.age),
924    widest(line => line.count),
925    widest(line => line.slowest),
926  ]
927
928  return lines.map(({ turn, rows, took, age, count, slowest }) => {
929    const isOpen = open.includes(turn.turnId)
930    const prompt = cut(turn.prompt ?? '', MAX_PROMPT_LETTERS)
931    const unfolded = isOpen && (
932      <Box flexDirection="column" paddingLeft={2}>
933        {paneTurnBody({ ...draw, columns: draw.columns - 2 }, turn, rows, false)}
934      </Box>
935    )
936    // The desktop: columns, the bar an SVG, the line a button of blank label laid over it, as its folded line is
937    if (desktop !== undefined) {
938      const { Svg } = desktop
939      const cellsOf = (letters: number) => Math.ceil(letters * DESKTOP_CELLS_PER_LETTER) + 2
940      const [tookCells, ageCells, countCells, slowestCells] = [cellsOf(tookWidth), cellsOf(ageWidth), cellsOf(countWidth), cellsOf(slowestWidth)]
941      // The button as wide as the whole line: its columns and its bar in cells, the prompt a cell a letter, as its wide
942      // letters may take; two no-break spaces a letter, as the folded line's
943      const lineCells = tookCells + DESKTOP_ROW_WIDTH / DESKTOP_PX_PER_CELL + 1 + ageCells + countCells + slowestCells + prompt.length
944      const blanks = Math.ceil((lineCells / DESKTOP_CELLS_PER_LETTER) * 2) + 4
945
946      return [
947        <Box flexDirection="row" alignItems="center">
948          <Box width={tookCells}>
949            <Text dimColor>{took}</Text>
950          </Box>
951          <Box>
952            <Svg source={rowSvg(turn.durationMs, longest)} width={DESKTOP_ROW_WIDTH} height={12} alt={took} />
953          </Box>
954          <Box marginLeft={1} width={ageCells}>
955            <Text dimColor>{age}</Text>
956          </Box>
957          <Box width={countCells}>
958            <Text dimColor>{count}</Text>
959          </Box>
960          <Box width={slowestCells}>
961            <Text color={SLOW_COLOR}>{slowest}</Text>
962          </Box>
963          <Text dimColor wrap="truncate">
964            {prompt}
965          </Text>
966          <Box position="absolute" top={0} left={0}>
967            <Button key={`history-${turn.turnId}`} plain label={'\u00a0'.repeat(blanks)} onPress={() => toggle(turn.turnId)} />
968          </Box>
969        </Box>,
970        unfolded,
971      ]
972    }
973    const props: HistoryProps = {
974      turnId: turn.turnId,
975      age: age.padEnd(ageWidth),
976      count: count.padEnd(countWidth),
977      slowest: slowest.padEnd(slowestWidth),
978      prompt,
979    }
980    // A space in from the heading, so the lines do not crowd its edge
981    const head = <Text dimColor>{` ${took.padEnd(tookWidth)} `}</Text>
982    const fraction = longest > 0 ? turn.durationMs / longest : 0
983    const { filled, track } = gauge(turn.durationMs, longest)
984    const bar =
985      pictures !== undefined ? (
986        <pictures.Image
987          source={filed(barPicture(BAR_CELLS, [{ from: 0, to: fraction }], BAR_COLOR, { color: INK_COLOR, alpha: PICTURE_TRACK_ALPHA }))}
988          columns={BAR_CELLS}
989          rows={1}
990          alt={took}
991        />
992      ) : (
993        <Text>
994          <Text color={BAR_COLOR}>{filled}</Text>
995          <Text dimColor>{track}</Text>
996        </Text>
997      )
998    const words =
999      Client !== undefined ? (
1000        <Client key={`history-${turn.turnId}`} module="./history.tsx" props={props} />
1001      ) : (
1002        <Button
1003          key={`history-${turn.turnId}`}
1004          plain
1005          dimColor
1006          label={` ${props.age}  ${props.count}  ${props.slowest}${prompt === '' ? '' : `  ${prompt}`}`}
1007          onPress={() => toggle(turn.turnId)}
1008        />
1009      )
1010
1011    return [
1012      <Box flexDirection="row" overflow="hidden">
1013        <Box flexShrink={0}>
1014          {head}
1015          {bar}
1016        </Box>
1017        {words}
1018      </Box>,
1019      unfolded,
1020    ]
1021  })
1022}
1023
1024// `just now`, `40s ago`, `3m ago`, `2h ago`, `5d ago`: how long ago an earlier turn ended
1025function agoOf(ms: number): string {
1026  const seconds = Math.max(0, Math.round(ms / 1000))
1027  if (seconds < 10) return 'just now'
1028  if (seconds < 60) return `${seconds}s ago`
1029  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
1030  if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h ago`
1031
1032  return `${Math.floor(seconds / 86_400)}d ago`
1033}
1034
1035// The desktop's lanes, a line each: its name, its bars as an SVG, then its totals in columns, each lane's under the
1036// one above: the time to the right of its own, then the count, then the tokens; in a pane of so many columns, the bars
1037// as wide as it leaves beside the name and the longest totals, so none of those wraps
1038function desktopLaneLines(kit: DesktopKit, lanes: Lane[], turn: TimelineTurn, columns?: number) {
1039  const { Box, Text, Svg } = kit
1040  // Wide enough for the longest count; a cell is wider than the desktop font's average letter
1041  const countCells = Math.ceil(Math.max(0, ...lanes.map(lane => countOfLane(lane).length + 2)) * DESKTOP_CELLS_PER_LETTER) + 1
1042  const restOf = (lane: Lane) => {
1043    const tokens = tokensOfLane(lane)
1044    const misses = missesOfLane(lane)
1045
1046    return `${tokens === '' ? '' : `· ${tokens}`}${misses === '' ? '' : ` · ${misses}`}`
1047  }
1048  const tailCells = 1 + DESKTOP_TIME_CELLS + 1 + countCells + Math.ceil(Math.max(0, ...lanes.map(lane => restOf(lane).length)) * DESKTOP_CELLS_PER_LETTER) + 1
1049  const width =
1050    columns === undefined
1051      ? LANE_WIDTH
1052      : Math.round(Math.max(MIN_LANE_WIDTH, Math.min(LANE_WIDTH, (columns - DESKTOP_LABEL_CELLS - tailCells) * DESKTOP_PX_PER_CELL)))
1053
1054  return lanes.map(lane => {
1055    const tokens = tokensOfLane(lane)
1056    const misses = missesOfLane(lane)
1057
1058    return (
1059      <Box key={`lane-${lane.name}`} flexDirection="row" alignItems="center">
1060        <Box width={DESKTOP_LABEL_CELLS}>
1061          <Text dimColor>{shortName(lane.name)}</Text>
1062        </Box>
1063        <Svg source={laneSvg(lane, turn, width)} width={width} height={12} alt={`${lane.name} ${laneTotal(lane)}`} />
1064        <Box marginLeft={1} width={DESKTOP_TIME_CELLS} justifyContent="flex-end">
1065          <Text dimColor>{formatMs(busyMs(lane.spans))}</Text>
1066        </Box>
1067        <Box marginLeft={1} width={countCells}>
1068          <Text dimColor>{`· ${countOfLane(lane)}`}</Text>
1069        </Box>
1070        {tokens !== '' && (
1071          <Text dimColor wrap="truncate">
1072            {`· ${tokens}`}
1073          </Text>
1074        )}
1075        {misses !== '' && (
1076          <Text color={SLOW_COLOR} wrap="truncate">
1077            {` · ${misses}`}
1078          </Text>
1079        )}
1080      </Box>
1081    )
1082  })
1083}
1084
1085// One call on the desktop's pane, in columns as its lanes are: its time, a pill against the slowest call's, its mark
1086// and name, then what it ran on
1087function desktopRowLine(kit: DesktopKit, row: Row, slowest: Row, nameCells: number, lead: string) {
1088  const { Box, Text, Svg } = kit
1089  const { call, ms } = row
1090  const isSlowest = row === slowest
1091  const tone = isSlowest || call.outcome === 'running' ? { color: SLOW_COLOR } : ms >= 1000 ? {} : { dimColor: true }
1092  const usual = usualNote(row)
1093  const progress = call.outcome === 'running' ? progressNote(row).trim() : ''
1094
1095  return (
1096    <Box flexDirection="row" alignItems="center">
1097      {/* The branch and the mark each in a column of its own: `└` is wider than `├`, `✗` than `·` */}
1098      <Box width={DESKTOP_MARK_CELLS}>
1099        <Text dimColor>{lead}</Text>
1100      </Box>
1101      <Box width={DESKTOP_TIME_CELLS} justifyContent="flex-end">
1102        <Text {...tone} {...(isSlowest ? { bold: true } : {})}>
1103          {formatMs(ms)}
1104        </Text>
1105      </Box>
1106      <Box marginLeft={1}>
1107        <Svg source={rowSvg(ms, slowest.ms)} width={DESKTOP_ROW_WIDTH} height={12} alt={formatMs(ms)} />
1108      </Box>
1109      <Box marginLeft={1} width={DESKTOP_MARK_CELLS}>
1110        <Text {...(call.outcome === 'error' || call.outcome === 'denied' ? { color: 'error' } : { dimColor: true })}>
1111          {MARKS[call.outcome]}
1112        </Text>
1113      </Box>
1114      <Box width={nameCells}>
1115        <Text bold={isSlowest} wrap="truncate">
1116          {labelOf(row)}
1117        </Text>
1118      </Box>
1119      {usual !== '' && <Text color={SLOW_COLOR}>{`${usual}  `}</Text>}
1120      {progress !== '' && <Text color={SLOW_COLOR}>{`${progress}  `}</Text>}
1121      <Text dimColor wrap="truncate">
1122        {call.detail}
1123      </Text>
1124    </Box>
1125  )
1126}
1127
1128// A desktop row's bar: one pill from the start, as long against the slowest call's as its time, on a faint track
1129function rowSvg(ms: number, longest: number): string {
1130  const width = longest > 0 ? Math.max(4, (ms / longest) * DESKTOP_ROW_WIDTH) : 0
1131  const pill = ms > 0 ? `<rect x="0" y="2" width="${width.toFixed(1)}" height="8" rx="4" fill="${TOOL_COLOR}"/>` : ''
1132
1133  return `<svg xmlns="http://www.w3.org/2000/svg" width="${DESKTOP_ROW_WIDTH}" height="12" viewBox="0 0 ${DESKTOP_ROW_WIDTH} 12"><line x1="0" x2="${DESKTOP_ROW_WIDTH}" y1="6" y2="6" stroke="${INK_COLOR}" stroke-opacity="0.3"/>${pill}</svg>`
1134}
1135
1136// The latest turn whose final answer ends with this reply block
1137function turnOfReply(all: TimelineTurn[], text: string): TimelineTurn | undefined {
1138  const block = text.trim()
1139  if (block === '') {
1140    return undefined
1141  }
1142
1143  return [...all].reverse().find(turn => typeof turn.answer === 'string' && isAnswerOf(turn.answer, block))
1144}
1145
1146// Whether a turn's answer ends with this block; one kept from an earlier run is its last letters alone, and a block
1147// longer than those ends with all of them
1148function isAnswerOf(answer: string, block: string): boolean {
1149  const kept = answer.trim()
1150
1151  return kept.endsWith(block) || (answer.length >= MAX_KEPT_ANSWER && block.endsWith(kept))
1152}
1153
1154// Claude first, then each tool by the time it kept busy; the subagents' own calls, a lane per tool (`↳ Bash`),
1155// right under the Agent lane they ran inside
1156function lanesOf(turn: TimelineTurn): Lane[] {
1157  // A turn kept by an earlier version of this mod has no request record to draw lanes from
1158  if (!Array.isArray(turn.steps)) {
1159    return []
1160  }
1161  const tools: Lane[] = []
1162  const inside: Lane[] = []
1163  for (const call of turn.calls) {
1164    // Right under the Agent lane, an arrow says enough
1165    const name = call.isSubagent ? `↳ ${call.tool}` : call.tool
1166    const group = call.isSubagent ? inside : tools
1167    let lane = group.find(one => one.name === name)
1168    if (lane === undefined) {
1169      lane = { name, spans: [], count: 0, unit: 'call' }
1170      group.push(lane)
1171    }
1172    lane.spans.push({ startedAt: call.startedAt, endedAt: call.endedAt ?? turn.endedAt })
1173    lane.count += 1
1174  }
1175  tools.sort((a, b) => busyMs(b.spans) - busyMs(a.spans))
1176  inside.sort((a, b) => busyMs(b.spans) - busyMs(a.spans))
1177  const agent = tools.findIndex(lane => lane.name === 'Agent')
1178  // An agent no Agent call of this turn spawned (one a mod started) has its calls' lanes last
1179  tools.splice(agent === -1 ? tools.length : agent + 1, 0, ...inside)
1180  const claude: Lane = {
1181    name: 'Claude',
1182    spans: turn.steps,
1183    count: turn.steps.length,
1184    unit: 'request',
1185    outputTokens: turn.steps.reduce((sum, step) => sum + (step.outputTokens ?? 0), 0),
1186    missed: turn.steps.filter(missedCache),
1187  }
1188
1189  return claude.count > 0 ? [claude, ...tools] : tools
1190}
1191
1192// `Claude ▆▆▆▁▁▆▁▁▁▁ 8.2s · 4 requests`: a cell is drawn as a bar while the lane was busy in its slice of the turn,
1193// in the slow violet where a request missed the prompt cache
1194function laneLine(Text: TextElement, lane: Lane, turn: TimelineTurn, pictures?: Pictures, layout: LaneLayout = UNFOLDED_LANE) {
1195  const total = Math.max(turn.endedAt - turn.startedAt, 1)
1196  const width = layout.cells
1197  let cells = ''
1198  let misses = ''
1199  for (let i = 0; i < width; i++) {
1200    const from = turn.startedAt + (total * i) / width
hooks/bars.ts 85 lines
1import type { ImageSource } from 'claude-code'
2
3// Bars as pictures, where the terminal draws them: a capsule in the line's middle, half of it tall, as the
4// desktop draws its lanes; no block glyph can, each standing on its cell's floor. Over the gaps a hairline track
5const CELL_WIDTH = 16
6const CELL_HEIGHT = 32
7const BAR_HEIGHT = 16
8const TRACK_HEIGHT = 2
9// A turn redraws its lines more often than they change; the same bar is not painted twice
10const MAX_KEPT = 200
11
12// A stretch of the bar's width, from 0 to 1, in the bar's color unless it has one of its own
13export type Stretch = { from: number; to: number; color?: string }
14export type Track = { color: string; alpha: number }
15
16const kept = new Map<string, ImageSource>()
17
18export function barPicture(columns: number, stretches: Stretch[], color: string, track: Track): ImageSource {
19  const key = JSON.stringify([columns, stretches, color, track])
20  const known = kept.get(key)
21  if (known !== undefined) {
22    return known
23  }
24  const width = columns * CELL_WIDTH
25  const height = CELL_HEIGHT
26  const middle = height / 2
27  const radius = BAR_HEIGHT / 2
28  // Each stretch as the line its capsule is drawn around; a short one still a whole dot, kept inside the picture
29  const cores = stretches.map(({ from, to, color: own }) => {
30    const end = Math.min(width, Math.max(to * width, from * width + BAR_HEIGHT))
31    const start = Math.max(0, Math.min(from * width, end - BAR_HEIGHT))
32    return [start + radius, end - radius, rgb(own ?? color)] as const
33  })
34  const line = rgb(track.color)
35  const fallback = rgb(color)
36  const pixels = new Uint8Array(width * height * 4)
37  for (let y = 0; y < height; y++) {
38    const py = y + 0.5
39    const lineAlpha = Math.abs(py - middle) <= TRACK_HEIGHT / 2 ? track.alpha : 0
40    for (let x = 0; x < width; x++) {
41      const px = x + 0.5
42      // How much of the pixel a capsule covers, a pixel's width of soft edge, in the color of the one covering most
43      let cover = 0
44      let bar = cores[0]?.[2] ?? fallback
45      for (const [a, z, own] of cores) {
46        const distance = Math.hypot(px - Math.min(Math.max(px, a), z), py - middle) - radius
47        const covers = Math.min(1, Math.max(0, 0.5 - distance))
48        if (covers > cover) {
49          cover = covers
50          bar = own
51        }
52      }
53      const alpha = cover + lineAlpha * (1 - cover)
54      if (alpha === 0) {
55        continue
56      }
57      const i = (y * width + x) * 4
58      for (let c = 0; c < 3; c++) {
59        pixels[i + c] = Math.round((bar[c]! * cover + line[c]! * lineAlpha * (1 - cover)) / alpha)
60      }
61      pixels[i + 3] = Math.round(alpha * 255)
62    }
63  }
64  const source: ImageSource = { rgba: base64(pixels), width, height }
65  kept.set(key, source)
66  if (kept.size > MAX_KEPT) {
67    kept.delete(kept.keys().next().value!)
68  }
69
70  return source
71}
72
73function rgb(hex: string): [number, number, number] {
74  const n = Number.parseInt(hex.slice(1), 16)
75  return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
76}
77
78function base64(bytes: Uint8Array): string {
79  let text = ''
80  for (let i = 0; i < bytes.length; i += 0x8000) {
81    text += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
82  }
83  return btoa(text)
84}
85
hooks/history.tsx 43 lines
1import type { ClientModule } from 'claude-code'
2
3import { SLOW_COLOR } from './summary'
4
5// Each column padded to the list's widest, so the lines' columns stand under one another
6export type HistoryProps = {
7  turnId: string
8  // How long ago it ended and with how many calls: `3m ago`, `7 tool calls`
9  age: string
10  count: string
11  slowest: string
12  // The start of what was asked, '' for a turn started without a typed prompt or kept by an earlier version
13  prompt: string
14}
15
16// An earlier turn's words in the pane, after its time and bar, as a region of its own: under the pointer they light
17// up, and a press unfolds the turn there or folds it again
18const History: ClientModule<HistoryProps, { isHovered: boolean }> = (props, surface) => {
19  if (surface.state === undefined) {
20    surface.setState({ isHovered: false })
21    surface.onPointer(event => {
22      if (event.type === 'enter' || event.type === 'leave') {
23        surface.setState({ isHovered: event.type === 'enter' })
24      }
25      if (event.type === 'down' && event.button === 'left') {
26        surface.post({ open: props.turnId })
27      }
28    })
29  }
30  const { Text } = surface.elements
31  const isLit = surface.state?.isHovered === true
32
33  return (
34    <Text wrap="truncate">
35      <Text dimColor={!isLit}>{` ${props.age}  ${props.count}  `}</Text>
36      <Text color={SLOW_COLOR}>{props.slowest}</Text>
37      {props.prompt !== '' && <Text dimColor={!isLit}>{`  ${props.prompt}`}</Text>}
38    </Text>
39  )
40}
41
42export default History
43
hooks/row.tsx 59 lines
1import type { ClientModule } from 'claude-code'
2
3import { SLOW_COLOR } from './summary'
4
5export type RowProps = {
6  turnId: string
7  callId: string
8  mark: string
9  isFailed: boolean
10  label: string
11  // The spaces after the label that line the rows' words up, drawn apart so the underline stops at the name
12  pad: string
13  isSlowest: boolean
14  usual: string
15  detail: string
16  // Whether Claude's answer hangs under the row
17  isOpen: boolean
18  // Whether the call took long enough to ask about; a short one's words stay as they are under the pointer
19  canAsk: boolean
20}
21
22// The words of an unfolded call row on the terminal, as a region of its own: under the pointer the name underlines
23// and a dim hint says what a press does, which asks Claude why the call took as long as it did
24const Row: ClientModule<RowProps, { isHovered: boolean }> = (props, surface) => {
25  if (surface.state === undefined) {
26    surface.setState({ isHovered: false })
27    surface.onPointer(event => {
28      if (!props.canAsk) {
29        return
30      }
31      if (event.type === 'enter' || event.type === 'leave') {
32        surface.setState({ isHovered: event.type === 'enter' })
33      }
34      if (event.type === 'down' && event.button === 'left') {
35        surface.post({ why: { turnId: props.turnId, callId: props.callId } })
36      }
37    })
38  }
39  const { Text } = surface.elements
40  const isLit = surface.state?.isHovered === true
41
42  return (
43    <Text wrap="truncate">
44      {' '}
45      <Text {...(props.isFailed ? { color: 'error' } : { dimColor: true })}>{props.mark}</Text>
46      {' '}
47      <Text bold={props.isSlowest} underline={isLit}>
48        {props.label}
49      </Text>
50      {props.pad}
51      {props.usual !== '' && <Text color={SLOW_COLOR}>{`  ${props.usual}`}</Text>}
52      <Text dimColor={!isLit}>{props.detail === '' ? '' : `  ${props.detail}`}</Text>
53      {isLit && <Text dimColor>{props.isOpen ? '  click to hide' : '  click to ask why'}</Text>}
54    </Text>
55  )
56}
57
58export default Row
59
hooks/summary.tsx 51 lines
1import type { ClientModule, ElementConstructor, RenderElement, TextProps } from 'claude-code'
2
3// The terminal's tool bars, fixed: a pale sky, where a theme's `suggestion` can be a loud cyan; the floor under
4// them dim, so the tall ones are what the eye finds
5export const BAR_COLOR = '#86bbd8'
6// The slowest call, and one still running, in a soft violet beside them, where a theme's `warning` is a loud yellow
7export const SLOW_COLOR = '#b48ead'
8
9export type SummaryProps = { turnId: string; count: string; slowest: string; sparkline: string; isOpen: boolean }
10
11// A turn's folded line on the terminal, as a region of its own: a Button inverts under the pointer, where this only
12// lights its dim words, as the engine's own folded rows do, and a press anywhere on the line folds or unfolds the turn
13const Summary: ClientModule<SummaryProps, { isHovered: boolean }> = (props, surface) => {
14  if (surface.state === undefined) {
15    surface.setState({ isHovered: false })
16    surface.onPointer(event => {
17      if (event.type === 'enter' || event.type === 'leave') {
18        surface.setState({ isHovered: event.type === 'enter' })
19      }
20      if (event.type === 'down' && event.button === 'left') {
21        surface.post({ toggle: props.turnId })
22      }
23    })
24  }
25  const { Text } = surface.elements
26  const isLit = surface.state?.isHovered === true
27
28  // One line, cut at the edge where the transcript is narrow (a pane docked beside it), not each part wrapped apart
29  return (
30    <Text wrap="truncate">
31      <Text dimColor>{'  ⎿  '}</Text>
32      <Text bold dimColor={!isLit}>{props.count}</Text>
33      <Text dimColor={!isLit}>{' · slowest '}</Text>
34      <Text color={SLOW_COLOR}>{props.slowest}</Text>
35      {'  '}
36      {sparkRuns(Text, props.sparkline)}
37      {'  '}
38      <Text dimColor={!isLit}>{props.isOpen ? '(click to fold)' : '(click to expand)'}</Text>
39    </Text>
40  )
41}
42
43// `▁▁█▁▅`: the floor's runs dim, the bars above it in full
44export function sparkRuns(Text: ElementConstructor<TextProps>, sparkline: string): RenderElement[] {
45  return (sparkline.match(/▁+|[^▁]+/g) ?? []).map(run =>
46    Text({ color: BAR_COLOR, ...(run.startsWith('▁') ? { dimColor: true } : {}), children: [run] }),
47  )
48}
49
50export default Summary
51
hooks/earlier.tsx 32 lines
1import type { ClientModule } from 'claude-code'
2
3export type EarlierProps = {
4  // `… 6 earlier calls`
5  words: string
6}
7
8// The current turn's line in the pane for its calls before its latest ten, as a region of its own: under the pointer
9// its words light up, and a press lists those calls under it or leaves them out again
10const Earlier: ClientModule<EarlierProps, { isHovered: boolean }> = (props, surface) => {
11  if (surface.state === undefined) {
12    surface.setState({ isHovered: false })
13    surface.onPointer(event => {
14      if (event.type === 'enter' || event.type === 'leave') {
15        surface.setState({ isHovered: event.type === 'enter' })
16      }
17      if (event.type === 'down' && event.button === 'left') {
18        surface.post({ earlier: true })
19      }
20    })
21  }
22  const { Text } = surface.elements
23
24  return (
25    <Text dimColor={surface.state?.isHovered !== true} wrap="truncate">
26      {props.words}
27    </Text>
28  )
29}
30
31export default Earlier
32
types/index.d.ts 81 lines
1export type TimelineOutcome = 'running' | 'ok' | 'error' | 'denied'
2
3export type TimelineCall = {
4  id: string
5  tool: string
6  detail: string
7  isSubagent: boolean
8  startedAt: number
9  endedAt: number | null
10  outcome: TimelineOutcome
11  // The middle of the times calls like it took in earlier runs, when there were enough of them
12  usualMs?: number
13  // For a call long enough to ask about: the Bash command it ran, and the end of what it gave back, so Claude answers
14  // why it took that long from them, not from a guess once the conversation holds them no more
15  command?: string
16  output?: string
17}
18
19// One stretch of the turn's time: a model request of the main loop, or a tool call
20export type TimelineSpan = {
21  startedAt: number
22  endedAt: number
23}
24
25// A model request: its stretch, the tokens its response wrote, and of its prompt's tokens how many the prompt
26// cache served (each absent on one kept by an earlier version)
27export type TimelineStep = TimelineSpan & {
28  outputTokens?: number
29  promptTokens?: number
30  cachedTokens?: number
31}
32
33// A finished main-loop turn, matched to its closing line by its duration
34export type TimelineTurn = {
35  turnId: string
36  durationMs: number
37  startedAt: number
38  endedAt: number
39  calls: TimelineCall[]
40  steps: TimelineStep[]
41  // The final answer's text, which finds the turn's reply on the desktop
42  answer: string
43  // The start of what was asked, which the pane lists the turn by; absent on one kept by an earlier version
44  prompt?: string
45}
46
47// Claude's answer to why a call took as long as it did, asked from its row: while it is asked, its text, or why
48// there is none; and whether it hangs under the row
49export type TimelineWhy = {
50  state: 'asking' | 'answered' | 'failed'
51  text: string
52  isShown: boolean
53}
54
55declare module 'claude-code' {
56  interface PluginState {
57    'tool-timeline': {
58      calls: TimelineCall[]
59      steps: TimelineStep[]
60      turnStartedAt: number
61      // When the turn ended, none while it runs; when the model request under way was sent, none between requests
62      turnEndedAt: number | null
63      stepStartedAt: number | null
64      now: number
65      turns: TimelineTurn[]
66      expanded: string[]
67      // By `<turnId>:<callId>`
68      whys: Record<string, TimelineWhy>
69      // What Claude was last told of this project's usual times; '' before the first prompt and after a compaction
70      toldUsual: string
71      // How many times in a row each Bash command failed, by its kind; a command that ran through is let go
72      failStreaks: Record<string, number>
73      // The start of what the current turn was asked; the earlier turns the pane has unfolded, by id; the turn whose
74      // calls before its latest ten the pane lists too, by when it started, none while it lists those ten alone
75      turnPrompt: string
76      paneOpen: string[]
77      paneAllCalls: number | null
78    }
79  }
80}
81