SLOPSHOPPER

session-dash

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

newpaneguardcommand
v0.1.0MITupdated 2026-10-03lucenity0/claude-code-mods/session-dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dash
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ 1 turns · 42s · 97.4k in · 1.5k out │ ┃ 0 blocked · 1 errored ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Tools (9 calls) ⏺ Update(src/auth.ts) │ ┃ Bash ⎿ Added 2 lines, removed 1 line │ ┃ ████████████████████████████████████████████ ⏺ Bash(bun test) │ ┃ 4 ⎿ 3 pass, 1 fail │ ┃ Write ██████████████████████ 2 │ ┃ Read ███████████ 1 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Grep ███████████ 1 │ ┃ Edit ███████████ 1 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Turn time (last 1) · max 42s › /dash │ ┃ █ ⎿ session-dash: Session dashboard opened. /dash again to close it. │ ┃ │ ┃ Recent files (3) │ ┃ /work/app/src/cache.ts │ ┃ /work/app/src/audit.ts │ ┃ /work/app/src/auth.ts │ ┃ │ ┃ [ Reset ] [ Close ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
1 turns · 42s · 97.4k in · 1.5k out 0 blocked · 1 errored Tools (9 calls) Bash ████████████████████████████████████████████ 4 Write ██████████████████████ 2 Read ███████████ 1 Grep ███████████ 1 Edit ███████████ 1 Turn time (last 1) · max 42s █ Recent files (3) /work/app/src/cache.ts /work/app/src/audit.ts /work/app/src/auth.ts [ Reset ] [ Close ]
README

claude-code-mods

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.

&nbsp;

install

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.

&nbsp;

turn-meter

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)
})

&nbsp;

seatbelt

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.

&nbsp;

ding

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 } } } }

&nbsp;

session-dash

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
})

&nbsp;

context-meter

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)
})

&nbsp;

make one

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

&nbsp;


<sub>MIT · built with Claude Code</sub>

Source 3 files
hooks/register.tsx 148 lines
1import { 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}
148
hooks/stats.ts 96 lines
1import 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}
96
types/index.d.ts 16 lines
1export 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