SLOPSHOPPER

sessions

/sessions: pane listing this project's past sessions from prompt history

newpanecommand
v0.1.0MITupdated 2026-10-06cmbaldwin/claude-usage-band/sessions
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sessions
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ No past sessions. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sessions │ ⎿ sessions: No past sessions found for /work/app. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
No past sessions.
README

claude-usage-band

Two Claude Code mods:

  • usage-band: a compact, responsive band above the prompt with your context fill, 5h and 7d rate limits, prompt-cache state, cost, and a few extras.
  • sessions: /sessions opens a pane listing this project's past sessions (short id, age, prompt count, first prompt), read from your prompt history.

Looks best with a Nerd Font. The icons and rounded bar ends need one, and they are off by default: without a Nerd Font the band still works, with words for labels and half-block ends. A mod cannot install or select a font, so you do that yourself:

brew install --cask font-jetbrains-mono-nerd-font   # macOS; any Nerd Font works

then set your terminal's font to it (for Terminal.app: Settings → Profiles → Font) and turn on the icons option below.

Built and tested on Claude Code 2.1.289, macOS, in a terminal. Mods are a new API, so expect it to move.

What the band shows

SegmentMeaning
ctxContext window fill, a green→red ramp (one shade per 10%), a notch at 80%, and used/window when there is room
5h, 7dRate-limit use as one solid bar each. The track behind is shaded by elapsed time: days (or hours) already gone are lighter, the current one lightest. ⟳ is time to reset
→100% 1h54mAt your current pace you reach 100% of that window in 1h54m, before it resets. Shown only after 15% of the window has passed
sparklineBurn rate: the 5h window in 12 slices, each as tall as the share of the limit used in it. Kept between sessions in the plugin store
$Session cost
cachePrompt-cache lifetime left (green, yellow under 20%) and hit ratio, or cold with how many tokens the next message re-caches
pt, branch, PR, jevTiny group: ponytail mode, git branch (* = uncommitted) and PR number, jev-model-router status, last skill
↓ ◈Output tokens and cached tokens since the mod loaded

The band measures itself. It tries the richest layout first and, when the terminal is narrow, folds branch/PR/jev/skill into the tiny group, drops the token counts, and finally simplifies the bars. Core segments (ctx, 5h, 7d, cost, cache) stay as long as they can.

Install

git clone https://github.com/cmbaldwin/claude-usage-band ~/claude-usage-band

In ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1",
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-usage-band/usage-band:/Users/you/claude-usage-band/sessions"
  }
}

Use absolute paths; CLAUDE_CODE_PLUGIN_DIRS is colon-separated. (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS is in my own settings; I have not checked whether newer builds still need it.) Start a new session.

Cache segment (optional)

Claude Code only sends prompt_cache to a status line command, not to mods, so the band reads it from a small file written by a status line script. Needs jq.

{
  "statusLine": {
    "type": "command",
    "command": "bash /Users/you/claude-usage-band/scripts/cache-bridge.sh",
    "refreshInterval": 30
  }
}

The script prints nothing; it writes ~/.claude/state/cache/<session-id>.json (files older than two days are deleted). If you already have a status line, read stdin once and feed both:

input=$(cat)
printf '%s' "$input" | bash /Users/you/claude-usage-band/scripts/cache-bridge.sh
printf '%s' "$input" | your-existing-command

The cache segment needs Claude Code 2.1.251 or later. Without the script it is simply absent.

Options

Set under pluginConfigs in settings.json, keyed by plugin name:

{
  "pluginConfigs": {
    "usage-band": { "options": { "icons": true, "hideStatus": true } }
  }
}
  • icons (default false): Nerd Font glyphs and rounded bar ends. Set your terminal font to a Nerd Font first, or you get boxes. Without it the labels are words and the ends are half blocks.
  • hideStatus (default false): clear every status line other plugins set ($.ui.status), so the bottom row shows only the mode labels. The band still shows the jev status either way.

