SLOPSHOPPER

session-panel

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

newpanecommandtoastprocesstimer
v0.1.0no licenseupdated 2026-10-08mlopian/claude-plugins/session-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-panel
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ [ Usage ] [ Tool calls ] [ Changes ] [ Activ │ ┃ ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ────────── ⏺ Update(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ │ ◆ Session ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ ID preview-session │ ┃ │ Model claude-opus-5-5 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Effort n/a │ ┃ │ Mode n/a ✻ Worked for 42s · done 4:20 PM │ ┃ │ Claude Code 2.1.289 │ ┃ │ Started 09 Oct, 08:23 › /session-panel │ ┃ │ Turns 1 ⎿ session-panel: Session panel opened. │ ┃ │ Cost $0.42 │ ┃ │ Working dir /work/app │ ┃ │ Repository /work/app │ ┃ │ (https://github.com/acme/ap │ ┃ │ │ ┃ ╰─────────────────────────────────────────── │ ┃ │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◔ Rate limits │ ┃ │ five_hour ██████░░░░░░░░░░░░░░ 31% re │ ┃ ╰─────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
[ Usage ] [ Tool calls ] [ Changes ] [ Activity ] [ Files ] ────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────── │ ◆ Session │ │ ID preview-session │ Model claude-opus-5-5 │ Effort n/a │ Mode n/a │ Claude Code 2.1.289 │ Started 09 Oct, 08:23 │ Turns 1 │ Cost $0.42 │ Working dir /work/app │ Repository /work/app (https://github.com/acme/app.git) │ ╰─────────────────────────────────────────────────────────── ╭─────────────────────────────────────────────────────────── │ ◔ Rate limits │ five_hour ██████░░░░░░░░░░░░░░ 31% resets Invalid Dat ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ ▤ Context │ │ Context ██████████░░░░░░░░░░ 49% 97.4k / 200.0k │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ ⌂ Paths │ Transcript /Users/dev/.claude/projects/-work-app/previ │ onl │ session-env /Users/dev/.claude/session-env/preview-sess │ File history /Users/dev/.claude/file-history/preview-ses │ Scratch dir /tmp/claude-/-work-app/preview-session │ │ [ Edit transcript ] [ Browse scratch dir ] ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ $ session-env │ │ Empty, the session has not exported any variables yet. │ ╰──────────────────────────────────────────────────────────╯
README

Session panel

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.

Install

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.

Tabs

KeyTabShows
uUsageSession 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
tTool callsOne 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
cChangesgit 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
aActivitySubagents and teammates with their status, and background task output files
fFilesBrowser of the session's scratch directory, including pasted images
sStatsToken totals, cache hit rate, turn durations, tool usage and the heaviest requests
h?All keyboard shortcuts

Keyboard

KeyAction
ctrl+x tabMove the keyboard from the prompt to the panel (Claude Code)
escGive the keyboard back to the prompt (Claude Code)
ctrl+x xClose the panel (Claude Code)
enterOpen the focused tool call, diff, file or folder
bBack from a tool call or diff to its list, or to the parent folder in Files
j / kScroll the tool call list by half a page of cards
eTool calls: show only failed calls, or all of them
rTool 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.

Behavior

  • Reads the transcript from ~/.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>/.
  • Tool calls refresh after every turn and with 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.
  • The context breakdown uses Claude Code's local estimate (summary), so it sends no token-count requests.
  • Changes lists only files edited with Claude's Edit and Write tools. Files changed through Bash have no backups; the git section shows them when they are inside the repository.
  • 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.

Troubleshooting

  • If /session-panel is unknown, check /plugin to confirm session-panel is installed and enabled, then start a new session.
  • If no tab is highlighted after an update, the panel remembers a tab that no longer exists. Pick any tab.
  • If the editor does not open, check that $EDITOR is set and that the session runs inside herdr or tmux.
  • open for PNG files is macOS only.

Validate and test

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.

Source 3 files
hooks/register.tsx 980 lines
1import { 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}
980
hooks/format.ts 302 lines
1type 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}
302
types/index.d.ts 36 lines
1export 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