SLOPSHOPPER

session-dashboard

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

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dashboard
│ ┃ Session dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Session dashboard ● idle │ ┃ ⏺ Read(src/auth.ts) │ ┃ 1: Overview 2: Activity 3: Notes ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ ┃ │ Tool calls │ ⏺ Bash(bun test) │ ┃ │ 9 │ ⎿ 3 pass, 1 fail │ ┃ ╰────────────────────────────────────────╯ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Turn durations │ ┃ ▁ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Most used › /dashboard │ ┃ Bash ██████████████████████████████ │ ┃ Write ████████████████░░░░░░░░░░░░░░ │ ┃ Read ████████░░░░░░░░░░░░░░░░░░░░░░ │ ┃ Grep ████████░░░░░░░░░░░░░░░░░░░░░░ │ ┃ Edit ████████░░░░░░░░░░░░░░░░░░░░░░ │ ┃ │ ┃ 1·2·3 tabs · Tab moves · Esc back to │ ┃ prompt · /dashboard │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session dashboard
◆ Session dashboard ● idle 1: Overview 2: Activity 3: Notes ╭────────────────────────────────────────────────────────╮ │ Tool calls │ │ 9 │ ╰────────────────────────────────────────────────────────╯ Turn durations ▁ Most used Bash ████████████████████████████████ 4 Write ████████████████░░░░░░░░░░░░░░░░ 2 Read ████████░░░░░░░░░░░░░░░░░░░░░░░░ 1 Grep ████████░░░░░░░░░░░░░░░░░░░░░░░░ 1 Edit ████████░░░░░░░░░░░░░░░░░░░░░░░░ 1 1·2·3 tabs · Tab moves · Esc back to prompt · /dashboard
README

session-dashboard

Leia em português

A dashboard panel that shows what Claude has been doing in this session, plus notes that stay with you.

What is a mod?

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.

What it does

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.

What you see

  • Top line: the panel title and whether Claude is working right now ("◌ 2 running") or waiting ("● idle").
  • Overview tab: four cards (Tool calls, Errors, Turns, Avg turn), a small chart of how long each turn took, and the tools Claude used most.
  • Activity tab: the list of recent tool calls, newest first, with a filter to show only one tool (for example only Bash).
  • Notes tab: a field to write a note and the list of saved notes.

How to use it

  1. Type /dashboard and press Enter. The panel opens.
  2. Press 1, 2 or 3 to switch tabs.
  3. In the Notes tab, type a note and press Enter to save it. Use × next to a note to delete it.
  4. Press Esc to go back to typing your prompt. The panel keeps updating while Claude works.

Keyboard shortcuts

KeyWhat it does
1 2 3Overview, Activity, Notes tab
TabMove between buttons and fields
cClear the activity list (Activity tab)
EnterSave the note you typed (Notes tab)
EscLeave the panel and go back to the prompt

Commands

TypeWhat it does
/dashboardOpen the panel

Settings

Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.

SettingWhat it meansDefault
language (Language)Language of the text the mod shows: en (English) or pt-BR (Brazilian Portuguese).en

Good to know

  • Counts start at zero in each new session. Notes are the only thing kept between sessions.
  • Running /clear starts the counts again, the same as a new session.

Install

You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.

  1. Copy this folder anywhere on your computer.
  2. Inside the copy, rename the folder _claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.
  3. Start Claude Code with the mod:
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").

Check it

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.

Source 3 files
hooks/register.tsx 269 lines
1import { 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}
269
hooks/i18n.ts 68 lines
1// 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}
68
types/index.d.ts 19 lines
1export 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