A side panel with a live tool log grouped by prompt, the plan, changed files with diffs and subagents; folds tool calls in the chat and replaces the spinner…

Personal Claude Code mods for a calmer, more readable terminal.
| Mod | What it does |
|---|---|
readable-width | Wraps replies and prompts at 80 columns; tables and code blocks that name a language keep the full width; an unlabelled or text block wraps like prose. Your prompts are bold and accent-colored, with a blank line above each message. |
usage-meters | One band above the prompt: ctx, 5h and 7d meters, green under 50%, yellow under 80%, red above; 5h also shows the time left until it resets. |
tool-log | Folds tool calls and their results in the chat into a dim › N tools line and keeps them in a side log grouped by prompt. Toggle it with [ panel ], /panel or a key bound to app:toggleDiffPreSession (ctrl+t in ~/.claude/keybindings.json, see below; this replaces the built-in todo list toggle, which the Todo tab covers); a click on a chat line opens it on that prompt's steps, until the next prompt or ✕ returns it to live. The chat line also sums file changes and failures (› 6 tools · 2 files changed +14 −3 · 1 failed). A turn longer than 30 s ends with a desktop notification. Successful Bash rows expand to show their command, and Edit/Write rows to show their diff. Inside a git repo a [ diff ] button opens the built-in /diff panel. The panel has four tabs: Log, Files (every file changed this session with its diff), Agents (subagents with status, time, tool count and their latest step) and Todo (Claude's plan); [ panel ] reopens it on the tab last in view. The active tab is orange, a tab with something new since you last looked is bright, the rest are dim. Replaces the spinner with a minimal one that names the current step and the plan's progress, e.g. ⠋ 12s · 3/7 · Reading register.tsx or ⠙ 3s · Thinking. |
turn-details | Replaces the Brewed for 34s · done 1:09 PM line with 📋 details ▸, which folds the turn's duration, finish time, tool calls and model open in place. |
From GitHub:
claude plugin marketplace add Tvrtk0/claude-code-mods
claude plugin install readable-width@local-mods
claude plugin install usage-meters@local-mods
claude plugin install tool-log@local-mods
claude plugin install turn-details@local-mods
From a local clone (edits apply after /reload-plugins, no reinstall):
claude plugin marketplace add ~/.claude/mods
claude plugin install readable-width@local-mods
claude plugin install usage-meters@local-mods
claude plugin install tool-log@local-mods
claude plugin install turn-details@local-mods
Installed from a local clone, plugins are read straight from the folder.
claude plugin validate ~/.claude/mods/<mod>
claude plugin test ~/.claude/mods/<mod>
A mod cannot add a key of its own, so the [ panel ] button borrows an engine action that only the /diff panel handles. Bind any key to it:
{
"$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"bindings": [
{ "context": "Global", "bindings": { "ctrl+t": "app:toggleDiffPreSession" } }
]
}
ctrl+t hands the panel the keyboard (opening it if needed) and closes it when it already has it. Inside: h/l or 1–4 switch tabs, j/k move between rows (starting from the newest), enter opens one, d/u scroll half a window, g/b jump to the first or last row, esc returns to the prompt and leaves the panel open. The panel scrolls itself, so its tabs and keys stay at the top; Bottom is b, not G: a button hotkey cannot tell shift+g from g.
hooks/register.tsx 1200 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderNode, Timer } from 'claude-code'
3
4import type { AgentRow, LogEntry, LogGroup, Tab, TodoItem } from '../types'
5
6const PANE = 'tool-log'
7const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
8const FRAME_MS = 120
9const NOTIFY_AFTER_MS = 30_000
10
11// The log's own background, a dark plum in place of the engine's grey.
12const PANE_BG = '#2a2133'
13
14const groups = atom({ plugin: 'tool-log', key: 'groups' } as const, [])
15const MAX_GROUPS = 50
16const expanded = atom({ plugin: 'tool-log', key: 'expanded' } as const, [])
17const isRepo = atom({ plugin: 'tool-log', key: 'isRepo' } as const, false)
18// The prompt group a chat line opened the log on; null follows the newest.
19const focus = atom({ plugin: 'tool-log', key: 'focus' } as const, null)
20
21// The most patch text kept per call; whole hunks only, so the diff still parses.
22const PATCH_LIMIT = 20_000
23const tick = atom({ plugin: 'tool-log', key: 'tick' } as const, {
24 frame: 0,
25 elapsedMs: 0,
26 now: 0,
27})
28const tabAtom = atom({ plugin: 'tool-log', key: 'tab' } as const, 'log')
29const todos = atom({ plugin: 'tool-log', key: 'todos' } as const, [])
30const agents = atom({ plugin: 'tool-log', key: 'agents' } as const, [])
31// Each tab's content signature when it was last in view: a tab whose
32// signature has moved on since shows as unread.
33const seen = atom({ plugin: 'tool-log', key: 'seen' } as const, {})
34// The panel scrolls itself so its tabs stay put: rows scrolled off the top,
35// per tab; null keeps the log pinned to its newest line.
36const scrollTop = atom({ plugin: 'tool-log', key: 'scrollTop' } as const, {})
37
38const TABS: Tab[] = ['log', 'files', 'agents', 'todo']
39const TAB_TITLES: Record<Tab, string> = {
40 log: 'Log',
41 todo: 'Todo',
42 files: 'Files',
43 agents: 'Agents',
44}
45const ENDED = new Set<AgentRow['status']>(['completed', 'failed', 'killed'])
46
47const pad = (n: number) => String(n).padStart(2, '0')
48
49const clockTime = (ms: number) => {
50 const d = new Date(ms)
51
52 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
53}
54
55const elapsed = (ms: number) => {
56 const s = Math.floor(ms / 1000)
57
58 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${pad(s % 60)}s`
59}
60
61const countLabel = (n: number) => `${n} ${n === 1 ? 'tool' : 'tools'}`
62
63const basename = (path: string) => path.split('/').pop() ?? path
64
65const dirOf = (path: string) => path.slice(0, path.lastIndexOf('/')) || '/'
66
67const home = (text: string) => text.replace(/\/Users\/[^/\s]+/g, '~')
68
69const firstLine = (text: string) => text.split('\n')[0] ?? ''
70
71const quoted = (text: string) => `"${text}"`
72
73const hostOf = (url: string) => url.replace(/^\w+:\/\//, '').split('/')[0] ?? url
74
75// What a call did, in words: a Bash call's own description, else the file,
76// pattern or task it worked on. The raw command rides along for failures,
77// and `activity` is the verb-and-object line the spinner shows while it runs.
78function describe(
79 tool: string,
80 input: unknown,
81): { label: string; command?: string; activity: string } {
82 const fields = (input ?? {}) as Record<string, unknown>
83 const pick = (key: string) =>
84 typeof fields[key] === 'string' ? (fields[key] as string) : undefined
85
86 const command = pick('command')
87 if (command !== undefined) {
88 const description = pick('description')
89 const first = home(firstLine(command))
90
91 return {
92 label: description ?? first,
93 command: home(command),
94 activity: description ?? `Running ${first.split(' ')[0] ?? first}`,
95 }
96 }
97
98 const path = pick('file_path') ?? pick('notebook_path') ?? pick('path')
99 const pattern = pick('pattern')
100 const url = pick('url')
101 const task = pick('description') ?? pick('prompt')
102
103 const activity =
104 tool === 'Read' && path !== undefined
105 ? `Reading ${basename(path)}`
106 : (tool === 'Edit' || tool === 'NotebookEdit') && path !== undefined
107 ? `Editing ${basename(path)}`
108 : tool === 'Write' && path !== undefined
109 ? `Writing ${basename(path)}`
110 : tool === 'Grep' && pattern !== undefined
111 ? `Searching ${quoted(pattern)}`
112 : tool === 'Glob' && pattern !== undefined
113 ? `Finding ${pattern}`
114 : tool === 'WebFetch' && url !== undefined
115 ? `Fetching ${hostOf(url)}`
116 : tool === 'WebSearch'
117 ? 'Searching the web'
118 : (tool === 'Agent' || tool === 'Task') && task !== undefined
119 ? `Delegating ${firstLine(task)}`
120 : `Running ${tool}`
121
122 if (path !== undefined) return { label: basename(path), activity }
123
124 return {
125 label: home(firstLine(task ?? pattern ?? url ?? pick('query') ?? '')),
126 activity,
127 }
128}
129
130type Hunk = {
131 oldStart: number
132 oldLines: number
133 newStart: number
134 newLines: number
135 lines: string[]
136}
137
138// An Edit or Write result's hunks as unified-diff text, with the lines each
139// side gained and lost; undefined for any other result.
140function patchOf(
141 result: unknown,
142): { path?: string; patch: string; added: number; removed: number } | undefined {
143 const fields = (result ?? {}) as Record<string, unknown>
144 const hunks = fields.structuredPatch
145
146 if (!Array.isArray(hunks) || hunks.length === 0) return undefined
147
148 let patch = ''
149 let added = 0
150 let removed = 0
151
152 for (const hunk of hunks as Hunk[]) {
153 for (const line of hunk.lines) {
154 if (line.startsWith('+')) added += 1
155 else if (line.startsWith('-')) removed += 1
156 }
157
158 const text = `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join('\n')}\n`
159 if (patch.length + text.length <= PATCH_LIMIT) patch += text
160 }
161
162 const path = typeof fields.filePath === 'string' ? fields.filePath : undefined
163
164 return { path, patch, added, removed }
165}
166
167// `2 files changed +14 −3` for the calls of one chat row, from the log.
168function changesOf(all: LogGroup[], ids: readonly (string | undefined)[]) {
169 const wanted = new Set(ids)
170 const files = new Set<string>()
171 let added = 0
172 let removed = 0
173
174 for (const g of all) {
175 for (const x of g.entries) {
176 if (!wanted.has(x.id) || x.patch === undefined) continue
177 files.add(x.path ?? x.id)
178 added += x.added ?? 0
179 removed += x.removed ?? 0
180 }
181 }
182
183 if (files.size === 0) return ''
184
185 return ` · ${files.size} ${files.size === 1 ? 'file' : 'files'} changed +${added} −${removed}`
186}
187
188type FileChange = {
189 path: string
190 added: number
191 removed: number
192 edits: number
193 patch: string
194}
195
196// Every file the session's calls changed, newest first, its hunks joined.
197function filesOf(all: LogGroup[]): FileChange[] {
198 const byPath = new Map<string, FileChange>()
199
200 for (const g of all) {
201 for (const x of g.entries) {
202 if (x.patch === undefined || x.path === undefined) continue
203 const prev = byPath.get(x.path)
204 byPath.delete(x.path)
205 const patch = (prev?.patch ?? '') + x.patch
206 byPath.set(x.path, {
207 path: x.path,
208 added: (prev?.added ?? 0) + (x.added ?? 0),
209 removed: (prev?.removed ?? 0) + (x.removed ?? 0),
210 edits: (prev?.edits ?? 0) + 1,
211 patch: patch.length > PATCH_LIMIT * 3 ? (prev?.patch ?? x.patch) : patch,
212 })
213 }
214 }
215
216 return [...byPath.values()].reverse()
217}
218
219// The plan Claude keeps with TodoWrite, or with TaskCreate and TaskUpdate.
220async function trackTodos($: EngineInterface, tool: string, input: unknown, result: unknown) {
221 const fields = (input ?? {}) as Record<string, unknown>
222 const text = (key: string) => (typeof fields[key] === 'string' ? (fields[key] as string) : undefined)
223 const isStatus = (v: unknown): v is TodoItem['status'] =>
224 v === 'pending' || v === 'in_progress' || v === 'completed'
225
226 if (tool === 'TodoWrite' && Array.isArray(fields.todos)) {
227 const items: TodoItem[] = (fields.todos as Record<string, unknown>[]).map((t, i) => ({
228 id: `t${i}`,
229 content: typeof t.content === 'string' ? t.content : '',
230 activeForm: typeof t.activeForm === 'string' ? t.activeForm : undefined,
231 status: isStatus(t.status) ? t.status : 'pending',
232 }))
233 await update($, todos, () => items)
234 return
235 }
236
237 if (tool === 'TaskCreate') {
238 const id = ((result ?? {}) as { task?: { id?: unknown } }).task?.id
239 const subject = text('subject')
240 if (typeof id !== 'string' || subject === undefined) return
241 const item: TodoItem = { id, content: subject, activeForm: text('activeForm'), status: 'pending' }
242 await update($, todos, list => [...(list ?? []).filter(x => x.id !== id), item])
243 return
244 }
245
246 const id = text('taskId')
247 if (tool !== 'TaskUpdate' || id === undefined) return
248
249 if (fields.status === 'deleted') {
250 await update($, todos, list => (list ?? []).filter(x => x.id !== id))
251 return
252 }
253
254 await update($, todos, list =>
255 (list ?? []).map(x =>
256 x.id !== id
257 ? x
258 : {
259 ...x,
260 content: text('subject') ?? x.content,
261 activeForm: text('activeForm') ?? x.activeForm,
262 status: isStatus(fields.status) ? fields.status : x.status,
263 },
264 ),
265 )
266}
267
268// Brings each subagent's status up to date from the engine's roster.
269async function refreshAgents($: EngineInterface) {
270 if ((await read($, agents)).length === 0) return
271
272 const live = new Map((await $.agent.list()).map(a => [a.id, a.status]))
273 const now = await $.clock.now()
274
275 await update($, agents, list =>
276 (list ?? []).map(row => {
277 const status = live.get(row.id) ?? row.status
278
279 return { ...row, status, endedAt: ENDED.has(status) ? (row.endedAt ?? now) : undefined }
280 }),
281 )
282 await update($, tick, prev => ({ frame: prev?.frame ?? 0, elapsedMs: prev?.elapsedMs ?? 0, now }))
283}
284
285function signatures(
286 all: LogGroup[],
287 plan: TodoItem[],
288 crew: AgentRow[],
289): Record<Tab, string> {
290 return {
291 log: String(all.reduce((n, g) => n + g.entries.length, 0)),
292 todo: plan.map(t => t.id + t.status).join(','),
293 files: filesOf(all).map(f => f.path + f.edits).join(','),
294 agents: crew.map(a => a.id + a.status).join(','),
295 }
296}
297
298async function markSeen($: EngineInterface, tabs: readonly Tab[]) {
299 const now = signatures(await read($, groups), await read($, todos), await read($, agents))
300 await update($, seen, prev => {
301 const next = { ...(prev ?? {}) }
302 for (const t of tabs) next[t] = now[t]
303 return next
304 })
305}
306
307// The pane's pressable rows in drawing order, as the last drawing laid them
308// out, and the one j and k last moved to; a reload starts both over.
309let rowKeys: string[] = []
310let cursorKey: string | null = null
311
312// Keeps the focus ring on a row and that row in view.
313// What the last drawing laid out: each pressable row's first line, the
314// content's height, the window's height and how far it was scrolled.
315let layout = { tab: 'log' as Tab, rowLine: new Map<string, number>(), content: 0, view: 1, offset: 0 }
316
317const maxOffset = () => Math.max(0, layout.content - layout.view)
318
319async function setOffset($: EngineInterface, offset: number) {
320 const tab = layout.tab
321 const clamped = Math.max(0, Math.min(maxOffset(), Math.round(offset)))
322 // Remembered at once, so the next key counts from here, not from the
323 // last drawing.
324 layout = { ...layout, offset: clamped }
325 // The log at its bottom stays pinned there as new rows arrive.
326 const value = tab === 'log' && clamped >= maxOffset() ? null : clamped
327 await update($, scrollTop, prev => ({ ...(prev ?? {}), [tab]: value }))
328}
329
330async function scrollBy($: EngineInterface, rows: number) {
331 await setOffset($, layout.offset + rows)
332}
333
334// Keeps the focus ring on a row and scrolls the window to show it.
335async function focusRow($: EngineInterface, key: string) {
336 cursorKey = key
337 try {
338 await $.ui.focus({ requestId: PANE, key })
339 } catch {}
340 const line = layout.rowLine.get(key)
341 if (line === undefined) return
342 // Like vim's scrolloff: keep a margin of rows around the selected one, so a
343 // row height the layout guessed a little wrong still leaves it in view.
344 const margin = Math.max(1, Math.min(5, Math.floor(layout.view / 4)))
345 if (line < layout.offset + margin) await setOffset($, line - margin)
346 else if (line > layout.offset + layout.view - 1 - margin) await setOffset($, line - layout.view + 1 + margin)
347}
348
349// j and k: the first press lands on the newest row, at the bottom.
350async function moveCursor($: EngineInterface, by: number) {
351 if (rowKeys.length === 0) return
352 const at = cursorKey === null ? -1 : rowKeys.indexOf(cursorKey)
353 const next = at === -1 ? rowKeys.length - 1 : Math.min(rowKeys.length - 1, Math.max(0, at + by))
354 const key = rowKeys[next]
355 if (key !== undefined) await focusRow($, key)
356}
357
358// g and b: the top or the bottom, with the focus on the first or last row.
359async function jumpTo($: EngineInterface, end: 'first' | 'last') {
360 const key = end === 'first' ? rowKeys[0] : rowKeys[rowKeys.length - 1]
361 await setOffset($, end === 'first' ? 0 : Number.MAX_SAFE_INTEGER)
362 if (key === undefined) return
363 cursorKey = key
364 try {
365 await $.ui.focus({ requestId: PANE, key })
366 } catch {}
367}
368
369async function toggleExpanded($: EngineInterface, key: string) {
370 // Hold the window where it is, so the row does not move under the eye.
371 await setOffset($, layout.offset)
372 if (layout.tab === 'log' && layout.offset >= maxOffset()) {
373 const tab = layout.tab
374 await update($, scrollTop, prev => ({ ...(prev ?? {}), [tab]: layout.offset }))
375 }
376 await update($, expanded, ids =>
377 (ids ?? []).includes(key)
378 ? (ids ?? []).filter(x => x !== key)
379 : [...(ids ?? []), key].slice(-100),
380 )
381 // The row grew or shrank under the focus: put it back on the same row.
382 await focusRow($, key)
383}
384
385// What the turn is doing when no tool runs, by the spinner's own mode.
386const MODE_ACTIVITY: Record<string, string> = {
387 requesting: 'Waiting for model',
388 thinking: 'Thinking',
389 responding: 'Writing reply',
390 'tool-input': 'Preparing a tool call',
391 'tool-use': 'Using tools',
392}
393
394// Applies fn to the newest group, opening one first when there is none.
395function onLatest(list: LogGroup[] | undefined, at: number, fn: (g: LogGroup) => LogGroup) {
396 const all = list ?? []
397 const last = all[all.length - 1]
398 const latest = last ?? { id: `g${at}`, prompt: '', startedAt: at, entries: [] }
399
400 return [...all.slice(0, last ? -1 : undefined), fn(latest)]
401}
402
403const isLogOpen = async ($: EngineInterface) =>
404 (await $.ui.panes()).some(pane => pane.id === PANE)
405
406const openPane = ($: EngineInterface, focus?: true) =>
407 $.ui.open({ id: PANE, title: 'Panel', columns: 56, ...(focus ? { focus } : {}) })
408
409// [ panel ] and /panel: open the panel on the tab last in view, or close it.
410async function toggle($: EngineInterface) {
411 await update($, focus, () => null)
412
413 const tab = await read($, tabAtom)
414 await markSeen($, [tab])
415
416 if (await isLogOpen($)) {
417 await $.ui.close({ id: PANE })
418 } else {
419 if (tab === 'agents') await refreshAgents($)
420 await openPane($)
421 }
422}
423
424// ctrl+t: hand the panel the keyboard, opening it if it is closed; when it
425// already holds the keyboard, close it. Esc alone gives the keys back.
426async function focusPanel($: EngineInterface) {
427 const pane = (await $.ui.panes()).find(x => x.id === PANE)
428 const tab = await read($, tabAtom)
429 await markSeen($, [tab])
430
431 if (pane?.isFocused) {
432 await update($, focus, () => null)
433 await $.ui.close({ id: PANE })
434 return
435 }
436
437 if (pane === undefined && tab === 'agents') await refreshAgents($)
438 await openPane($, true)
439}
440
441// A chat line: open the log on its own prompt's group; pressed again while
442// that group is in view, close the log.
443async function focusOn($: EngineInterface, ids: readonly (string | undefined)[]) {
444 const wanted = new Set(ids)
445 const group = (await read($, groups)).find(g => g.entries.some(x => wanted.has(x.id)))
446 const isOpen = await isLogOpen($)
447 const prevTab = await read($, tabAtom)
448 const isOnLog = prevTab === 'log'
449 await markSeen($, [prevTab, 'log'])
450 await update($, tabAtom, () => 'log')
451
452 if (group === undefined) {
453 if (!isOpen) await openPane($)
454 return
455 }
456
457 if (isOpen && isOnLog && (await read($, focus)) === group.id) {
458 await update($, focus, () => null)
459 await $.ui.close({ id: PANE })
460 return
461 }
462
463 await update($, focus, () => group.id)
464 if (!isOpen) await openPane($)
465}
466
467// The running turn's start and its animation timer; a reload starts over.
468let startedAt: number | null = null
469let timer: Timer | null = null
470
471async function start($: EngineInterface) {
472 if (timer !== null) return
473
474 startedAt = await $.clock.now()
475 timer = $.clock.every(FRAME_MS, async () => {
476 const now = await $.clock.now()
477 let frame = 0
478 await update($, tick, prev => {
479 frame = ((prev?.frame ?? 0) + 1) % FRAMES.length
480
481 return { frame, elapsedMs: now - (startedAt ?? now), now }
482 })
483
484 // About once a second, the subagents' status too.
485 if (frame === 0) {
486 try {
487 await refreshAgents($)
488 } catch {}
489 }
490 })
491}
492
493function stop() {
494 timer?.cancel()
495 timer = null
496 startedAt = null
497}
498
499export const register: Register = on => {
500 on('session.start', async ($, e, next) => {
501 await $.command.register({
502 name: 'panel',
503 description: 'Toggle the side panel: log, todo, files, agents',
504 })
505 const started = await next(e)
506
507 // The [ diff ] button opens the engine's git diff panel, so it shows only
508 // inside a work tree.
509 try {
510 const git = await $.process.run(['git', 'rev-parse', '--is-inside-work-tree'], {
511 cwd: started.cwd,
512 timeoutMs: 5000,
513 })
514 await update($, isRepo, () => git.exitCode === 0 && git.stdout.trim() === 'true')
515 } catch {
516 await update($, isRepo, () => false)
517 }
518
519 return started
520 })
521
522 on('command.run', { command: 'panel' }, async $ => {
523 await toggle($)
524
525 return { text: '' }
526 })
527
528 on('prompt.submit', async ($, e, next) => {
529 try {
530 await start($)
531
532 if (e.origin.kind === 'composer') {
533 const at = await $.clock.now()
534 const group: LogGroup = {
535 id: `g${at}`,
536 prompt: firstLine(e.text),
537 startedAt: at,
538 entries: [],
539 }
540 await update($, groups, list => [...(list ?? []), group].slice(-MAX_GROUPS))
541 // A new prompt takes the log back to what is happening now.
542 await update($, focus, () => null)
543 }
544
545 // A task notification often means a background subagent ended.
546 await refreshAgents($)
547 } catch {}
548
549 return next(e)
550 })
551
552 on('turn.start', async ($, e, next) => {
553 await start($)
554
555 return next(e)
556 })
557
558 on('turn.complete', async ($, e, next) => {
559 stop()
560
561 try {
562 await refreshAgents($)
563 } catch {}
564
565 // A long turn ends with a desktop notification, for when you looked away.
566 try {
567 if (!e.isAborted && e.durationMs >= NOTIFY_AFTER_MS) {
568 const prompt = (await read($, groups)).at(-1)?.prompt ?? ''
569 const gist = prompt.length > 60 ? `${prompt.slice(0, 59)}…` : prompt
570 await $.ui.notify(gist === '' ? 'Done' : `Done: ${gist}`, { title: 'Claude Code' })
571 }
572 } catch {}
573
574 return next(e)
575 })
576
577 on('agent.spawn', async ($, e, next) => {
578 const spawned = await next(e)
579
580 try {
581 if (spawned.deny === undefined && spawned.agentId !== undefined) {
582 const row: AgentRow = {
583 id: spawned.agentId,
584 description: e.description,
585 type: e.subagentType,
586 startedAt: await $.clock.now(),
587 status: 'running',
588 lastStep: '',
589 tools: 0,
590 }
591 await update($, agents, list => [...(list ?? []).filter(a => a.id !== row.id), row].slice(-30))
592 }
593 } catch {}
594
595 return spawned
596 })
597
598 // The log must never stand in a call's way: its own failures are swallowed
599 // and next(e) runs exactly once either way.
600 on('tool.call', async ($, e, next) => {
601 const id = e.tool_use_id
602
603 // A subagent's calls count toward its row in Agents, not the log.
604 if (e.agentId !== undefined) {
605 const agentId = e.agentId
606 try {
607 const step = describe(e.tool, e).activity
608 await update($, agents, list =>
609 (list ?? []).map(a => (a.id === agentId ? { ...a, tools: a.tools + 1, lastStep: step } : a)),
610 )
611 } catch {}
612
613 return next(e)
614 }
615
616 try {
617 const entry: LogEntry = {
618 id,
619 tool: e.tool,
620 ...describe(e.tool, e),
621 status: 'running',
622 }
623 const at = await $.clock.now()
624 await update($, groups, list =>
625 onLatest(list, at, g => ({ ...g, entries: [...g.entries, entry] })),
626 )
627 } catch {}
628
629 const ran = await next(e)
630
631 try {
632 const status = ran.deny !== undefined || ran.isError === true ? 'error' : 'ok'
633 const change = status === 'ok' ? patchOf(ran.result) : undefined
634 await update($, groups, list =>
635 (list ?? []).map(g => ({
636 ...g,
637 entries: g.entries.map(one =>
638 one.id === id ? { ...one, status, ...(change ?? {}) } : one,
639 ),
640 })),
641 )
642 if (status === 'ok') await trackTodos($, e.tool, e, ran.result)
643 } catch {}
644
645 return ran
646 })
647
648 // In the chat a run of tool calls is one dim, pressable line; the log holds
649 // the rest. An expanded group unfolds into ToolUse rows of its own.
650 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
651 if (e.surface !== 'terminal' || e.props.isExpanded) {
652 return next(e)
653 }
654
655 const { Box, Button, Text } = $.ui.resolve(e)
656 const changes = changesOf(
657 await read($, groups),
658 e.props.calls.map(call => call.tool_use_id),
659 )
660 const failed = e.props.calls.filter(call => call.isErrored).length
661
662 return (
663 <Box marginTop={1}>
664 <Button
665 key="log"
666 plain
667 dimColor
668 onPress={() => focusOn($, e.props.calls.map(call => call.tool_use_id))}
669 >
670 {`› ${countLabel(e.props.calls.length)}${changes}`}
671 {failed > 0 && <Text color="error">{` · ${failed} failed`}</Text>}
672 </Button>
673 </Box>
674 )
675 })
676
677 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
678 if (e.surface !== 'terminal') {
679 return next(e)
680 }
681
682 const { Box, Button, Text } = $.ui.resolve(e)
683 const changes = changesOf(await read($, groups), [e.props.tool_use_id])
684
685 return (
686 <Box marginTop={1}>
687 <Button key="log" plain dimColor onPress={() => focusOn($, [e.props.tool_use_id])}>
688 {`› ${countLabel(1)}${changes}`}
689 {e.props.isErrored && <Text color="error"> · failed</Text>}
690 </Button>
691 </Box>
692 )
693 })
694
695 // A standalone call's result block draws apart from its row: it lives in
696 // the log too, so the chat drops it.
697 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
698 if (e.surface !== 'terminal') {
699 return next(e)
700 }
701
702 const { Box } = $.ui.resolve(e)
703
704 return <Box />
705 })
706
707 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
708 if (e.surface !== 'terminal') {
709 return next(e)
710 }
711
712 const { Box, Text } = $.ui.resolve(e)
713 const now = await read($, tick)
714 const latest = (await read($, groups)).at(-1)
715 const running = latest?.entries.filter(x => x.status === 'running').at(-1)
716 const plan = await read($, todos)
717 const planDone = plan.filter(x => x.status === 'completed').length
718 const progress = plan.length > 0 && planDone < plan.length ? `${planDone}/${plan.length} · ` : ''
719 const activity = progress + (running?.activity ?? MODE_ACTIVITY[e.props.mode] ?? '')
720 const room = Math.max(10, (e.viewport?.columns ?? 80) - 14)
721
722 // A blank line above keeps it off the tool line; the band spaces itself.
723 return (
724 <Box marginTop={1}>
725 <Text color="claude">{FRAMES[now.frame] ?? FRAMES[0]}</Text>
726 <Text dimColor> {elapsed(now.elapsedMs)}</Text>
727 {activity !== '' && (
728 <Text wrap="truncate-end">
729 <Text dimColor> · </Text>
730 {activity.length > room ? `${activity.slice(0, room - 1)}…` : activity}
731 </Text>
732 )}
733 </Box>
734 )
735 })
736
737 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
738 const below = await next(e)
739
740 if (e.surface !== 'terminal' || e.props.hasSurvey) {
741 return below
742 }
743
744 const { Box, Button, Text } = $.ui.resolve(e)
745 const hasDiff = await read($, isRepo)
746
747 // No sizing props here: the engine refuses its own node under a sized Box.
748 return (
749 <Box>
750 <Button key="log-toggle" label="panel" dimColor onPress={() => toggle($)} />
751 <Text> </Text>
752 {/* The keyboard way in: ctrl+t in ~/.claude/keybindings.json is bound
753 to an engine action only the diff panel handles, free at the prompt. */}
754 <Button
755 key="panel-key"
756 label="ctrl+t"
757 plain
758 dimColor
759 action="app:toggleDiffPreSession"
760 onPress={() => focusPanel($)}
761 />
762 {hasDiff && <Text> </Text>}
763 {hasDiff && (
764 <Button
765 key="diff-open"
766 label="diff"
767 dimColor
768 onPress={async () => {
769 await $.command.run({ command: 'diff' })
770 }}
771 />
772 )}
773 {below}
774 </Box>
775 )
776 })
777
778 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
779 const { Box, Button, Code, Text } = $.ui.resolve(e)
780 const list = await read($, groups)
781 const now = await read($, tick)
782 const open = new Set(await read($, expanded))
783 const bodyRows = e.props.scroll.bodyRows
784 const columns = Math.max(20, e.props.bodyColumns - 2)
785 const clip = (text: string, width: number) =>
786 text.length > width ? `${text.slice(0, width - 1)}…` : text
787 const lines = (text: string, width: number) =>
788 text.split('\n').reduce((n, l) => n + Math.max(1, Math.ceil(l.length / Math.max(1, width))), 0)
789 const patchLines = (patch: string) => patch.replace(/\n$/, '').split('\n').length
790
791 const tab = await read($, tabAtom)
792 const frame = FRAMES[now.frame] ?? FRAMES[0]
793 const lastSeen = await read($, seen)
794 const current = signatures(list, await read($, todos), await read($, agents))
795 // Active: orange. Something new since last viewed: bright. Else dim.
796 const isNew = (t: Tab) => t !== tab && current[t] !== '' && current[t] !== (lastSeen[t] ?? '')
797 const step = (by: number) => async () => {
798 const next = TABS[(TABS.indexOf(tab) + by + TABS.length) % TABS.length] ?? tab
799 cursorKey = null
800 await markSeen($, [tab, next])
801 await update($, tabAtom, () => next)
802 }
803 const key = (k: string, label: string, onPress: () => unknown) => (
804 <Box key={`hintbox-${k}`} marginRight={2}>
805 <Button key={`hint-${k}`} hotkey={k} label={label} plain onPress={onPress} />
806 </Box>
807 )
808
809 // The tabs and, while the panel holds the keyboard, its keys: drawn above
810 // the scrolled window, so they never scroll away.
811 const isFocused = e.props.isFocused
812 const header = (
813 <Box flexDirection="column">
814 <Box>
815 {TABS.map((t, i) => (
816 <Box key={`tabbox-${t}`} marginRight={2}>
817 <Button
818 key={`tab-${t}`}
819 hotkey={String(i + 1)}
820 plain
821 dimColor={t !== tab && !isNew(t)}
822 onPress={async () => {
823 cursorKey = null
824 await markSeen($, [tab, t])
825 await update($, tabAtom, () => t)
826 }}
827 >
828 {t === tab ? (
829 <Text bold color="claude">{TAB_TITLES[t]}</Text>
830 ) : isNew(t) ? (
831 <Text bold>{TAB_TITLES[t]}</Text>
832 ) : (
833 TAB_TITLES[t]
834 )}
835 </Button>
836 </Box>
837 ))}
838 </Box>
839 {isFocused && (
840 <Box flexDirection="column" marginTop={1}>
841 <Box>
842 {key('h', 'prev', step(-1))}
843 {key('l', 'next', step(1))}
844 {key('j', 'down', () => moveCursor($, 1))}
845 {key('k', 'up', () => moveCursor($, -1))}
846 </Box>
847 <Box>
848 {key('d', 'half down', () => scrollBy($, Math.max(1, Math.floor(layout.view / 2))))}
849 {key('u', 'half up', () => scrollBy($, -Math.max(1, Math.floor(layout.view / 2))))}
850 {key('g', 'top', () => jumpTo($, 'first'))}
851 {/* b for the bottom: a hotkey cannot tell shift+g from g */}
852 {key('b', 'bottom', () => jumpTo($, 'last'))}
853 </Box>
854 </Box>
855 )}
856 </Box>
857 )
858 const headerRows = isFocused ? 4 : 1
859 // Body rows less the padding, the header and the gap under it.
860 const view = Math.max(3, bodyRows - 2 - headerRows - 1)
861
862 // Lays the content out in a window of `view` rows, scrolled by this tab's
863 // offset, and records where each pressable row sits for j, k and focus.
864 const windowed = async (
865 content: RenderNode,
866 height: number,
867 rowLine: Map<string, number>,
868 keys: string[] = [],
869 ) => {
870 const stored = (await read($, scrollTop))[tab]
871 const max = Math.max(0, height - view)
872 const fallback = tab === 'log' ? max : 0
873 const offset = Math.max(0, Math.min(max, stored === null || stored === undefined ? fallback : stored))
874 // Both at once, after the last await: a key pressed mid-drawing never
875 // sees one without the other.
876 layout = { tab, rowLine, content: height, view, offset }
877 rowKeys = keys
878 const more = offset > 0 ? '↑ ' : ''
879 const less = offset < max ? ' ↓' : ''
880
881 return (
882 <Box
883 backgroundColor={PANE_BG}
884 width={e.props.bodyColumns}
885 height={e.props.placement === 'dock' ? bodyRows : undefined}
886 paddingX={1}
887 paddingY={1}
888 flexDirection="column"
889 >
890 {header}
891 <Box height={1}>
892 {(more !== '' || less !== '') && (
893 <Text dimColor>
894 {more}
895 {offset + 1}–{Math.min(height, offset + view)} of {height}
896 {less}
897 </Text>
898 )}
899 </Box>
900 <Box height={view} overflow="hidden" flexDirection="column">
901 <Box marginTop={-offset} flexDirection="column" flexShrink={0}>
902 {content}
903 </Box>
904 </Box>
905 </Box>
906 )
907 }
908
909 if (tab === 'todo') {
910 const plan = await read($, todos)
911
912 if (plan.length === 0) {
913 return windowed(<Text dimColor>No plan yet. Claude writes one for longer tasks.</Text>, 1, new Map())
914 }
915
916 const done = plan.filter(t => t.status === 'completed').length
917 const height = 2 + plan.reduce((n, t) => n + lines(t.status === 'in_progress' ? (t.activeForm ?? t.content) : t.content, columns - 2), 0)
918
919 return windowed(
920 <Box flexDirection="column">
921 <Text dimColor>
922 {done}/{plan.length} done
923 </Text>
924 <Text> </Text>
925 {plan.map(t => (
926 <Box key={t.id}>
927 <Box width={2} flexShrink={0}>
928 {t.status === 'completed' ? (
929 <Text color="success">✓</Text>
930 ) : t.status === 'in_progress' ? (
931 <Text color="claude">{frame}</Text>
932 ) : (
933 <Text dimColor>○</Text>
934 )}
935 </Box>
936 <Box width={columns - 2}>
937 <Text
938 dimColor={t.status === 'completed'}
939 bold={t.status === 'in_progress'}
940 wrap="wrap"
941 >
942 {t.status === 'in_progress' ? (t.activeForm ?? t.content) : t.content}
943 </Text>
944 </Box>
945 </Box>
946 ))}
947 </Box>,
948 height,
949 new Map(),
950 )
951 }
952
953 if (tab === 'files') {
954 const files = filesOf(list)
955 const fileKeys = files.map(f => `f:${f.path}`)
956
957 if (files.length === 0) {
958 return windowed(<Text dimColor>No files changed yet.</Text>, 1, new Map())
959 }
960
961 const rowLine = new Map<string, number>()
962 let y = 0
963 for (const f of files) {
964 const k = `f:${f.path}`
965 rowLine.set(k, y)
966 y += 2 + (open.has(k) ? patchLines(f.patch) : 0) + 1
967 }
968
969 return windowed(
970 <Box flexDirection="column">
971 {files.map(f => {
972 const k = `f:${f.path}`
973
974 return (
975 <Box key={`row-${k}`} flexDirection="column" marginBottom={1}>
976 <Button key={k} plain onPress={() => toggleExpanded($, k)}>
977 <Text bold>{clip(basename(f.path), columns - 24)}</Text>{' '}
978 <Text color="success">+{f.added}</Text>{' '}
979 <Text color="error">−{f.removed}</Text>
980 <Text dimColor> {open.has(k) ? '▾' : '▸'}</Text>
981 </Button>
982 <Box marginLeft={2}>
983 <Text dimColor wrap="truncate-start">
984 {home(dirOf(f.path))} · {f.edits} {f.edits === 1 ? 'edit' : 'edits'}
985 </Text>
986 </Box>
987 {open.has(k) && (
988 <Box marginLeft={2} width={columns - 2}>
989 <Code source={f.patch} format="diff" path={f.path} wrap="truncate-end" />
990 </Box>
991 )}
992 </Box>
993 )
994 })}
995 </Box>,
996 y,
997 rowLine,
998 fileKeys,
999 )
1000 }
1001
1002 if (tab === 'agents') {
1003 const crew = await read($, agents)
1004
1005 if (crew.length === 0) {
1006 return windowed(<Text dimColor>No subagents in this session.</Text>, 1, new Map())
1007 }
1008
1009 const height = crew.reduce((n, a) => n + 3 + (!ENDED.has(a.status) && a.lastStep !== '' ? 1 : 0), 0)
1010
1011 return windowed(
1012 <Box flexDirection="column">
1013 {[...crew].reverse().map(a => {
1014 const isLive = !ENDED.has(a.status)
1015 const end = a.endedAt ?? Math.max(now.now, a.startedAt)
1016
1017 return (
1018 <Box key={a.id} flexDirection="column" marginBottom={1}>
1019 <Box>
1020 <Box width={2} flexShrink={0}>
1021 {a.status === 'running' || a.status === 'pending' ? (
1022 <Text color="claude">{frame}</Text>
1023 ) : a.status === 'completed' ? (
1024 <Text color="success">✓</Text>
1025 ) : a.status === 'failed' ? (
1026 <Text color="error">✗</Text>
1027 ) : a.status === 'killed' ? (
1028 <Text color="warning">■</Text>
1029 ) : (
1030 <Text dimColor>◌</Text>
1031 )}
1032 </Box>
1033 <Text bold={isLive} dimColor={!isLive} wrap="truncate-end">
1034 {clip(a.description, columns - 4)}
1035 </Text>
1036 </Box>
1037 <Box marginLeft={2}>
1038 <Text dimColor wrap="truncate-end">
1039 {a.type} · {elapsed(end - a.startedAt)} · {countLabel(a.tools)}
1040 {a.status === 'idle' || a.status === 'waiting' ? ` · ${a.status}` : ''}
1041 </Text>
1042 </Box>
1043 {isLive && a.lastStep !== '' && (
1044 <Box marginLeft={2}>
1045 <Text dimColor wrap="truncate-end">
1046 › {clip(a.lastStep, columns - 6)}
1047 </Text>
1048 </Box>
1049 )}
1050 </Box>
1051 )
1052 })}
1053 </Box>,
1054 height,
1055 new Map(),
1056 )
1057 }
1058
1059 const focused = await read($, focus)
1060 const all = list.filter(g => g.entries.length > 0 || g === list[list.length - 1])
1061 const at = focused === null ? -1 : all.findIndex(g => g.id === focused)
1062 // Focused, the log ends at that group: it sits at the bottom, older above.
1063 const shown = (at === -1 ? all : all.slice(0, at + 1)).slice(-40)
1064 const target = at === -1 ? undefined : all[at]
1065
1066 if (shown.length === 0) {
1067 return windowed(<Text dimColor>No tool calls yet.</Text>, 1, new Map())
1068 }
1069
1070 const pressable = (x: LogEntry) => (x.command !== undefined || x.patch !== undefined) && x.status === 'ok'
1071 const logKeys = shown.flatMap(g => g.entries.filter(pressable).map(x => x.id))
1072
1073 // Where each row lands: a group is its header, a line per call, what an
1074 // open or failed call unfolds, and a blank line before the next group.
1075 const rowLine = new Map<string, number>()
1076 let y = target === undefined ? 0 : 2
1077 shown.forEach((g, gi) => {
1078 if (gi > 0) y += 1
1079 y += 1
1080 for (const x of g.entries) {
1081 rowLine.set(x.id, y)
1082 // The row's own line, longer when its label and counts wrap.
1083 const head = (x.patch !== undefined ? `${x.tool} ` : '') + x.label + (x.patch !== undefined ? ` +${x.added ?? 0} −${x.removed ?? 0}` : '') + ' ▸'
1084 y += lines(head, columns - 4)
1085 if (x.patch !== undefined && open.has(x.id)) y += patchLines(x.patch)
1086 if (x.command !== undefined && x.status === 'error') y += 1
1087 else if (x.command !== undefined && open.has(x.id)) y += lines(x.command, columns - 6)
1088 }
1089 })
1090
1091 const latest = list[list.length - 1]
1092
1093 return windowed(
1094 <Box flexDirection="column" gap={1}>
1095 {target !== undefined && (
1096 <Box>
1097 <Text dimColor wrap="truncate-end">
1098 viewing: {clip(target.prompt === '' ? '(no prompt)' : target.prompt, columns - 16)}
1099 </Text>
1100 <Text dimColor> · </Text>
1101 <Button
1102 key="unfocus"
1103 label="✕"
1104 plain
1105 dimColor
1106 onPress={() => update($, focus, () => null)}
1107 />
1108 </Box>
1109 )}
1110 {shown.map(g => {
1111 const isOld = target === undefined ? g !== latest : g !== target
1112 const meta = `${clockTime(g.startedAt).slice(0, 5)} · ${g.entries.length}`
1113 const promptWidth = Math.max(8, columns - meta.length - 4)
1114
1115 return (
1116 <Box key={g.id} flexDirection="column">
1117 <Box>
1118 <Text bold={!isOld} dimColor={isOld} color="claude">
1119 ❯{' '}
1120 </Text>
1121 <Box width={promptWidth}>
1122 <Text bold={!isOld} dimColor={isOld} wrap="truncate-end">
1123 {g.prompt === '' ? '(no prompt)' : g.prompt}
1124 </Text>
1125 </Box>
1126 <Text dimColor> {meta}</Text>
1127 </Box>
1128 {g.entries.map(entry => (
1129 <Box key={`row-${entry.id}`} flexDirection="column">
1130 <Box>
1131 <Box width={4} flexShrink={0}>
1132 <Text> </Text>
1133 {entry.status === 'running' ? (
1134 <Text color="claude">{frame}</Text>
1135 ) : entry.status === 'ok' ? (
1136 <Text color="success" dimColor={isOld}>✓</Text>
1137 ) : (
1138 <Text color="error">✗</Text>
1139 )}
1140 </Box>
1141 {pressable(entry) ? (
1142 <Button
1143 key={entry.id}
1144 plain
1145 dimColor={isOld}
1146 onPress={() => toggleExpanded($, entry.id)}
1147 >
1148 {entry.patch !== undefined && <Text bold>{entry.tool} </Text>}
1149 {clip(entry.label, columns - 20)}
1150 {entry.patch !== undefined && (
1151 <Text>
1152 {' '}
1153 <Text color="success">+{entry.added ?? 0}</Text>{' '}
1154 <Text color="error">−{entry.removed ?? 0}</Text>
1155 </Text>
1156 )}
1157 <Text dimColor> {open.has(entry.id) ? '▾' : '▸'}</Text>
1158 </Button>
1159 ) : (
1160 <Text dimColor={isOld} wrap="truncate-end">
1161 {entry.command === undefined && (
1162 <Text bold>{entry.tool} </Text>
1163 )}
1164 {clip(entry.label, columns - 6)}
1165 </Text>
1166 )}
1167 </Box>
1168 {entry.patch !== undefined && open.has(entry.id) && (
1169 <Box marginLeft={4} width={columns - 4}>
1170 <Code
1171 source={entry.patch}
1172 format="diff"
1173 path={entry.path}
1174 wrap="truncate-end"
1175 />
1176 </Box>
1177 )}
1178 {entry.command !== undefined &&
1179 (entry.status === 'error' || open.has(entry.id)) && (
1180 <Box marginLeft={6} width={columns - 6}>
1181 <Text dimColor wrap="wrap">
1182 {entry.status === 'error'
1183 ? clip(firstLine(entry.command), columns - 8)
1184 : entry.command}
1185 </Text>
1186 </Box>
1187 )}
1188 </Box>
1189 ))}
1190 </Box>
1191 )
1192 })}
1193 </Box>,
1194 y,
1195 rowLine,
1196 logKeys,
1197 )
1198 })
1199}
1200types/index.d.ts 59 lines1export type LogEntry = {
2 id: string
3 tool: string
4 label: string
5 command?: string
6 activity: string
7 path?: string
8 patch?: string
9 added?: number
10 removed?: number
11 status: 'running' | 'ok' | 'error'
12}
13
14export type LogGroup = {
15 id: string
16 prompt: string
17 startedAt: number
18 entries: LogEntry[]
19}
20
21export type Tick = { frame: number; elapsedMs: number; now: number }
22
23export type Tab = 'log' | 'todo' | 'files' | 'agents'
24
25export type TodoItem = {
26 id: string
27 content: string
28 activeForm?: string
29 status: 'pending' | 'in_progress' | 'completed'
30}
31
32export type AgentRow = {
33 id: string
34 description: string
35 type: string
36 startedAt: number
37 endedAt?: number
38 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
39 lastStep: string
40 tools: number
41}
42
43declare module 'claude-code' {
44 interface PluginState {
45 'tool-log': {
46 groups: LogGroup[]
47 tick: Tick
48 expanded: string[]
49 isRepo: boolean
50 focus: string | null
51 tab: Tab
52 todos: TodoItem[]
53 agents: AgentRow[]
54 seen: Partial<Record<Tab, string>>
55 scrollTop: Partial<Record<Tab, number | null>>
56 }
57 }
58}
59