SLOPSHOPPER

tool-log

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…

newpanebandspinnerrowsguard
v1.11.5no licenseupdated 2026-10-13Tvrtk0/claude-code-mods/tool-log
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-log
│ ┃ Panel ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ 1: tab-log 2: Files 3: Agents 4: Todo ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ❯ fix the failing auth test and add an aud… │ ┃ ✓ Read auth.ts › 1 tool │ ┃ ✓ Grep src ⏺ Bash(bun test) │ ┃ ✓ Edit auth.ts ⎿ 3 pass, 1 fail │ ┃ ✓ Write audit.ts │ ┃ ✓ Write cache.ts ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✗ Run the test suite │ ┃ bun test ✻ Worked for 42s · done 4:20 PM │ ┃ ✓ Show working tree status │ ┃ ✓ Clean build output and push › /panel │ ┃ ✓ Show environment file │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⠋ 0s · Thinking [ panel ] ctrl+t⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ panel ] ctrl+t⟨Claude Code's own drawing⟩
Pane · Panel
1: tab-log 2: Files 3: Agents 4: Todo ❯ fix the failing auth test and add an aud… 08:53 · 9 ✓ Read auth.ts ✓ Grep src ✓ Edit auth.ts ✓ Write audit.ts ✓ Write cache.ts ✗ Run the test suite bun test ✓ Show working tree status ✓ Clean build output and push ✓ Show environment file
Spinner
⠋ 0s · Thinking
Tool row
› 1 tool
README

local-mods

Personal Claude Code mods for a calmer, more readable terminal.

ModWhat it does
readable-widthWraps 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-metersOne 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-logFolds 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-detailsReplaces 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.

Install

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.

Develop

claude plugin validate ~/.claude/mods/<mod>
claude plugin test ~/.claude/mods/<mod>

Keyboard shortcut for the panel

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.

Source 2 files
hooks/register.tsx 1200 lines
1import { 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}
1200
types/index.d.ts 59 lines
1export 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