/dash opens a live side pane with tool calls, files touched, tokens and turn times

Small plugins for Claude Code. Each mod is one TypeScript file of function hooks.
<img src="assets/hero.svg" alt="Claude Code with all four mods running: seatbelt blocking a dangerous rm, the session-dash pane, the turn-meter status line and a ding toast" width="900">
turn-meter a turn timer in the status line
seatbelt blocks destructive commands and edits to secrets
ding a chime when a long turn finishes
session-dash /dash, a live pane of what the session did
context-meter a context window meter under the prompt; click it for the breakdown
Also: claude-familiar, a pixel companion that sits above the prompt and reacts to your session, and claude-readout, which names the files on the folded Read 3 files line.
git clone https://github.com/lucenity0/claude-code-mods ~/claude-code-mods
claude --plugin-dir ~/claude-code-mods/seatbelt --plugin-dir ~/claude-code-mods/session-dash
To turn on function hooks and load the mods in every session, set this in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
"CLAUDE_CODE_PLUGIN_DIRS": "~/claude-code-mods/turn-meter:~/claude-code-mods/seatbelt:~/claude-code-mods/ding:~/claude-code-mods/session-dash:~/claude-code-mods/context-meter"
}
}
These use function hooks, an early-access API that CLAUDE_CODE_ENABLE_FUNCTION_HOOKS turns on, and were tested on Claude Code 2.1.287.
The status line counts up while Claude works. When the turn ends it shows the summary.
✓ 41s · 12.3k in · 1.8k out
on('turn.start', ($, e, next) => {
ticker?.cancel()
let seconds = 0
$.ui.status('running 0s')
ticker = $.clock.every(1000, () => {
seconds += 1
$.ui.status(`running ${formatDuration(seconds * 1000)}`)
})
return next(e)
})
Checks every Bash, Edit and Write call before it runs, and denies the ones that would hurt.
● Bash(rm -rf /tmp/build/../../)
⎿ seatbelt: blocked `rm -rf /tmp/build/../../` (rm -rf on / or ~): it
recursively deletes the filesystem root, a top-level folder or your home folder…
blocks still allows
rm -rf / rm -fr ~ rm -rf /x/../../ rm -rf node_modules
git push -f origin main git push --force origin my-branch
curl … | sh curl -o install.sh …
mkfs dd of=/dev/… chmod -R 777 chmod 755 script.sh
edits to .env *.pem ~/.ssh/* .env.example
Each rule is a small object in rules.ts:
{
name: 'curl | sh',
reason: 'pipes a script from the internet straight into a shell',
matches: command => /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(command),
}
It's a guardrail against accidents, not a sandbox.
When a turn that ran longer than 30 seconds finishes, ding shows a toast and plays an original two-note chime. The sound plays on macOS only.
{ "pluginConfigs": { "ding": { "options": { "thresholdSeconds": 60, "sound": false } } } }
Type /dash to open the pane. Type /dash again, or press q, to close it.
╭─ Session ─────────────────────────────╮
│ 4 turns · 2m13s · 184k in · 9.2k out │
│ │
│ Tools (23 calls) │
│ Bash ████████████████████ 9 │
│ Read ███████████████ 7 │
│ Edit █████████ 4 │
│ │
│ Turn time · max 1m12s │
│ ▂▄▃▆▁▅█▃▄▁▆▃ │
│ │
│ Recent files │
│ src/server.ts │
│ README.md │
│ │
│ [ Reset ] [ Close ] │
╰───────────────────────────────────────╯
on('tool.call', async ($, e, next) => {
const ran = await next(e)
await update($, stats, current =>
countTool(current, e.tool, { isDenied: ran.deny !== undefined, isError: ran.isError === true }),
)
return ran
})
A meter sits at the bottom right of the prompt, under the input. It turns yellow at 60% and red at 80%.
ctx ▰▰▰▱▱▱▱▱ 42%
Click it, or type /ctx, to open the breakdown. Esc closes it.
42% 84k of 200k claude-opus-5-5 · estimated
██████████████████████████▌█░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒
83k free · compacts in 83k
● Messages 62k ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 74%
● System tools 13k ━━━━━━━━ 15%
● Skills 4.9k ━━━ 6%
● System prompt 3.7k ━━ 4%
○ on demand 93k MCP tools, not in the window
last request 84k in · 82k cached · 967 out
memory
CLAUDE.md ~/code/app 900
[ Count exactly ] [ Refresh ] [ Compact ] [ Close ]
The breakdown is a local estimate. Count exactly counts it with the token-count API, as /context does.
on('session.measure', async ($, e, next) => {
await update($, fill, () => e.context)
if (e.changed.includes('context') && (await isPaneOpen($))) void loadDetails($, 'summary')
return next(e)
})
hello/
├── .claude-plugin/plugin.json { "name": "hello", "version": "0.1.0", "description": "…" }
└── hooks/
├── hooks.json { "modules": ["./register.ts"] }
└── register.ts
import type { Register } from 'claude-code'
export const register: Register = on => {
on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
const result = await next(e)
$.ui.toast(`edited ${e.file_path.split('/').pop()}`)
return result
})
}
claude plugin validate ./hello
claude plugin test ./hello
<sub>MIT · built with Claude Code</sub>
hooks/register.tsx 148 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import {
5 EMPTY,
6 countTool,
7 countTurn,
8 formatDuration,
9 formatTokens,
10 rasterCells,
11 sparkline,
12} from './stats'
13
14const PANE = 'session-dash'
15const ACCENT = '#d97757'
16const stats = atom({ plugin: 'session-dash', key: 'stats' } as const, EMPTY)
17
18const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'NotebookEdit'])
19
20function pathOf(input: object): string | undefined {
21 const value =
22 'file_path' in input ? input.file_path : 'notebook_path' in input ? input.notebook_path : undefined
23 return typeof value === 'string' ? value : undefined
24}
25
26export const register: Register = on => {
27 on('session.start', async ($, e, next) => {
28 await $.command.register({
29 name: 'dash',
30 description: 'Toggle the session dashboard pane',
31 })
32
33 return next(e)
34 })
35
36 on('command.run', { command: 'dash' }, async $ => {
37 const isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
38 if (isOpen) {
39 await $.ui.close({ id: PANE })
40 return { text: 'Session dashboard closed.' }
41 }
42
43 await $.ui.open({ id: PANE, title: 'Session' })
44 return { text: 'Session dashboard opened. /dash again to close it.' }
45 })
46
47 on('tool.call', async ($, e, next) => {
48 const ran = await next(e)
49 const path = FILE_TOOLS.has(e.tool) ? pathOf(e) : undefined
50 await update($, stats, current =>
51 countTool(current, e.tool, {
52 isDenied: ran.deny !== undefined,
53 isError: ran.isError === true,
54 ...(path === undefined ? {} : { path }),
55 }),
56 )
57
58 return ran
59 })
60
61 on('turn.complete', async ($, e, next) => {
62 await update($, stats, current =>
63 countTurn(current, {
64 durationMs: e.durationMs,
65 isMainLoop: e.agentId === undefined,
66 ...(e.usage === undefined ? {} : { usage: e.usage }),
67 }),
68 )
69
70 return next(e)
71 })
72
73 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
74 const { Box, Text, Button } = $.ui.resolve(e)
75 const current = await read($, stats)
76 const width = Math.max(20, e.props.bodyColumns)
77
78 const tools = Object.entries(current.toolCounts).sort((a, b) => b[1] - a[1])
79 const totalCalls = tools.reduce((sum, [, count]) => sum + count, 0)
80 const topCount = tools[0]?.[1] ?? 1
81 const nameWidth = Math.min(14, Math.max(4, ...tools.map(([name]) => name.length)))
82 const barRoom = Math.max(4, width - nameWidth - 7)
83
84 const turns = current.turnDurations
85 const totalTime = turns.reduce((sum, ms) => sum + ms, 0)
86 const spark = sparkline(turns.slice(-width))
87
88 let sparkNode = <Text dimColor>No turns yet.</Text>
89 if (turns.length > 0 && e.surface === 'terminal') {
90 const { Raster } = $.ui.resolve(e)
91 sparkNode = <Raster key="spark" columns={[...spark].length} rows={1} cells={rasterCells(spark, 0xd97757)} />
92 } else if (turns.length > 0) {
93 sparkNode = <Text color={ACCENT}>{spark}</Text>
94 }
95
96 const shortName = (name: string) =>
97 name.length > nameWidth ? `${name.slice(0, nameWidth - 1)}…` : name.padEnd(nameWidth)
98
99 return (
100 <Box flexDirection="column">
101 <Text bold>
102 {turns.length} turns · {formatDuration(totalTime)} · {formatTokens(current.tokensIn)} in ·{' '}
103 {formatTokens(current.tokensOut)} out
104 </Text>
105 {(current.blocked > 0 || current.errors > 0) && (
106 <Text color="#c0504d">
107 {current.blocked} blocked · {current.errors} errored
108 </Text>
109 )}
110
111 <Box flexDirection="column" marginTop={1}>
112 <Text dimColor>Tools ({totalCalls} calls)</Text>
113 {tools.length === 0 && <Text dimColor>No tool calls yet.</Text>}
114 {tools.slice(0, 8).map(([name, count]) => (
115 <Text>
116 {shortName(name)} <Text color={ACCENT}>{'█'.repeat(Math.max(1, Math.round((count / topCount) * barRoom)))}</Text>{' '}
117 {count}
118 </Text>
119 ))}
120 </Box>
121
122 <Box flexDirection="column" marginTop={1}>
123 <Text dimColor>
124 Turn time (last {turns.length}){turns.length > 0 ? ` · max ${formatDuration(Math.max(...turns))}` : ''}
125 </Text>
126 {sparkNode}
127 </Box>
128
129 <Box flexDirection="column" marginTop={1}>
130 <Text dimColor>Recent files ({current.files.length})</Text>
131 {current.files.length === 0 && <Text dimColor>None yet.</Text>}
132 {current.files
133 .slice(-8)
134 .reverse()
135 .map(file => (
136 <Text wrap="truncate-start">{file}</Text>
137 ))}
138 </Box>
139
140 <Box marginTop={1} columnGap={1}>
141 <Button key="reset" label="Reset" hotkey="r" onPress={() => update($, stats, () => EMPTY)} />
142 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
143 </Box>
144 </Box>
145 )
146 })
147}
148hooks/stats.ts 96 lines1import type { TurnUsage } from 'claude-code'
2
3import type { DashStats } from '../types'
4
5export const EMPTY: DashStats = {
6 toolCounts: {},
7 blocked: 0,
8 errors: 0,
9 files: [],
10 tokensIn: 0,
11 tokensOut: 0,
12 turnDurations: [],
13}
14
15const MAX_FILES = 50
16const MAX_TURNS = 30
17
18export function countTool(
19 stats: DashStats,
20 tool: string,
21 outcome: { isDenied: boolean; isError: boolean; path?: string },
22): DashStats {
23 const files =
24 outcome.path === undefined || outcome.isDenied
25 ? stats.files
26 : [...stats.files.filter(file => file !== outcome.path), outcome.path].slice(-MAX_FILES)
27
28 return {
29 ...stats,
30 toolCounts: { ...stats.toolCounts, [tool]: (stats.toolCounts[tool] ?? 0) + 1 },
31 blocked: stats.blocked + (outcome.isDenied ? 1 : 0),
32 errors: stats.errors + (outcome.isError ? 1 : 0),
33 files,
34 }
35}
36
37export function countTurn(
38 stats: DashStats,
39 turn: { durationMs: number; usage?: TurnUsage; isMainLoop: boolean },
40): DashStats {
41 const usage = turn.usage
42 return {
43 ...stats,
44 tokensIn:
45 stats.tokensIn +
46 (usage === undefined
47 ? 0
48 : usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens),
49 tokensOut: stats.tokensOut + (usage?.output_tokens ?? 0),
50 turnDurations: turn.isMainLoop
51 ? [...stats.turnDurations, turn.durationMs].slice(-MAX_TURNS)
52 : stats.turnDurations,
53 }
54}
55
56export function formatDuration(ms: number): string {
57 const seconds = Math.round(ms / 1000)
58 if (seconds < 60) return `${seconds}s`
59 const minutes = Math.floor(seconds / 60)
60 if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`
61 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`
62}
63
64export function formatTokens(count: number): string {
65 if (count < 1000) return String(count)
66 if (count < 1_000_000) return `${(count / 1000).toFixed(1)}k`
67 return `${(count / 1_000_000).toFixed(2)}M`
68}
69
70const SPARKS = '▁▂▃▄▅▆▇█'
71
72/** One spark glyph per value, scaled to the largest. */
73export function sparkline(values: readonly number[]): string {
74 const max = Math.max(1, ...values)
75 return values
76 .map(value => SPARKS[Math.min(SPARKS.length - 1, Math.floor((value / max) * (SPARKS.length - 1)))] ?? ' ')
77 .join('')
78}
79
80const DEFAULT_COLOR = 0x01000000
81
82/** Packs a one-row Raster: each glyph in `color` over the terminal's own background. */
83export function rasterCells(glyphs: string, color: number): string {
84 const chars = [...glyphs]
85 const words = new Uint32Array(chars.length * 3)
86 chars.forEach((char, index) => {
87 words[index * 3] = char.codePointAt(0) ?? 0x20
88 words[index * 3 + 1] = color
89 words[index * 3 + 2] = DEFAULT_COLOR
90 })
91 const bytes = new Uint8Array(words.buffer)
92 let binary = ''
93 for (const byte of bytes) binary += String.fromCharCode(byte)
94 return btoa(binary)
95}
96types/index.d.ts 16 lines1export type DashStats = {
2 toolCounts: Record<string, number>
3 blocked: number
4 errors: number
5 files: string[]
6 tokensIn: number
7 tokensOut: number
8 turnDurations: number[]
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'session-dash': { stats: DashStats }
14 }
15}
16