Notes

  • Colors are hex RGB. A true-color terminal draws them exactly; others approximate to 256 colors.
  • The sparkline uses the terminal Raster element. I have only used the band in a terminal.
  • Git and PR info need git and gh; PR lookups run only when the branch changes or every five minutes.
  • Segments show only what they can read: no ponytail file, no jev status, or no repo just means that piece is absent.

Develop

Each mod has claude plugin validate <folder> and claude plugin test <folder>. Layout, color and history logic are covered by tests; the visuals are not.

MIT licensed.

Source 3 files
hooks/register.tsx 50 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { ago, group } from './format'
5
6const PANE = 'sessions'
7
8// Claude Code's config folder: CLAUDE_CONFIG_DIR, else ~/.claude
9async function claudeDir($: EngineInterface) {
10  return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
11}
12const rows = atom({ plugin: 'sessions', key: 'rows' } as const, [])
13
14export const register: Register = on => {
15  on('session.start', async ($, e, next) => {
16    await $.command.register({ name: 'sessions', description: "This project's past sessions, from prompt history" })
17    return next(e)
18  })
19
20  on('command.run', { command: 'sessions' }, async $ => {
21    const cwd = await $.session.cwd()
22    const history = await $.fs.read(`${await claudeDir($)}/history.jsonl`).catch(() => '')
23    const list = group(history, cwd)
24    await update($, rows, () => list)
25    await $.ui.open({ id: PANE, title: 'Sessions' })
26    return { text: list.length ? `${list.length} sessions in ${cwd}. Resume one with: claude --resume <id>` : `No past sessions found for ${cwd}.` }
27  })
28
29  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
30    const { Box, Text } = $.ui.resolve(e)
31    const list = await read($, rows)
32    const now = await $.clock.now()
33    const width = Math.max(20, e.props.bodyColumns - 24)
34
35    return (
36      <Box flexDirection="column">
37        {list.length === 0 && <Text dimColor>No past sessions.</Text>}
38        {list.map(r => (
39          <Box key={r.id} gap={1}>
40            <Text dimColor>{r.id.slice(0, 8)}</Text>
41            <Text color="cyan">{ago(r.last, now).padStart(7)}</Text>
42            <Text dimColor>{String(r.prompts).padStart(3)}p</Text>
43            <Text>{r.first.length > width ? `${r.first.slice(0, width - 1)}…` : r.first || '(no prompt)'}</Text>
44          </Box>
45        ))}
46      </Box>
47    )
48  })
49}
50
hooks/format.ts 30 lines
1import type { Row } from '../types'
2
3type Entry = { display: string; timestamp: number; project: string; sessionId: string }
4
5// Group one project's history entries into sessions, newest first.
6export const group = (jsonl: string, project: string, limit = 20): Row[] => {
7  const by = new Map<string, Row>()
8  for (const line of jsonl.split('\n')) {
9    let e: Entry
10    try { e = JSON.parse(line) } catch { continue }
11    if (e.project !== project || !e.sessionId) continue
12    const text = e.display.trim()
13    // bare "exit" and "/usage" make poor titles
14    const isNoise = text === 'exit' || text.startsWith('/usage')
15    const r = by.get(e.sessionId) ?? { id: e.sessionId, first: '', last: 0, prompts: 0 }
16    if (!r.first && !isNoise) r.first = text.replace(/\s+/g, ' ')
17    r.last = Math.max(r.last, e.timestamp)
18    r.prompts += 1
19    by.set(e.sessionId, r)
20  }
21  return [...by.values()].sort((a, b) => b.last - a.last).slice(0, limit)
22}
23
24export const ago = (ms: number, now: number) => {
25  const m = Math.max(0, Math.round((now - ms) / 60000))
26  const h = Math.floor(m / 60)
27  const d = Math.floor(h / 24)
28  return d ? `${d}d ago` : h ? `${h}h ago` : `${m}m ago`
29}
30
types/index.d.ts 8 lines
1export type Row = { id: string; first: string; last: number; prompts: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    sessions: { rows: Row[] }
6  }
7}
8