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

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>
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:
↳ Bash).Bash ×3). ✗ marks a failed call, ⊘ one refused: by a rule, by you at the permission prompt, or by auto mode's classifier.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.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">
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">
/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">
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">
Notification hooks get it.Bash failed 3 times in a row · npm test), a good moment to step in.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.
/tmp/tool-timeline/<session>; a session removes, as it starts, the folders not written for a day.hooks/register.tsx 1772 lines1import { 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) / widthhooks/bars.ts 85 lines1import 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}
85hooks/history.tsx 43 lines1import 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
43hooks/row.tsx 59 lines1import 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
59hooks/summary.tsx 51 lines1import 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
51hooks/earlier.tsx 32 lines1import 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
32types/index.d.ts 81 lines1export 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