Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

A dashboard panel that shows what Claude has been doing in this session, plus notes that stay with you.
Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.
While Claude works it calls tools: it reads files, edits them, runs commands. Normally that scrolls past and is gone. This mod opens a panel with a summary you can look at any time: how many tools Claude used, how many failed, how many turns (one question and its answer is one turn) and how long they took. It also has a small notepad whose notes are still there the next time you open Claude Code.
Bash)./dashboard and press Enter. The panel opens.1, 2 or 3 to switch tabs.× next to a note to delete it.Esc to go back to typing your prompt. The panel keeps updating while Claude works.| Key | What it does |
|---|---|
1 2 3 | Overview, Activity, Notes tab |
Tab | Move between buttons and fields |
c | Clear the activity list (Activity tab) |
Enter | Save the note you typed (Notes tab) |
Esc | Leave the panel and go back to the prompt |
| Type | What it does |
|---|---|
/dashboard | Open the panel |
Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.
| Setting | What it means | Default |
|---|---|---|
language (Language) | Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese). | en |
/clear starts the counts again, the same as a new session.You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.
_claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.claude --plugin-dir path/to/session-dashboard
To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").
From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 7 automated tests of this mod:
node scripts/check-mod.mjs examples/session-dashboard
The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.
For developers: the examples table lists the hooks and API calls this mod demonstrates.
hooks/register.tsx 269 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Note, Tab } from '../types'
5import { strings } from './i18n'
6import type { Strings } from './i18n'
7
8// ── Identity ────────────────────────────────────────────────────────────────
9const PANE = 'session-dashboard'
10// The title and every other visible text come from i18n.ts (options.language).
11// The command name is written as a literal in register() and in the matcher:
12// the validator reads it from source.
13
14// ── State: lives in the host, survives hot reload. plugin/key MUST be literals ──
15const tab = atom({ plugin: 'session-dashboard', key: 'tab' } as const, 'overview')
16const activity = atom({ plugin: 'session-dashboard', key: 'activity' } as const, [])
17const notes = atom({ plugin: 'session-dashboard', key: 'notes' } as const, [])
18const filter = atom({ plugin: 'session-dashboard', key: 'filter' } as const, 'all')
19const turns = atom({ plugin: 'session-dashboard', key: 'turns' } as const, [])
20const totals = atom({ plugin: 'session-dashboard', key: 'totals' } as const, { calls: 0, errors: 0, byTool: {} })
21
22// ── Persistence across sessions ($.store) ──────────────────────────────────
23const STORE_NOTES = 'notes'
24
25// ── Drawing helpers (pure, no $) ───────────────────────────────────────────
26const tabs = (t: Strings): { id: Tab; label: string; hotkey: string }[] => [
27 { id: 'overview', label: t.tabOverview, hotkey: '1' },
28 { id: 'activity', label: t.tabActivity, hotkey: '2' },
29 { id: 'notes', label: t.tabNotes, hotkey: '3' },
30]
31const BARS = '▁▂▃▄▅▆▇█'
32
33function sparkline(values: number[], width: number): string {
34 const slice = values.slice(-width)
35 if (slice.length === 0) return ''
36 const max = Math.max(...slice, 1)
37 return slice.map(v => BARS[Math.min(7, Math.floor((v / max) * 7))] ?? BARS[0]).join('')
38}
39
40function meter(ratio: number, width: number): string {
41 const full = Math.round(Math.max(0, Math.min(1, ratio)) * width)
42 return '█'.repeat(full) + '░'.repeat(Math.max(0, width - full))
43}
44
45function clock(ms: number): string {
46 const d = new Date(ms)
47 const pad = (n: number) => String(n).padStart(2, '0')
48 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
49}
50
51function topTools(byTool: Record<string, number>, n: number): [string, number][] {
52 return Object.entries(byTool).sort((a, b) => b[1] - a[1]).slice(0, n)
53}
54
55// Helpers that take $ are top-level functions, so the validator can trace their calls.
56async function loadNotes($: EngineInterface) {
57 const saved = await $.store.get(STORE_NOTES)
58 await update($, notes, () => (Array.isArray(saved) ? (saved as Note[]) : []))
59}
60
61async function saveNotes($: EngineInterface, list: Note[]) {
62 await update($, notes, () => list)
63 await $.store.set(STORE_NOTES, list)
64}
65
66export const register: Register = (on, options) => {
67 const t = strings(options.language)
68 const TITLE = t.title
69 // A turn's start time can be lost on reload without harm, so a module variable is fine.
70 let turnStartedAt = 0
71
72 // ── Lifecycle ────────────────────────────────────────────────────────────
73 on('session.start', async ($, e, next) => {
74 await loadNotes($)
75 // Opened unasked: the pane seats from 144 columns and waits below that.
76 void $.ui.open({ id: PANE, title: TITLE })
77 // Register commands last: a taken name throws and would skip what follows.
78 await $.command.register({ name: 'dashboard', description: t.commandDescription(TITLE), immediate: true })
79 return next(e)
80 })
81
82 // /clear, /resume and /branch reset $.state and do not fire session.start again.
83 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
84 await loadNotes($)
85 return next(e)
86 })
87
88 on('command.run', { command: 'dashboard' }, async $ => {
89 await $.ui.open({ id: PANE, title: TITLE })
90 return {}
91 })
92
93 // ── Data collection (observers: no .catch, a failure lets the event through) ─
94 on('prompt.submit', async ($, e, next) => {
95 turnStartedAt = await $.clock.now()
96 return next(e)
97 })
98
99 on('turn.complete', async ($, e, next) => {
100 if (e.agentId === undefined && turnStartedAt > 0) {
101 const seconds = Math.round(((await $.clock.now()) - turnStartedAt) / 1000)
102 await update($, turns, list => [...list, seconds].slice(-60))
103 }
104 return next(e)
105 })
106
107 on('tool.call', async ($, e, next) => {
108 const tool = String(e.tool)
109 const item: Activity = { id: e.tool_use_id, tool, at: await $.clock.now(), isDone: false, isError: false }
110 await update($, activity, list => [...list, item].slice(-300))
111 await update($, totals, t => ({ ...t, calls: t.calls + 1, byTool: { ...t.byTool, [tool]: (t.byTool[tool] ?? 0) + 1 } }))
112 // finally: an interrupted or failing call is still marked done, never left "running" forever.
113 let isError = true
114 try {
115 const ran = await next(e)
116 isError = ran.deny !== undefined || ran.isError === true
117 return ran
118 } finally {
119 await update($, activity, list => list.map(a => (a.id === item.id ? { ...a, isDone: true, isError } : a)))
120 if (isError) await update($, totals, t => ({ ...t, errors: t.errors + 1 }))
121 }
122 })
123
124 // ── The pane ─────────────────────────────────────────────────────────────
125 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
126 const ui = $.ui.resolve(e)
127 const { Box, Text, Button } = ui
128 const width = Math.max(24, e.props.bodyColumns)
129 const rows = Math.max(6, e.props.scroll.bodyRows - 8)
130
131 const current = await read($, tab)
132 const list = await read($, activity)
133 const sums = await read($, totals)
134 const turnList = await read($, turns)
135 const noteList = await read($, notes)
136 const currentFilter = await read($, filter)
137
138 const running = list.filter(a => !a.isDone).length
139 const avgTurn = turnList.length === 0 ? 0 : Math.round(turnList.reduce((s, n) => s + n, 0) / turnList.length)
140
141 const header = (
142 <Box flexDirection="column" marginBottom={1}>
143 <Box justifyContent="space-between">
144 <Text bold color="claude">◆ {TITLE}</Text>
145 <Text color={running > 0 ? 'warning' : 'success'}>{running > 0 ? t.running(running) : t.idle}</Text>
146 </Box>
147 <Box gap={2} marginTop={1}>
148 {tabs(t).map(item => (
149 <Button
150 key={`tab-${item.id}`}
151 label={item.label}
152 hotkey={item.hotkey}
153 plain
154 dimColor={item.id !== current}
155 onPress={() => update($, tab, () => item.id)}
156 />
157 ))}
158 </Box>
159 </Box>
160 )
161
162 const kpi = (key: string, label: string, value: string, color: string) => (
163 <Box key={key} flexDirection="column" borderStyle="round" borderColor="subtle" paddingX={1} flexGrow={1}>
164 <Text dimColor>{label}</Text>
165 <Text bold color={color}>{value}</Text>
166 </Box>
167 )
168
169 let body
170 if (current === 'overview') {
171 const top = topTools(sums.byTool, 5)
172 const topMax = top[0]?.[1] ?? 1
173 body = (
174 <Box flexDirection="column" gap={1}>
175 <Box gap={1} flexWrap="wrap">
176 {kpi('kpi-tools', t.kpiTools, String(sums.calls), 'text')}
177 {kpi('kpi-errors', t.kpiErrors, String(sums.errors), sums.errors > 0 ? 'error' : 'success')}
178 {kpi('kpi-turns', t.kpiTurns, String(turnList.length), 'text')}
179 {kpi('kpi-avg', t.kpiAvg, `${avgTurn}s`, avgTurn > 120 ? 'warning' : 'text')}
180 </Box>
181 <Box flexDirection="column">
182 <Text bold>{t.turnDurations}</Text>
183 <Text color="claude">{sparkline(turnList, width - 2) || t.noFinishedTurn}</Text>
184 </Box>
185 <Box flexDirection="column">
186 <Text bold>{t.mostUsed}</Text>
187 {top.length === 0 && <Text dimColor>{t.noToolCalls}</Text>}
188 {top.map(([name, count]) => (
189 <Box key={`top-${name}`} gap={1}>
190 <Box width={12}><Text wrap="truncate-end">{name}</Text></Box>
191 <Box flexGrow={1}><Text color="suggestion" wrap="truncate-end">{meter(count / topMax, Math.max(6, width - 24))}</Text></Box>
192 <Text dimColor>{String(count)}</Text>
193 </Box>
194 ))}
195 </Box>
196 </Box>
197 )
198 } else if (current === 'activity') {
199 const tools = ['all', ...topTools(sums.byTool, 8).map(([name]) => name)]
200 const shown = list.filter(a => currentFilter === 'all' || a.tool === currentFilter).slice(-rows)
201 body = (
202 <Box flexDirection="column" gap={1}>
203 {e.surface !== 'mobile' && 'Select' in ui && (
204 <ui.Select
205 key="filter"
206 label={t.filter}
207 value={tools.includes(currentFilter) ? currentFilter : 'all'}
208 options={tools.map(tool => ({ value: tool, label: tool === 'all' ? t.all : tool }))}
209 onSelect={value => update($, filter, () => value)}
210 />
211 )}
212 <Box flexDirection="column">
213 {shown.length === 0 && <Text dimColor>{t.nothingHere}</Text>}
214 {shown.map(a => (
215 <Box key={`act-${a.id}`} gap={1}>
216 <Text dimColor>{clock(a.at)}</Text>
217 <Text color={!a.isDone ? 'warning' : a.isError ? 'error' : 'success'}>{!a.isDone ? '◌' : a.isError ? '✗' : '✓'}</Text>
218 <Text wrap="truncate-end">{a.tool}</Text>
219 </Box>
220 ))}
221 </Box>
222 <Box>
223 <Button key="clear-activity" label={t.clearList} hotkey="c" onPress={() => update($, activity, () => [])} />
224 </Box>
225 </Box>
226 )
227 } else {
228 body = (
229 <Box flexDirection="column" gap={1}>
230 {e.surface !== 'mobile' && 'Input' in ui && (
231 <ui.Input
232 key="new-note"
233 label={t.newNote}
234 placeholder={t.notePlaceholder}
235 value=""
236 submitLabel={t.save}
237 onSubmit={async text => {
238 const clean = text.trim()
239 if (clean === '') return
240 const now = await $.clock.now()
241 await saveNotes($, [...noteList, { id: `n${now}`, text: clean.slice(0, 500), at: now }])
242 $.ui.toast(t.noteSaved)
243 }}
244 />
245 )}
246 {noteList.length === 0 && <Text dimColor>{t.noNotes}</Text>}
247 {noteList.slice(-rows).map(n => (
248 <Box key={`note-${n.id}`} gap={1}>
249 <Button key={`del-${n.id}`} label="×" plain onPress={() => saveNotes($, noteList.filter(x => x.id !== n.id))} />
250 <Text dimColor>{clock(n.at)}</Text>
251 <Box flexGrow={1}><Text wrap="wrap">{n.text}</Text></Box>
252 </Box>
253 ))}
254 </Box>
255 )
256 }
257
258 return (
259 <Box flexDirection="column" paddingX={1}>
260 {header}
261 {body}
262 <Box marginTop={1}>
263 <Text dimColor>{t.footer}</Text>
264 </Box>
265 </Box>
266 )
267 })
268}
269hooks/i18n.ts 68 lines1// The text this mod shows, per language. Pure: no $ calls, so tests can cover it directly.
2// Anything other than a known language falls back to English.
3
4export type Language = 'en' | 'pt-BR'
5
6const en = {
7 title: 'Session dashboard',
8 commandDescription: (title: string) => `Open the ${title}`,
9 tabOverview: 'Overview',
10 tabActivity: 'Activity',
11 tabNotes: 'Notes',
12 running: (n: number) => `◌ ${n} running`,
13 idle: '● idle',
14 kpiTools: 'Tool calls',
15 kpiErrors: 'Errors',
16 kpiTurns: 'Turns',
17 kpiAvg: 'Avg turn',
18 turnDurations: 'Turn durations',
19 noFinishedTurn: 'No finished turn yet.',
20 mostUsed: 'Most used',
21 noToolCalls: 'No tool calls yet.',
22 filter: 'Filter',
23 all: 'all',
24 nothingHere: 'Nothing here yet.',
25 clearList: 'c: Clear list',
26 newNote: 'New note',
27 notePlaceholder: 'Type and press Enter',
28 save: 'save',
29 noteSaved: 'Note saved',
30 noNotes: 'No notes. They are kept across sessions.',
31 footer: '1·2·3 tabs · Tab moves · Esc back to prompt · /dashboard',
32}
33
34const ptBR: typeof en = {
35 title: 'Painel da sessão',
36 commandDescription: (title: string) => `Abre o ${title}`,
37 tabOverview: 'Visão geral',
38 tabActivity: 'Atividade',
39 tabNotes: 'Notas',
40 running: (n: number) => `◌ ${n} rodando`,
41 idle: '● ocioso',
42 kpiTools: 'Ferramentas',
43 kpiErrors: 'Erros',
44 kpiTurns: 'Turnos',
45 kpiAvg: 'Turno médio',
46 turnDurations: 'Duração dos turnos',
47 noFinishedTurn: 'Nenhum turno concluído ainda.',
48 mostUsed: 'Mais usadas',
49 noToolCalls: 'Nenhuma ferramenta usada ainda.',
50 filter: 'Filtrar',
51 all: 'todas',
52 nothingHere: 'Nada por aqui ainda.',
53 clearList: 'c: Limpar lista',
54 newNote: 'Nova nota',
55 notePlaceholder: 'Digite e tecle Enter',
56 save: 'salvar',
57 noteSaved: 'Nota salva',
58 noNotes: 'Sem notas. Elas ficam salvas entre sessões.',
59 footer: '1·2·3 abas · Tab navega · Esc volta ao prompt · /dashboard',
60}
61
62export type Strings = typeof en
63const TABLE: Record<Language, Strings> = { en, 'pt-BR': ptBR }
64
65export function strings(language: unknown): Strings {
66 return language === 'pt-BR' ? TABLE['pt-BR'] : TABLE.en
67}
68types/index.d.ts 19 lines1export type Tab = 'overview' | 'activity' | 'notes'
2export type Activity = { id: string; tool: string; at: number; isDone: boolean; isError: boolean }
3export type Note = { id: string; text: string; at: number }
4// Session totals: the activity list keeps only the last 300 calls, so counts live here.
5export type Totals = { calls: number; errors: number; byTool: Record<string, number> }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'session-dashboard': {
10 tab: Tab
11 activity: Activity[]
12 notes: Note[]
13 filter: string
14 turns: number[]
15 totals: Totals
16 }
17 }
18}
19