Side panel with the current session's usage, tool calls, changes, activity, scratch files and stats

A side panel for browsing everything Claude Code knows about the current session: context and rate limits, every tool call with its full input and result, edited files with diffs, background activity, the session's scratch directory and usage statistics.
Open it with /session-panel.
Requires Claude Code with function-hook plugin support. Validated and tested with Claude Code 2.1.295.
Run in Claude Code:
/plugin install session-panel --marketplace mlopian/claude-plugins
For installation from a local clone, see the repository README. Start a new Claude Code session after installing.
| Key | Tab | Shows |
|---|---|---|
u | Usage | Session facts (title, ID, model, effort, permission mode, versions, branch, cost), rate limits, context fill with the /context grid, categories, memory files and tokens left before auto-compact, session paths, session-env contents |
t | Tool calls | One card per tool call, newest first: tool, status, start time, duration, description or file and the first three lines of input. Enter opens the call in full with syntax colouring |
c | Changes | git status of the session's repository with line counts, and the files Claude edited in this session with their backup versions. Enter opens a coloured diff |
a | Activity | Subagents and teammates with their status, and background task output files |
f | Files | Browser of the session's scratch directory, including pasted images |
s | Stats | Token totals, cache hit rate, turn durations, tool usage and the heaviest requests |
h | ? | All keyboard shortcuts |
| Key | Action |
|---|---|
ctrl+x tab | Move the keyboard from the prompt to the panel (Claude Code) |
esc | Give the keyboard back to the prompt (Claude Code) |
ctrl+x x | Close the panel (Claude Code) |
enter | Open the focused tool call, diff, file or folder |
b | Back from a tool call or diff to its list, or to the parent folder in Files |
j / k | Scroll the tool call list by half a page of cards |
e | Tool calls: show only failed calls, or all of them |
r | Tool calls: refresh |
The tool call list keeps the tab bar pinned and scrolls card by card. The other tabs and the full views scroll with the arrows, page keys and mouse wheel.
~/.claude/projects/<project>/<session>.jsonl, the edit backups from ~/.claude/file-history/<session>/, session-env from ~/.claude/session-env/<session>/ and the scratch directory from /tmp/claude-<uid>/<project>/<session>/.r. Usage, Activity and Files refresh every 2 seconds while the panel is shown. Changes reruns git status every 2 seconds while it is shown, and redraws only when the result changed.summary), so it sends no token-count requests.enter on a file opens it in $VISUAL or $EDITOR in a new herdr pane, or a new tmux window. Outside herdr and tmux the path is copied to the clipboard instead. PNG files open in the system viewer with open./session-panel is unknown, check /plugin to confirm session-panel is installed and enabled, then start a new session.$EDITOR is set and that the session runs inside herdr or tmux.open for PNG files is macOS only.Run from the repository root:
claude plugin validate session-panel
claude plugin test session-panel
Tests cover transcript parsing, tool call descriptions and timing, session metadata, file history, git status parsing, diff counts and formatting.
hooks/register.tsx 980 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, FsEntry, Register, RenderChildren } from 'claude-code'
3
4import type { SessionDiff, SessionGit, SessionPaths, SessionTab } from '../types'
5import {
6 cacheHitRate,
7 diffCounts,
8 formatCount,
9 formatDuration,
10 formatSize,
11 formatTime,
12 meter,
13 parseGitStatus,
14 parseTranscript,
15 previewLines,
16} from './format'
17import type { FileVersion, ToolCall } from './format'
18
19const PANE = 'session-panel'
20const tab = atom({ plugin: 'session-panel', key: 'tab' } as const, 'usage' as SessionTab)
21const dir = atom({ plugin: 'session-panel', key: 'dir' } as const, '')
22const tick = atom({ plugin: 'session-panel', key: 'tick' } as const, 0)
23const offset = atom({ plugin: 'session-panel', key: 'offset' } as const, 0)
24const isErrorsOnly = atom({ plugin: 'session-panel', key: 'isErrorsOnly' } as const, false)
25const selectedTool = atom({ plugin: 'session-panel', key: 'selectedTool' } as const, null as string | null)
26const listOffset = atom({ plugin: 'session-panel', key: 'listOffset' } as const, 0)
27const diff = atom({ plugin: 'session-panel', key: 'diff' } as const, null as SessionDiff | null)
28const git = atom({ plugin: 'session-panel', key: 'git' } as const, null as SessionGit | null)
29const pathsAtom = atom({ plugin: 'session-panel', key: 'paths' } as const, null as SessionPaths | null)
30
31const TABS: { id: SessionTab; label: string; hotkey: string }[] = [
32 { id: 'usage', label: 'Usage', hotkey: 'u' },
33 { id: 'tools', label: 'Tool calls', hotkey: 't' },
34 { id: 'changes', label: 'Changes', hotkey: 'c' },
35 { id: 'activity', label: 'Activity', hotkey: 'a' },
36 { id: 'files', label: 'Files', hotkey: 'f' },
37 { id: 'stats', label: 'Stats', hotkey: 's' },
38 { id: 'help', label: '?', hotkey: 'h' },
39]
40
41const LIVE_TABS = new Set<SessionTab>(['usage', 'activity', 'files'])
42const LIVE_REFRESH_MS = 2000
43const HEADER_ROWS = 3
44const CARD_ROWS = 6
45const PREVIEW_LINES = CARD_ROWS - 3
46
47const FILE_COLORS: Record<string, Color> = {
48 ts: 'ide',
49 tsx: 'ide',
50 js: 'warning',
51 json: 'merged',
52 jsonl: 'merged',
53 md: 'suggestion',
54 sh: 'success',
55 py: 'autoAccept',
56 html: 'claude',
57 css: 'planMode',
58}
59
60const GIT_COLORS: Record<string, Color> = {
61 M: 'warning',
62 A: 'success',
63 '??': 'success',
64 D: 'error',
65 R: 'merged',
66}
67
68function toolColor(name: string): Color {
69 if (name === 'Bash') return 'bashBorder'
70 if (name === 'Edit' || name === 'Write') return 'permission'
71 if (name === 'Read') return 'ide'
72 if (name.startsWith('mcp__')) return 'merged'
73
74 return 'suggestion'
75}
76
77function statusOf(call: ToolCall): { mark: string; color: Color } {
78 if (call.isError === null) return { mark: '…', color: 'warning' }
79
80 return call.isError ? { mark: '✗', color: 'error' } : { mark: '✓', color: 'success' }
81}
82
83const AGENT_COLORS: Record<string, Color> = {
84 running: 'warning',
85 pending: 'inactive',
86 waiting: 'permission',
87 idle: 'suggestion',
88 completed: 'success',
89 failed: 'error',
90 killed: 'error',
91}
92
93async function sessionPaths($: EngineInterface): Promise<SessionPaths> {
94 const [id, cwd, home, uid] = await Promise.all([
95 $.session.id(),
96 $.session.cwd(),
97 $.env.get('HOME'),
98 $.process.run(['id', '-u']),
99 ])
100 const projectKey = cwd.replace(/[^a-zA-Z0-9]/g, '-')
101
102 return {
103 id,
104 cwd,
105 transcript: `${home}/.claude/projects/${projectKey}/${id}.jsonl`,
106 env: `${home}/.claude/session-env/${id}`,
107 tmp: `/tmp/claude-${uid.stdout.trim()}/${projectKey}/${id}`,
108 fileHistory: `${home}/.claude/file-history/${id}`,
109 }
110}
111
112function shellQuote(text: string): string {
113 return `'${text.replace(/'/g, `'\\''`)}'`
114}
115
116async function openInEditor($: EngineInterface, path: string): Promise<void> {
117 const editor = (await $.env.get('VISUAL')) ?? (await $.env.get('EDITOR')) ?? 'vi'
118 const command = `exec ${editor} ${shellQuote(path)}`
119 try {
120 const herdrPane = await $.env.get('HERDR_PANE_ID')
121 if (herdrPane) {
122 const split = await $.process.run(['herdr', 'pane', 'split', '--pane', herdrPane, '--direction', 'right', '--focus'])
123 const pane: string = JSON.parse(split.stdout).result.pane.pane_id
124 await $.process.run(['herdr', 'pane', 'run', pane, command])
125 return
126 }
127 if (await $.env.get('TMUX')) {
128 await $.process.run(['tmux', 'new-window', command])
129 return
130 }
131 await $.ui.copy({ text: path })
132 $.ui.toast(`No herdr or tmux, path copied: ${path}`)
133 } catch (error) {
134 $.ui.toast(`Could not open the editor: ${String(error)}`)
135 }
136}
137
138async function showDiff($: EngineInterface, paths: SessionPaths, file: string, versions: FileVersion[]): Promise<void> {
139 const first = versions.find(version => version.backupFileName)
140 const before = first ? `${paths.fileHistory}/${first.backupFileName}` : '/dev/null'
141 const after = (await $.fs.exists(file)) ? file : '/dev/null'
142 const run = await $.process.run(['diff', '-u', '-L', `before (v${first?.version ?? 0})`, '-L', 'now', before, after])
143 await update($, diff, () => ({ path: file, text: run.stdout, against: `first backup (v${first?.version ?? 0})` }))
144 await update($, offset, () => 0)
145}
146
147async function refreshGit($: EngineInterface): Promise<void> {
148 const repo = await $.session.repo()
149 if (!repo) {
150 await update($, git, () => null)
151 return
152 }
153 const [status, numstat] = await Promise.all([
154 $.process.run(['git', 'status', '--porcelain=v1', '--branch', '--untracked-files=all'], { cwd: repo.root }),
155 $.process.run(['git', 'diff', '--numstat', 'HEAD'], { cwd: repo.root }),
156 ])
157 const next = parseGitStatus(repo.root, status.stdout, numstat.stdout)
158 const previous = await read($, git)
159 if (JSON.stringify(previous) !== JSON.stringify(next)) await update($, git, () => next)
160}
161
162async function showGitDiff($: EngineInterface, status: SessionGit, file: SessionGit['files'][number]): Promise<void> {
163 const argv =
164 file.code === '??'
165 ? ['git', 'diff', '--no-index', '--', '/dev/null', file.path]
166 : ['git', 'diff', 'HEAD', '--', file.path]
167 const run = await $.process.run(argv, { cwd: status.root })
168 await update($, diff, () => ({ path: `${status.root}/${file.path}`, text: run.stdout, against: file.code === '??' ? 'untracked, whole file' : 'git HEAD' }))
169 await update($, offset, () => 0)
170}
171
172async function readOr($: EngineInterface, path: string, fallback: string): Promise<string> {
173 return (await $.fs.exists(path)) ? $.fs.read(path) : fallback
174}
175
176async function listOr($: EngineInterface, path: string): Promise<FsEntry[]> {
177 return (await $.fs.exists(path)) ? $.fs.list(path) : []
178}
179
180function sortEntries(entries: FsEntry[]): FsEntry[] {
181 return [...entries].sort((a, b) =>
182 (a.kind === 'dir') === (b.kind === 'dir') ? a.name.localeCompare(b.name) : a.kind === 'dir' ? -1 : 1,
183 )
184}
185
186function loadColor(percent: number): Color {
187 if (percent >= 80) return 'error'
188 if (percent >= 50) return 'warning'
189
190 return 'success'
191}
192
193function fileColor(name: string): Color {
194 return FILE_COLORS[name.split('.').pop() ?? ''] ?? 'text'
195}
196
197function shortTime(ms: number): string {
198 return new Date(ms).toLocaleString('en-GB', { hour12: false, day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
199}
200
201function shortPath(path: string, cwd: string): string {
202 return path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
203}
204
205let maxOffset = 0
206let isListMode = false
207
208async function refreshLiveTab($: EngineInterface): Promise<void> {
209 const [current, panes] = await Promise.all([read($, tab), $.ui.panes()])
210 if (!panes.some(pane => pane.id === PANE && pane.isShown)) return
211 if (current === 'changes') await refreshGit($)
212 else if (LIVE_TABS.has(current)) await update($, tick, n => n + 1)
213}
214
215function scrollBy($: EngineInterface, rows: number): Promise<unknown> {
216 return update($, offset, current => Math.max(0, Math.min(maxOffset, current + rows)))
217}
218
219export const register: Register = on => {
220 on('session.start', async ($, e, next) => {
221 await $.command.register({
222 name: 'session-panel',
223 description: 'Show the current session transcript, changes, activity, stats, environment and scratch directory in a side pane',
224 })
225 const paths = await sessionPaths($)
226 await update($, pathsAtom, () => paths)
227 $.clock.every(LIVE_REFRESH_MS, () => void refreshLiveTab($))
228
229 return next(e)
230 })
231
232 on('command.run', { command: 'session-panel' }, async $ => {
233 await $.ui.open({ id: PANE, title: 'Session', focus: true })
234 await $.ui.scroll({ to: 'start', in: PANE })
235
236 return { text: 'Session panel opened.' }
237 })
238
239 on('turn.complete', async ($, e, next) => {
240 const result = await next(e)
241 await update($, tick, n => n + 1)
242 await refreshGit($)
243
244 return result
245 })
246
247 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
248 if (!isListMode) return next(e)
249 if (e.origin.kind === 'person') await scrollBy($, Math.sign(e.by))
250
251 return next({ ...e, offset: 0 })
252 })
253
254 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
255 const { Box, Text, Button, Code } = $.ui.resolve(e)
256 const [current, relative, paths, scrolled, errorsOnly, selectedId, savedListOffset, currentDiff, gitStatus] = await Promise.all([
257 read($, tab),
258 read($, dir),
259 read($, pathsAtom),
260 read($, offset),
261 read($, isErrorsOnly),
262 read($, selectedTool),
263 read($, listOffset),
264 read($, diff),
265 read($, git),
266 read($, tick),
267 ])
268 if (!paths) return <Text dimColor>Loading session paths...</Text>
269
270 const columns = e.props.bodyColumns
271 const bodyRows = e.props.scroll.bodyRows
272 const windowRows = Math.max(CARD_ROWS, bodyRows - HEADER_ROWS)
273 const visibleCount = Math.max(1, Math.floor(windowRows / CARD_ROWS))
274 const page = Math.max(1, Math.floor(visibleCount / 2))
275 isListMode = false
276
277 const section = (title: string, color: Color, children: RenderChildren) => (
278 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
279 <Text bold color={color}>
280 {title}
281 </Text>
282 {children}
283 </Box>
284 )
285
286 const fact = (label: string, value: string, color: Color = 'text') => (
287 <Box flexDirection="row" columnGap={1}>
288 <Box width={14} flexShrink={0}>
289 <Text dimColor>{label}</Text>
290 </Box>
291 <Text color={color} wrap="wrap">
292 {value}
293 </Text>
294 </Box>
295 )
296
297 const gauge = (label: string, percent: number, detail: string) => (
298 <Box flexDirection="row" columnGap={1}>
299 <Box width={14} flexShrink={0}>
300 <Text dimColor>{label}</Text>
301 </Box>
302 <Text color={loadColor(percent)}>{meter(percent)}</Text>
303 <Text bold color={loadColor(percent)}>
304 {`${Math.round(percent)}%`}
305 </Text>
306 <Text dimColor>{detail}</Text>
307 </Box>
308 )
309
310 const bar = (label: string, value: number, max: number, detail: string, color: Color) => (
311 <Box flexDirection="row" columnGap={1}>
312 <Box width={14} flexShrink={0}>
313 <Text dimColor wrap="truncate-end">
314 {label}
315 </Text>
316 </Box>
317 <Text color={color}>{'█'.repeat(Math.max(1, Math.round((value / Math.max(1, max)) * 30)))}</Text>
318 <Text dimColor>{detail}</Text>
319 </Box>
320 )
321
322 const actions = (extra: RenderChildren) => (
323 <Box flexDirection="row" columnGap={1} height={1} flexShrink={0} overflow="hidden">
324 {extra}
325 </Box>
326 )
327
328 const tabRow = (
329 <Box flexDirection="row" columnGap={1} height={1} flexShrink={0} overflow="hidden">
330 {TABS.map(one => (
331 <Button
332 key={`tab-${one.id}`}
333 label={one.label}
334 hotkey={one.hotkey}
335 variant={one.id === current ? 'primary' : 'secondary'}
336 onPress={async () => {
337 await update($, tab, () => one.id)
338 await update($, offset, () => 0)
339 if (one.id === 'changes') await refreshGit($)
340 }}
341 />
342 ))}
343 </Box>
344 )
345
346 const separator = (label: string) => (
347 <Box flexDirection="row" columnGap={1} height={1} flexShrink={0}>
348 <Text color="inactive">{'─'.repeat(Math.max(0, columns - label.length - 2))}</Text>
349 <Text color="suggestion">{label}</Text>
350 </Box>
351 )
352
353 const frame = (toolbar: RenderChildren, body: RenderChildren) => (
354 <Box flexDirection="column">
355 {tabRow}
356 {toolbar}
357 {separator('')}
358 {body}
359 </Box>
360 )
361
362 if (current === 'help') {
363 const shortcut = (keys: string, action: string) => (
364 <Box flexDirection="row" columnGap={1}>
365 <Box width={14} flexShrink={0}>
366 <Text bold color="suggestion">
367 {keys}
368 </Text>
369 </Box>
370 <Text>{action}</Text>
371 </Box>
372 )
373
374 return frame(
375 actions(null),
376 <Box flexDirection="column" rowGap={1}>
377 {section(
378 '⌨ Claude Code',
379 'claude',
380 <Box flexDirection="column">
381 {shortcut('ctrl+x tab', 'move the keyboard from the prompt to this panel')}
382 {shortcut('esc', 'give the keyboard back to the prompt')}
383 {shortcut('ctrl+x x', 'close the panel')}
384 {shortcut('tab / arrows', 'walk the buttons while the panel has the keyboard')}
385 {shortcut('enter', 'press the focused button')}
386 {shortcut('/session-panel', 'open the panel with the keyboard in it')}
387 </Box>,
388 )}
389 {section(
390 '▦ Tabs',
391 'suggestion',
392 <Box flexDirection="column">
393 {TABS.map(one => shortcut(one.hotkey, one.id === 'help' ? 'this help' : one.label))}
394 </Box>,
395 )}
396 {section(
397 '↕ Everywhere',
398 'ide',
399 <Box flexDirection="column">
400 {shortcut('j / k', 'tool call list: scroll down / up by half a page of cards')}
401 {shortcut('mouse wheel', 'scroll, one card at a time in the tool call list')}
402 {shortcut('arrows / pgup / pgdn', 'scroll the other tabs and a tool call in full')}
403 </Box>,
404 )}
405 {section(
406 '⏺ Tool calls',
407 'bashBorder',
408 <Box flexDirection="column">
409 {shortcut('enter', 'open the focused tool call in full, with syntax colouring')}
410 {shortcut('b', 'back from a tool call to the list')}
411 {shortcut('e', 'show only failed tool calls, or all of them')}
412 {shortcut('r', 'refresh (it also refreshes after every turn)')}
413 </Box>,
414 )}
415 {section(
416 '± Changes',
417 'permission',
418 <Box flexDirection="column">
419 {shortcut('enter', 'open a diff: a git file against HEAD, a session file against its first backup')}
420 {shortcut('b', 'back from a diff to the list')}
421 </Box>,
422 )}
423 {section(
424 '▣ Files',
425 'ide',
426 <Box flexDirection="column">
427 {shortcut('enter', 'open a file in $EDITOR, a PNG in the system viewer, or enter a folder')}
428 {shortcut('b', 'go back to the parent folder')}
429 </Box>,
430 )}
431 <Text color="inactive" italic>
432 Usage, Activity and Files refresh by themselves every 2 seconds.
433 </Text>
434 </Box>,
435 )
436 }
437
438 const transcript = parseTranscript(await readOr($, paths.transcript, ''))
439 const { meta } = transcript
440
441 if (current === 'tools') {
442 const selected = selectedId ? transcript.toolCalls.find(call => call.id === selectedId) : undefined
443
444 if (selected) {
445 const status = statusOf(selected)
446 const color = toolColor(selected.name)
447
448 return frame(
449 actions(
450 <Button
451 key="back"
452 label="↰ Back"
453 hotkey="b"
454 onPress={async () => {
455 await update($, selectedTool, () => null)
456 await update($, offset, () => savedListOffset)
457 }}
458 />,
459 ),
460 <Box flexDirection="column" rowGap={1}>
461 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
462 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
463 <Text bold color={color}>{`⏺ ${selected.name}`}</Text>
464 <Text bold color={status.color}>{`${status.mark} ${selected.isError === null ? 'running' : selected.isError ? 'failed' : 'ok'}`}</Text>
465 <Text color="inactive">{formatDuration(selected.durationMs)}</Text>
466 </Box>
467 <Text dimColor>{`started ${selected.startedAt ? new Date(selected.startedAt).toLocaleString('en-GB', { hour12: false }) : 'unknown'}`}</Text>
468 {selected.summary && <Text color="suggestion">{selected.summary}</Text>}
469 </Box>
470 {section(
471 '▸ Input',
472 color,
473 <Code source={selected.source || ' '} language={selected.language || undefined} format={selected.format} />,
474 )}
475 {section(
476 selected.isError ? '✗ Result' : '◂ Result',
477 selected.isError ? 'error' : 'success',
478 selected.result ? <Code source={selected.result} /> : <Text dimColor italic>No result yet.</Text>,
479 )}
480 </Box>,
481 )
482 }
483
484 const calls = transcript.toolCalls.filter(call => !errorsOnly || call.isError).reverse()
485 const errorCount = transcript.toolCalls.filter(call => call.isError).length
486 isListMode = true
487 maxOffset = Math.max(0, calls.length - visibleCount)
488 const first = Math.min(scrolled, maxOffset)
489 const visibleCalls = calls.slice(first, first + visibleCount)
490 const position = calls.length === 0 ? '' : `${first + 1}-${first + visibleCalls.length} of ${calls.length}`
491
492 return (
493 <Box flexDirection="column" height={bodyRows}>
494 {tabRow}
495 {actions(
496 <>
497 <Button key="up" label="▲" hotkey="k" onPress={() => scrollBy($, -page)} />
498 <Button key="down" label="▼" hotkey="j" onPress={() => scrollBy($, page)} />
499 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => update($, tick, n => n + 1)} />
500 <Button
501 key="errors-only"
502 label={errorsOnly ? 'Show all' : `Errors only (${errorCount})`}
503 hotkey="e"
504 onPress={async () => {
505 await update($, isErrorsOnly, value => !value)
506 await update($, offset, () => 0)
507 }}
508 />
509 </>,
510 )}
511 {separator(position)}
512 <Box flexDirection="column" height={windowRows} flexShrink={0} overflow="hidden">
513 {calls.length === 0 && <Text dimColor italic>{errorsOnly ? 'No failed tool calls.' : 'No tool calls yet.'}</Text>}
514 {visibleCalls.map(call => {
515 const color = toolColor(call.name)
516 const status = statusOf(call)
517
518 return (
519 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1} height={CARD_ROWS} flexShrink={0}>
520 <Box flexDirection="row" columnGap={1} height={1} overflow="hidden">
521 <Button
522 key={`tool-${call.id}`}
523 plain
524 label={`⏺ ${call.name}`}
525 onPress={async () => {
526 await update($, listOffset, () => first)
527 await update($, selectedTool, () => call.id)
528 await update($, offset, () => 0)
529 }}
530 />
531 <Box flexShrink={0}>
532 <Text color={status.color}>{status.mark}</Text>
533 </Box>
534 <Box flexShrink={0}>
535 <Text dimColor>{formatTime(call.startedAt)}</Text>
536 </Box>
537 <Box flexShrink={0}>
538 <Text color="inactive">{formatDuration(call.durationMs)}</Text>
539 </Box>
540 <Text color="suggestion" wrap="truncate-end">
541 {call.summary}
542 </Text>
543 </Box>
544 <Code
545 source={previewLines(call.format === 'diff' ? call.source.split('\n').slice(3).join('\n') : call.source, PREVIEW_LINES).join('\n')}
546 language={call.language || undefined}
547 wrap="truncate-end"
548 />
549 </Box>
550 )
551 })}
552 </Box>
553 </Box>
554 )
555 }
556
557 if (current === 'changes') {
558 const files = Object.entries(transcript.fileVersions).sort(
559 ([, a], [, b]) => Date.parse(b.at(-1)?.time ?? '') - Date.parse(a.at(-1)?.time ?? ''),
560 )
561
562
563 if (currentDiff) {
564 const counts = diffCounts(currentDiff.text)
565
566 return frame(
567 actions(<Button key="back" label="↰ Back" hotkey="b" onPress={() => update($, diff, () => null)} />),
568 <Box flexDirection="column" rowGap={1}>
569 <Box flexDirection="column" borderStyle="round" borderColor={fileColor(currentDiff.path)} paddingX={1}>
570 <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
571 <Text bold color={fileColor(currentDiff.path)}>{`± ${shortPath(currentDiff.path, paths.cwd)}`}</Text>
572 <Text color="diffAdded">{`+${counts.added}`}</Text>
573 <Text color="diffRemoved">{`-${counts.removed}`}</Text>
574 </Box>
575 <Text dimColor>{`against ${currentDiff.against}`}</Text>
576 </Box>
577 {section(
578 '▸ Diff',
579 'diffAdded',
580 currentDiff.text ? (
581 <Code source={currentDiff.text} format="diff" path={currentDiff.path} />
582 ) : (
583 <Text dimColor italic>No changes.</Text>
584 ),
585 )}
586 </Box>,
587 )
588 }
589
590 return frame(
591 actions(null),
592 <Box flexDirection="column" rowGap={1}>
593 {section(
594 gitStatus ? `⎇ git: ${gitStatus.branch || 'detached'}` : '⎇ git',
595 'ide',
596 !gitStatus ? (
597 <Text dimColor italic>
598 The working directory is not a git repository.
599 </Text>
600 ) : gitStatus.files.length === 0 ? (
601 <Text color="success">Working tree clean.</Text>
602 ) : (
603 <Box flexDirection="column">
604 {gitStatus.files.map(file => (
605 <Box flexDirection="row" columnGap={1}>
606 <Box width={3} flexShrink={0}>
607 <Text bold color={GIT_COLORS[file.code] ?? 'warning'}>
608 {file.code}
609 </Text>
610 </Box>
611 <Button key={`git-${file.path}`} plain label={file.path} onPress={() => showGitDiff($, gitStatus, file)} />
612 {file.added > 0 && <Text color="diffAdded">{`+${file.added}`}</Text>}
613 {file.removed > 0 && <Text color="diffRemoved">{`-${file.removed}`}</Text>}
614 </Box>
615 ))}
616 </Box>
617 ),
618 )}
619 {section(
620 `✎ Files edited in this session (${files.length})`,
621 'permission',
622 files.length === 0 ? (
623 <Text dimColor italic>
624 Claude has not edited any files yet.
625 </Text>
626 ) : (
627 <Box flexDirection="column">
628 {files.map(([file, versions]) => (
629 <Box flexDirection="column">
630 <Box flexDirection="row" columnGap={1}>
631 <Text color={fileColor(file)}>●</Text>
632 <Button key={`diff-${file}`} plain label={shortPath(file, paths.cwd)} onPress={() => showDiff($, paths, file, versions)} />
633 </Box>
634 <Box flexDirection="row" columnGap={1} paddingLeft={2}>
635 <Text color="suggestion">{`${versions.length} versions`}</Text>
636 <Text dimColor>{`last ${shortTime(Date.parse(versions.at(-1)?.time ?? ''))}`}</Text>
637 <Button key={`edit-${file}`} plain label="edit" onPress={() => openInEditor($, file)} />
638 </Box>
639 </Box>
640 ))}
641 </Box>
642 ),
643 )}
644 <Text color="inactive" italic>
645 Enter shows a diff: git files against HEAD, session files against the first backup Claude took.
646 </Text>
647 </Box>,
648 )
649 }
650
651 if (current === 'activity') {
652 const [agents, tasks] = await Promise.all([$.agent.list(), listOr($, `${paths.tmp}/tasks`)])
653 const taskFiles = tasks.filter(task => task.kind === 'file').sort((a, b) => b.mtimeMs - a.mtimeMs)
654
655 return frame(
656 actions(null),
657 <Box flexDirection="column" rowGap={1}>
658 {section(
659 `⚙ Agents (${agents.length})`,
660 'autoAccept',
661 agents.length === 0 ? (
662 <Text dimColor italic>
663 No subagents or teammates in this session.
664 </Text>
665 ) : (
666 <Box flexDirection="column">
667 {agents.map(agent => (
668 <Box flexDirection="column">
669 <Box flexDirection="row" columnGap={1}>
670 <Text bold color={AGENT_COLORS[agent.status] ?? 'text'}>{`● ${agent.status}`}</Text>
671 <Text color="merged">{agent.type}</Text>
672 {agent.name && <Text color="suggestion">{agent.name}</Text>}
673 </Box>
674 <Box paddingLeft={2}>
675 <Text dimColor wrap="truncate-end">
676 {agent.description}
677 </Text>
678 </Box>
679 </Box>
680 ))}
681 </Box>
682 ),
683 )}
684 {section(
685 `▶ Background task output (${taskFiles.length})`,
686 'bashBorder',
687 taskFiles.length === 0 ? (
688 <Text dimColor italic>
689 No background task output yet.
690 </Text>
691 ) : (
692 <Box flexDirection="column">
693 {taskFiles.map(task => (
694 <Box flexDirection="row" columnGap={1}>
695 <Text color="bashBorder">▸</Text>
696 <Button key={`task-${task.name}`} plain label={task.name} onPress={() => openInEditor($, `${paths.tmp}/tasks/${task.name}`)} />
697 <Text color="inactive">{formatSize(task.size)}</Text>
698 <Text dimColor>{shortTime(task.mtimeMs)}</Text>
699 </Box>
700 ))}
701 </Box>
702 ),
703 )}
704 </Box>,
705 )
706 }
707
708 if (current === 'stats') {
709 const turns = transcript.turnSeconds.map((seconds, index) => ({ index: index + 1, seconds })).slice(-15)
710 const longestTurn = Math.max(0, ...transcript.turnSeconds)
711 const totalSeconds = transcript.turnSeconds.reduce((sum, seconds) => sum + seconds, 0)
712 const tools = Object.entries(transcript.toolCounts).sort(([, a], [, b]) => b - a)
713 const topTool = tools[0]?.[1] ?? 0
714 const heaviest = [...transcript.requests].sort((a, b) => b.output - a.output).slice(0, 5)
715 const sum = (pick: (one: (typeof transcript.requests)[number]) => number) => transcript.requests.reduce((total, one) => total + pick(one), 0)
716 const hitRate = cacheHitRate(transcript.requests)
717
718 return frame(
719 actions(null),
720 <Box flexDirection="column" rowGap={1}>
721 {section(
722 'Σ Totals',
723 'claude',
724 <Box flexDirection="column">
725 {fact('Requests', String(transcript.requests.length), 'suggestion')}
726 {fact('Turns', `${transcript.turnSeconds.length}, ${(totalSeconds / 60).toFixed(1)} min of work`, 'suggestion')}
727 {fact('Input', formatCount(sum(one => one.input)), 'ide')}
728 {fact('Output', formatCount(sum(one => one.output)), 'success')}
729 {fact('Cache read', formatCount(sum(one => one.cacheRead)), 'merged')}
730 {fact('Cache write', formatCount(sum(one => one.cacheWrite)), 'merged')}
731 {gauge('Cache hits', hitRate, 'of input served from cache')}
732 </Box>,
733 )}
734 {section(
735 `⏱ Turn durations (last ${turns.length})`,
736 'warning',
737 <Box flexDirection="column">
738 {turns.map(turn =>
739 bar(`#${turn.index}`, turn.seconds, longestTurn, `${turn.seconds.toFixed(1)}s`, turn.seconds === longestTurn ? 'error' : 'warning'),
740 )}
741 </Box>,
742 )}
743 {section(
744 '⚒ Tools used',
745 'permission',
746 <Box flexDirection="column">
747 {tools.map(([name, count]) => bar(name, count, topTool, String(count), name === 'Bash' ? 'bashBorder' : 'permission'))}
748 </Box>,
749 )}
750 {section(
751 '⇡ Heaviest requests',
752 'success',
753 <Box flexDirection="column">
754 {heaviest.map(one => (
755 <Box flexDirection="row" columnGap={1}>
756 <Text dimColor>{one.time}</Text>
757 <Text color="success">{`out ${formatCount(one.output)}`}</Text>
758 <Text color="merged">{`cache ${formatCount(one.cacheRead)}`}</Text>
759 <Text color="inactive">{one.model}</Text>
760 </Box>
761 ))}
762 </Box>,
763 )}
764 </Box>,
765 )
766 }
767
768 if (current === 'usage') {
769 const [model, version, turns, repo, usage, envFiles] = await Promise.all([
770 $.session.model(),
771 $.session.version(),
772 $.session.turns(),
773 $.session.repo(),
774 $.session.usage({ breakdown: 'summary', columns }),
775 listOr($, paths.env),
776 ])
777 const envContents = await Promise.all(
778 envFiles
779 .filter(entry => entry.kind === 'file')
780 .map(async entry => ({ name: entry.name, text: await $.fs.read(`${paths.env}/${entry.name}`) })),
781 )
782 const { context } = usage
783 const breakdown = context.breakdown
784 const categories = (breakdown?.categories ?? []).filter(category => category.kind !== 'deferred' && category.tokens > 0)
785
786 return frame(
787 actions(null),
788 <Box flexDirection="column" rowGap={1}>
789 {section(
790 '◆ Session',
791 'claude',
792 <Box flexDirection="column">
793 {meta.title && fact('Title', meta.title, 'claude')}
794 {fact('ID', paths.id, 'merged')}
795 {fact('Model', model, 'claude')}
796 {fact('Effort', meta.effort || 'n/a', 'planMode')}
797 {fact('Mode', meta.permissionMode || 'n/a', 'autoAccept')}
798 {fact('Claude Code', meta.versions.length > 1 ? meta.versions.join(' → ') : version.version)}
799 {fact('Started', shortTime(usage.startedAt))}
800 {fact('Turns', String(turns), 'suggestion')}
801 {fact('Cost', usage.cost ? `$${usage.cost.usd.toFixed(2)}` : 'n/a', 'success')}
802 {fact('Working dir', paths.cwd, 'ide')}
803 {fact('Repository', repo ? `${repo.name ?? repo.root}${repo.remote ? ` (${repo.remote})` : ''}` : 'none', 'ide')}
804 {meta.branch && fact('Branch', meta.branch, 'ide')}
805 </Box>,
806 )}
807 {usage.rateLimits.length > 0 &&
808 section(
809 '◔ Rate limits',
810 'error',
811 <Box flexDirection="column">
812 {usage.rateLimits.map(limit =>
813 gauge(limit.kind, limit.percentUsed, limit.resetsAt ? `resets ${shortTime(Date.parse(limit.resetsAt))}` : ''),
814 )}
815 </Box>,
816 )}
817 {section(
818 '▤ Context',
819 'warning',
820 <Box flexDirection="column">
821 {context.percent === undefined
822 ? fact('Context', 'not measured yet')
823 : gauge('Context', context.percent, `${formatCount(context.tokens ?? 0)} / ${formatCount(context.window)}`)}
824 {breakdown?.isAutoCompactEnabled && breakdown.autoCompactThreshold !== undefined && (
825 <Box>
826 {fact(
827 'Auto-compact',
828 `at ${formatCount(breakdown.autoCompactThreshold)}, ${formatCount(Math.max(0, breakdown.autoCompactThreshold - breakdown.totalTokens))} tokens left`,
829 'warning',
830 )}
831 </Box>
832 )}
833 {breakdown && (
834 <Box flexDirection="column" marginTop={1}>
835 {breakdown.gridRows.map(row => (
836 <Text>
837 {row.map(square => (
838 <Text color={square.color}>{square.isFilled ? '■ ' : '□ '}</Text>
839 ))}
840 </Text>
841 ))}
842 </Box>
843 )}
844 {categories.map(category => (
845 <Box flexDirection="row" columnGap={1}>
846 <Text color={category.color}>■</Text>
847 <Box width={22} flexShrink={0}>
848 <Text wrap="truncate-end">{category.name}</Text>
849 </Box>
850 <Text color={category.color}>{formatCount(category.tokens)}</Text>
851 <Text dimColor>{`${((category.tokens / Math.max(1, breakdown?.maxTokens ?? 1)) * 100).toFixed(1)}%`}</Text>
852 </Box>
853 ))}
854 {breakdown && breakdown.memoryFiles.length > 0 && (
855 <Box flexDirection="column" marginTop={1}>
856 <Text bold color="suggestion">
857 Memory files
858 </Text>
859 {breakdown.memoryFiles.map(file => (
860 <Box flexDirection="row" columnGap={1}>
861 <Text color="merged">{formatCount(file.tokens)}</Text>
862 <Text dimColor wrap="truncate-start">
863 {file.path}
864 </Text>
865 </Box>
866 ))}
867 </Box>
868 )}
869 </Box>,
870 )}
871 {section(
872 '⌂ Paths',
873 'suggestion',
874 <Box flexDirection="column">
875 {fact('Transcript', paths.transcript, 'merged')}
876 {fact('session-env', paths.env, 'merged')}
877 {fact('File history', paths.fileHistory, 'merged')}
878 {fact('Scratch dir', paths.tmp, 'merged')}
879 <Box flexDirection="row" columnGap={1} marginTop={1}>
880 <Button key="env-open-transcript" label="Edit transcript" onPress={() => openInEditor($, paths.transcript)} />
881 <Button key="env-open-scratch" label="Browse scratch dir" onPress={() => update($, tab, () => 'files' as const)} />
882 </Box>
883 </Box>,
884 )}
885 {section(
886 '$ session-env',
887 'success',
888 envContents.length === 0 ? (
889 <Text dimColor italic>
890 Empty, the session has not exported any variables yet.
891 </Text>
892 ) : (
893 <Box flexDirection="column">
894 {envContents.map(file => (
895 <Box flexDirection="column">
896 <Text color="success">{file.name}</Text>
897 <Code source={file.text} language="sh" />
898 </Box>
899 ))}
900 </Box>
901 ),
902 )}
903 </Box>,
904 )
905 }
906
907 const here = relative ? `${paths.tmp}/${relative}` : paths.tmp
908 const entries = sortEntries(await listOr($, here))
909 const crumbs = ['scratchpad root', ...(relative ? relative.split('/') : [])]
910
911 return frame(
912 actions(
913 relative && (
914 <Button key="parent" label="↰ Up" hotkey="b" onPress={() => update($, dir, d => d.split('/').slice(0, -1).join('/'))} />
915 ),
916 ),
917 <Box flexDirection="column" rowGap={1}>
918 {section(
919 '▣ Scratch directory',
920 'ide',
921 <Box flexDirection="column">
922 <Text color="inactive" wrap="truncate-start">
923 {paths.tmp}
924 </Text>
925 <Text>
926 {crumbs.map((crumb, index) => (
927 <Text color={index === crumbs.length - 1 ? 'suggestion' : 'inactive'} bold={index === crumbs.length - 1}>
928 {index === 0 ? crumb : ` › ${crumb}`}
929 </Text>
930 ))}
931 </Text>
932 </Box>,
933 )}
934 <Box flexDirection="column">
935 {entries.length === 0 && (
936 <Text dimColor italic>
937 Empty directory.
938 </Text>
939 )}
940 {entries.map(entry =>
941 entry.kind === 'dir' ? (
942 <Box flexDirection="row" columnGap={1}>
943 <Text color="suggestion">▸</Text>
944 <Button
945 key={`dir-${entry.name}`}
946 plain
947 label={`${entry.name}/`}
948 onPress={async () => {
949 await update($, dir, d => (d ? `${d}/${entry.name}` : entry.name))
950 await update($, offset, () => 0)
951 }}
952 />
953 </Box>
954 ) : (
955 <Box flexDirection="row" columnGap={1}>
956 <Text color={fileColor(entry.name)}>●</Text>
957 <Button
958 key={`file-${entry.name}`}
959 plain
960 label={entry.name}
961 onPress={async () => {
962 const path = `${here}/${entry.name}`
963 if (entry.name.toLowerCase().endsWith('.png')) await $.process.run(['open', path])
964 else await openInEditor($, path)
965 }}
966 />
967 <Text color="inactive">{formatSize(entry.size)}</Text>
968 <Text dimColor>{shortTime(entry.mtimeMs)}</Text>
969 </Box>
970 ),
971 )}
972 </Box>
973 <Text color="inactive" italic>
974 Enter opens a file in $EDITOR in a new herdr pane, a PNG in the system viewer.
975 </Text>
976 </Box>,
977 )
978 })
979}
980hooks/format.ts 302 lines1type Block = {
2 type: string
3 name?: string
4 id?: string
5 input?: Record<string, unknown>
6 tool_use_id?: string
7 content?: unknown
8 is_error?: boolean
9}
10
11type Usage = {
12 input_tokens?: number
13 output_tokens?: number
14 cache_read_input_tokens?: number
15 cache_creation_input_tokens?: number
16}
17
18type Backup = { backupFileName: string | null; version: number; backupTime: string }
19
20type Row = {
21 type: string
22 subtype?: string
23 timestamp?: string
24 requestId?: string
25 durationMs?: number
26 gitBranch?: string
27 effort?: string
28 version?: string
29 aiTitle?: string
30 permissionMode?: string
31 snapshot?: { trackedFileBackups?: Record<string, Backup> }
32 trackingPath?: string
33 backup?: Backup
34 message?: { model?: string; content?: string | Block[]; usage?: Usage }
35}
36
37export type ToolCall = {
38 id: string
39 name: string
40 startedAt: string
41 durationMs: number | null
42 summary: string
43 source: string
44 language: string
45 format: 'source' | 'diff'
46 result: string
47 isError: boolean | null
48}
49
50export type RequestStats = { time: string; model: string; input: number; output: number; cacheRead: number; cacheWrite: number }
51
52export type FileVersion = { backupFileName: string | null; version: number; time: string }
53
54export type SessionMeta = {
55 title: string
56 branch: string
57 permissionMode: string
58 effort: string
59 versions: string[]
60}
61
62export type Transcript = {
63 rows: number
64 prompts: number
65 outputTokens: number
66 meta: SessionMeta
67 toolCalls: ToolCall[]
68 turnSeconds: number[]
69 requests: RequestStats[]
70 toolCounts: Record<string, number>
71 fileVersions: Record<string, FileVersion[]>
72}
73
74const MAX_TEXT = 20_000
75
76const LANGUAGES: Record<string, string> = {
77 ts: 'typescript',
78 tsx: 'tsx',
79 js: 'javascript',
80 mjs: 'javascript',
81 json: 'json',
82 md: 'markdown',
83 py: 'python',
84 sh: 'sh',
85 java: 'java',
86 kt: 'kotlin',
87 vue: 'vue',
88 css: 'css',
89 html: 'html',
90 yml: 'yaml',
91 yaml: 'yaml',
92 sql: 'sql',
93}
94
95export function clip(text: string, max = MAX_TEXT): string {
96 return text.length > max ? `${text.slice(0, max)}\n... (+${text.length - max} chars)` : text
97}
98
99export function formatSize(bytes: number): string {
100 if (bytes < 1024) return `${bytes} B`
101 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
102
103 return `${(bytes / 1024 / 1024).toFixed(1)} MB`
104}
105
106export function formatTime(iso?: string): string {
107 return iso ? new Date(iso).toLocaleTimeString('en-GB', { hour12: false }) : ''
108}
109
110export function formatDuration(ms: number | null): string {
111 if (ms === null) return 'running'
112 if (ms < 1000) return `${ms} ms`
113 if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`
114
115 return `${Math.floor(ms / 60_000)} m ${Math.round((ms % 60_000) / 1000)} s`
116}
117
118export function formatCount(count: number): string {
119 return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count)
120}
121
122export function meter(percent: number, width = 20): string {
123 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
124
125 return '█'.repeat(filled) + '░'.repeat(width - filled)
126}
127
128export function previewLines(text: string, count: number): string[] {
129 const lines = text.split('\n').filter(line => line.trim() !== '')
130
131 return Array.from({ length: count }, (_, index) => lines[index] ?? '')
132}
133
134export function languageOf(path: string): string {
135 return LANGUAGES[path.split('.').pop()?.toLowerCase() ?? ''] ?? ''
136}
137
138function textOf(content: unknown): string {
139 if (typeof content === 'string') return content
140 if (Array.isArray(content)) {
141 return content.map(part => (typeof part === 'object' && part && 'text' in part ? String(part.text) : '')).join('\n')
142 }
143
144 return content === undefined ? '' : JSON.stringify(content)
145}
146
147function editDiff(path: string, before: string, after: string): string {
148 const removed = before.split('\n').map(line => `-${line}`)
149 const added = after.split('\n').map(line => `+${line}`)
150
151 return [`--- ${path}`, `+++ ${path}`, `@@ -1,${removed.length} +1,${added.length} @@`, ...removed, ...added].join('\n')
152}
153
154export function describeTool(name: string, input: Record<string, unknown>): Pick<ToolCall, 'summary' | 'source' | 'language' | 'format'> {
155 const text = (key: string) => (typeof input[key] === 'string' ? (input[key] as string) : '')
156 const path = text('file_path')
157
158 if (name === 'Bash') return { summary: text('description'), source: clip(text('command')), language: 'sh', format: 'source' }
159 if (name === 'Edit' && path) {
160 return { summary: path, source: clip(editDiff(path, text('old_string'), text('new_string'))), language: languageOf(path), format: 'diff' }
161 }
162 if (name === 'Write' && path) return { summary: path, source: clip(text('content')), language: languageOf(path), format: 'source' }
163 if (name === 'Read' && path) return { summary: path, source: path, language: '', format: 'source' }
164
165 return { summary: '', source: clip(JSON.stringify(input, null, 2)), language: 'json', format: 'source' }
166}
167
168function addFileVersion(versions: Record<string, FileVersion[]>, path: string, backup: Backup) {
169 const list = (versions[path] ??= [])
170 if (list.some(one => one.version === backup.version)) return
171 list.push({ backupFileName: backup.backupFileName, version: backup.version, time: backup.backupTime })
172 list.sort((a, b) => a.version - b.version)
173}
174
175export function parseTranscript(jsonl: string): Transcript {
176 const rows: Row[] = jsonl
177 .split('\n')
178 .filter(Boolean)
179 .flatMap(line => {
180 try {
181 return [JSON.parse(line) as Row]
182 } catch {
183 return []
184 }
185 })
186
187 const seenRequests = new Set<string>()
188 const meta: SessionMeta = { title: '', branch: '', permissionMode: '', effort: '', versions: [] }
189 const toolCalls = new Map<string, ToolCall>()
190 const turnSeconds: number[] = []
191 const requests: RequestStats[] = []
192 const toolCounts: Record<string, number> = {}
193 const fileVersions: Record<string, FileVersion[]> = {}
194 let prompts = 0
195 let outputTokens = 0
196
197 for (const row of rows) {
198 const content = row.message?.content
199 if (row.aiTitle) meta.title = row.aiTitle
200 if (row.permissionMode) meta.permissionMode = row.permissionMode
201 if (row.gitBranch) meta.branch = row.gitBranch
202 if (row.effort) meta.effort = row.effort
203 if (row.version && meta.versions.at(-1) !== row.version) meta.versions.push(row.version)
204
205 if (row.type === 'file-history-snapshot') {
206 for (const [path, backup] of Object.entries(row.snapshot?.trackedFileBackups ?? {})) addFileVersion(fileVersions, path, backup)
207 } else if (row.type === 'file-history-delta' && row.trackingPath && row.backup) {
208 addFileVersion(fileVersions, row.trackingPath, row.backup)
209 } else if (row.type === 'user' && typeof content === 'string') {
210 prompts++
211 } else if (row.type === 'user' && Array.isArray(content)) {
212 for (const block of content) {
213 const call = block.type === 'tool_result' ? toolCalls.get(block.tool_use_id ?? '') : undefined
214 if (!call) continue
215 call.result = clip(textOf(block.content))
216 call.isError = block.is_error === true
217 call.durationMs = row.timestamp && call.startedAt ? Math.max(0, Date.parse(row.timestamp) - Date.parse(call.startedAt)) : null
218 }
219 } else if (row.type === 'assistant' && Array.isArray(content)) {
220 if (!row.requestId || !seenRequests.has(row.requestId)) {
221 if (row.requestId) seenRequests.add(row.requestId)
222 const usage = row.message?.usage
223 outputTokens += usage?.output_tokens ?? 0
224 requests.push({
225 time: formatTime(row.timestamp),
226 model: row.message?.model ?? '',
227 input: usage?.input_tokens ?? 0,
228 output: usage?.output_tokens ?? 0,
229 cacheRead: usage?.cache_read_input_tokens ?? 0,
230 cacheWrite: usage?.cache_creation_input_tokens ?? 0,
231 })
232 }
233 for (const block of content) {
234 if (block.type !== 'tool_use' || !block.id) continue
235 const name = block.name ?? 'tool'
236 toolCounts[name] = (toolCounts[name] ?? 0) + 1
237 toolCalls.set(block.id, {
238 id: block.id,
239 name,
240 startedAt: row.timestamp ?? '',
241 durationMs: null,
242 result: '',
243 isError: null,
244 ...describeTool(name, block.input ?? {}),
245 })
246 }
247 } else if (row.type === 'system' && row.subtype === 'turn_duration' && row.durationMs !== undefined) {
248 turnSeconds.push(row.durationMs / 1000)
249 }
250 }
251
252 return {
253 rows: rows.length,
254 prompts,
255 outputTokens,
256 meta,
257 toolCalls: [...toolCalls.values()],
258 turnSeconds,
259 requests,
260 toolCounts,
261 fileVersions,
262 }
263}
264
265export function cacheHitRate(requests: RequestStats[]): number {
266 const read = requests.reduce((sum, one) => sum + one.cacheRead, 0)
267 const total = requests.reduce((sum, one) => sum + one.input + one.cacheRead + one.cacheWrite, 0)
268
269 return total === 0 ? 0 : (read / total) * 100
270}
271
272export type GitFile = { code: string; path: string; added: number; removed: number }
273
274export type GitStatus = { root: string; branch: string; files: GitFile[] }
275
276export function parseGitStatus(root: string, porcelain: string, numstat: string): GitStatus {
277 const counts = new Map<string, { added: number; removed: number }>()
278 for (const line of numstat.split('\n').filter(Boolean)) {
279 const [added = '0', removed = '0', path = ''] = line.split('\t')
280 counts.set(path, { added: Number(added) || 0, removed: Number(removed) || 0 })
281 }
282 const lines = porcelain.split('\n').filter(Boolean)
283 const branch = lines[0]?.startsWith('## ') ? lines.shift()!.slice(3) : ''
284 const files = lines.map(line => {
285 const code = line.slice(0, 2).trim() || '?'
286 const path = line.slice(3).split(' -> ').pop() ?? ''
287
288 return { code, path, ...(counts.get(path) ?? { added: 0, removed: 0 }) }
289 })
290
291 return { root, branch, files }
292}
293
294export function diffCounts(diff: string): { added: number; removed: number } {
295 const lines = diff.split('\n').filter(line => !line.startsWith('+++') && !line.startsWith('---'))
296
297 return {
298 added: lines.filter(line => line.startsWith('+')).length,
299 removed: lines.filter(line => line.startsWith('-')).length,
300 }
301}
302types/index.d.ts 36 lines1export type SessionTab = 'usage' | 'tools' | 'changes' | 'activity' | 'files' | 'stats' | 'help'
2
3export type SessionPaths = {
4 id: string
5 cwd: string
6 transcript: string
7 env: string
8 tmp: string
9 fileHistory: string
10}
11
12export type SessionDiff = { path: string; text: string; against: string }
13
14export type SessionGit = {
15 root: string
16 branch: string
17 files: { code: string; path: string; added: number; removed: number }[]
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'session-panel': {
23 tab: SessionTab
24 dir: string
25 tick: number
26 offset: number
27 isErrorsOnly: boolean
28 selectedTool: string | null
29 listOffset: number
30 diff: SessionDiff | null
31 git: SessionGit | null
32 paths: SessionPaths | null
33 }
34 }
35}
36