SLOPSHOPPER

sidebar

A live widget sidebar for Claude Code: model badge, context-window gauge, session cost, 5-hour / weekly usage dial, plus New chat, Commit & Push and Handoff…

newpanebandstatus
★ 1v0.1.0no licenseupdated 2026-10-11erte-del/claude-mods/mods/sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidebar
│ ┃ Sidebar ✕ › fix the failing auth test and add an audit log call │ ┃ ● Opus 5.5 │ ┃ ⏺ Read(src/auth.ts) │ ┃ Context █████░░░░░ 49% ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Cost █░░░░░░░░░ $0.420 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⣀⣤⣤⣤⣤⣤⣀ ⎿ 3 pass, 1 fail │ ┃ ⢀⣴⡿⠋⠉ ⠉⠙⢿⣦⡀ │ ┃ ⢀⣾⠋ ⣠⣶⣶⣶⣄ ⠙⣷⡀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ⢸⡏ ⣾⡏ ⢹⣷ ⢹⡇ │ ┃ 7d -- 5h 31% ✻ Worked for 42s · done 4:20 PM │ ┃ ↻-- ↻now │ ┃ │ ┃ │ ┃ [ ⇡ Commit & Push ] │ ┃ [ ⇄ Handoff ] │ ┃ [ + New chat ] │ ┃ [ ↻ Reload plugins ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ sidebar: ctx 49% · $0.42

Draws

Pane · Sidebar
● Opus 5.5 Context █████░░░░░ 49% Cost █░░░░░░░░░ $0.420 ⣀⣤⣤⣤⣤⣤⣀ ⢀⣴⡿⠋⠉ ⠉⠙⢿⣦⡀ ⢀⣾⠋ ⣠⣶⣶⣶⣄ ⠙⣷⡀ ⢸⡏ ⣾⡏ ⢹⣷ ⢹⡇ 7d -- 5h 31% ↻-- ↻now [ ⇡ Commit & Push ] [ ⇄ Handoff ] [ + New chat ] [ ↻ Reload plugins ]
README

claude-mods

<img width="391" height="379" alt="image" src="https://github.com/user-attachments/assets/6a3cb2d9-57cb-47e2-84c0-6cfc547499c3" />

Small mods for Claude Code: a live sidebar with one-click buttons for the things you do every session, and a repo picker. Works on Windows, macOS and Linux.

ModWhat you get
sidebarModel badge, context-window gauge, session cost, a 5-hour / weekly usage dial, and buttons for New chat, Commit & Push and Handoff
commit-pushOne click to stage, commit (with a Claude-written message) and push
handoffOne click to save a handoff.md, clear the chat, and continue from it
repoPick a GitHub repo, clone it if needed, and move the session into it

Install

In Claude Code:

/plugin marketplace add erte-del/claude-mods
/plugin install sidebar@claude-mods
/plugin install repo@claude-mods
/reload-plugins

Or from a terminal:

claude plugin marketplace add erte-del/claude-mods
claude plugin install sidebar@claude-mods --scope user
claude plugin install repo@claude-mods --scope user

Installing sidebar also installs commit-push and handoff, because its buttons call them. repo is separate, since it opens a picker when a session starts; skip it if you don't want that.

Start a new session or run /reload-plugins and the sidebar appears. It docks beside the transcript on terminals 110 columns or wider, and otherwise shows as a bar above the prompt.

Requirements: Claude Code and git. repo also uses the GitHub CLI (gh, signed in with gh auth login) to list your repos.

What each mod does

sidebar

Live status, redrawn after every turn:

  • the current model
  • how full the context window is
  • what the session has cost so far
  • how much of your 5-hour and weekly limits you've used, as a dial, with time until each resets

Buttons, top to bottom: + New chat runs /clear. ⇡ Commit & Push and ⇄ Handoff run the mods below. ↻ Reload plugins runs /reload-plugins.

commit-push

Click ⇡ Commit & Push, or type /commit-push.

  1. Checks the current repo. If nothing changed, it says so and stops.
  2. Stages everything (git add -A).
  3. Sends the staged diff to Claude (Haiku) for a one-line conventional-commit message such as fix: handle empty repo list in picker. If that fails it falls back to naming the changed files.
  4. Commits, then pushes. If the branch has no upstream yet, it runs git push -u origin <branch>.
  5. Shows the commit message and the result in the sidebar. A failed push shows git's own error text plus a hint (network, sign-in, rejected, protected branch).

It never force-pushes. It stops and leaves it to you when the branch is behind its upstream, there are unresolved merge conflicts, a merge or rebase is in progress, or HEAD is detached. Your files are only staged when it gets as far as committing.

It works on whatever folder the session is in, so it follows repo.

handoff

Click ⇄ Handoff, or type /handoff. Use it when your context is getting full.

  1. Asks Claude to write handoff.md in the project root: what you're doing, current state, the exact next step, decisions made, gotchas and open questions. An existing handoff.md is overwritten.
  2. Waits for the turn to finish and the file to appear.
  3. Runs /clear.
  4. Tells the fresh chat to read handoff.md and carry on from its next step.

If you interrupt, the turn errors, or Claude finishes without writing the file, it stops without clearing anything. The prompt is in mods/handoff/hooks/register.tsx if you want to change what goes in the file.

repo

Opens a picker when a session starts with no repo chosen, or type /repo.

  • Type part of a name to fuzzy-search your repos (matches on name, then description), list to see them all, then a number to pick.
  • Or type a full name, owner/repo, or a git URL.
  • It looks for an existing checkout in the usual places (~/repos, ~/Documents, ~/Desktop, ~/code, ~/projects, ~/dev, ~/src, ~/GitHub and a few more) and clones the repo if it can't find one.
  • Then it moves the session into that folder and tells Claude which repo is active.

Settings

Change these in /config (each mod's settings are listed under its name).

ModSettingDefaultWhat it does
sidebarbudgetUsd5The session cost that fills the cost bar
sidebarasciioffDraw bars with # and - and skip the dial, for terminals that can't show block characters
repoowneryour gh loginWhose repos to list: another user or an organization
repocloneDir~/reposWhere missing repos are cloned
repoopenOnStartonOpen the picker when a session starts with no active repo

Updating and removing

claude plugin marketplace update claude-mods
claude plugin uninstall sidebar@claude-mods

Each mod lives in its own folder under mods/ with its tests next to the code (claude plugin test mods/<name>).

Source 2 files
hooks/register.tsx 283 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Gauge, Limit } from '../types'
5
6const PANE = 'sidebar'
7const gauge =atom({ plugin: 'sidebar', key: 'gauge' } as const, null)
8const limits = atom({ plugin: 'sidebar', key: 'limits' } as const, [] as Limit[])
9// Owned by the commit-push mod; the sidebar only draws it and presses its command.
10const commitPush = atom({ plugin: 'commit-push', key: 'run' } as const, {
11  phase: 'idle', step: '', repo: '', branch: '', message: '', sha: '', target: '', summary: '', detail: '',
12})
13// Owned by the handoff mod.
14const handoff = atom({ plugin: 'handoff', key: 'run' } as const, { phase: 'idle', summary: '', path: '' })
15
16// ---- speedometer: two concentric half circles drawn with braille dots (2x4 per cell) ----
17const DW = 34 // dial grid width in dots (17 cells); the arcs use the left 30 dots
18const CX = 15 // arc centre in dots, left of the grid's middle so the dial sits left
19const DH = 16 // dial height in dots (4 cells)
20const BIT = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]
21const RINGS = [
22  { lo: 12, hi: 14.5 }, // outer: weekly
23  { lo: 4.5, hi: 7 }, // inner: 5 hour
24]
25
26const eta = (iso?: string): string => {
27  if (!iso) return ''
28  const ms = Date.parse(iso) - Date.now()
29  if (!(ms > 0)) return 'now'
30  const m = Math.round(ms / 60000)
31  if (m < 60) return `${m}m`
32  const h = Math.floor(m / 60)
33  if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
34  return `${Math.floor(h / 24)}d${h % 24}h`
35}
36
37// pcts[i] is the fill (0-100) of RINGS[i], or null for "no data" (ring drawn empty).
38function dial(pcts: (number | null)[]): { ch: string; ring: number; lit: boolean }[][] {
39  const rows: { ch: string; ring: number; lit: boolean }[][] = []
40  for (let cy = 0; cy < DH / 4; cy++) {
41    const row: { ch: string; ring: number; lit: boolean }[] = []
42    for (let cx = 0; cx < DW / 2; cx++) {
43      let bits = 0
44      const count = RINGS.map(() => ({ on: 0, lit: 0 }))
45      for (let i = 0; i < 2; i++) {
46        for (let j = 0; j < 4; j++) {
47          const x = cx * 2 + i
48          const y = cy * 4 + j
49          const dx = x + 0.5 - CX
50          const dy = DH - (y + 0.5)
51          const r = Math.hypot(dx, dy)
52          const ri = RINGS.findIndex((g) => r >= g.lo && r <= g.hi)
53          if (ri < 0) continue
54          const frac = 1 - Math.atan2(dy, dx) / Math.PI // 0 at the left end, 1 at the right
55          const p = pcts[ri]
56          bits |= BIT[i][j]
57          count[ri].on++
58          if (p !== null && frac <= p / 100) count[ri].lit++
59        }
60      }
61      const ring = count[0].on >= count[1].on ? 0 : 1
62      row.push({
63        ch: bits === 0 ? ' ' : String.fromCharCode(0x2800 + bits),
64        ring,
65        lit: count[ring].lit * 2 >= count[ring].on && count[ring].lit > 0,
66      })
67    }
68    rows.push(row)
69  }
70  return rows
71}
72
73const colorFor = (percent: number): string =>
74  percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green'
75
76// claude-opus-5-5 -> Opus 5.5; unknown ids are shown trimmed.
77const badge = (id: string): string => {
78  const m = /(opus|sonnet|haiku|fable)-(\d+(?:-\d+)*)/i.exec(id)
79  if (m === null) return id.replace(/^claude-/, '').slice(0, 16)
80  return `${m[1][0].toUpperCase()}${m[1].slice(1).toLowerCase()} ${m[2].replace(/-/g, '.')}`
81}
82
83const k = (n: number): string => (n >= 1000 ? `${Math.round(n / 100) / 10}k` : `${n}`)
84
85// Real figures from the last API response: input + cache reads/writes (system prompt, tool results, history).
86async function refresh($: any, answered?: string): Promise<void> {
87  const model: string = answered ?? (await $.session.model())
88  const { context, cost, rateLimits } = await $.session.usage()
89  const next: Gauge | null =
90    context.tokens === undefined
91      ? null
92      : {
93          percent: Math.min(100, context.percent ?? Math.round((context.tokens / context.window) * 100)),
94          tokens: context.tokens,
95          window: context.window,
96          usd: cost?.usd ?? null,
97          model,
98        }
99  await update($, gauge, () => next)
100  await update($, limits, () => rateLimits ?? [])
101  $.ui.status(next === null ? undefined : `ctx ${next.percent}%${next.usd === null ? '' : ` · $${next.usd.toFixed(2)}`}`)
102}
103
104// Docked pane (top-right, beside the logo) stacks the widgets; the band above the prompt is the fallback.
105async function draw($: any, e: any, vertical: boolean, options: any) {
106  const isAscii = options?.ascii === true
107  const { Box, Text, Button } = $.ui.resolve(e)
108  const g = await read($, gauge)
109  const budget = typeof options?.budgetUsd === 'number' && options.budgetUsd > 0 ? options.budgetUsd : 5
110  const W = vertical ? 10 : 12
111
112  const bar = (fraction: number): [string, string] => {
113    const n = Math.round(Math.min(1, Math.max(0, fraction)) * W)
114    return [(isAscii ? '#' : '█').repeat(n), (isAscii ? '-' : '░').repeat(W - n)]
115  }
116
117  const pct = g?.percent ?? 0
118  const [ctxOn, ctxOff] = bar(pct / 100)
119  const ctxColor = colorFor(pct)
120
121  const usd = g?.usd ?? null
122  const costPct = usd === null ? 0 : Math.round((usd / budget) * 100)
123  const [costOn, costOff] = bar(costPct / 100)
124  const costColor = colorFor(costPct)
125
126  const lims: Limit[] = await read($, limits)
127  const week = lims.find((l) => l.kind === 'seven_day') ?? null
128  const five = lims.find((l) => l.kind === 'five_hour') ?? null
129  const WEEK_COLOR = 'magenta'
130  const FIVE_COLOR = 'cyan'
131  const fmt = (l: Limit | null): string => (l === null ? '--' : `${Math.round(l.percentUsed)}%`)
132
133  let speedo: any = null
134  if (vertical && !isAscii) {
135    const colors = [WEEK_COLOR, FIVE_COLOR]
136    const rows = dial([week?.percentUsed ?? null, five?.percentUsed ?? null])
137    speedo = (
138      <Box flexDirection="column" marginTop={1} alignItems="center">
139        {rows.map((row, ri) => (
140          <Box key={ri}>
141            {row.map((c, ci) =>
142              c.lit ? (
143                <Text key={ci} color={colors[c.ring]}>{c.ch}</Text>
144              ) : (
145                <Text key={ci} dimColor>{c.ch}</Text>
146              ),
147            )}
148          </Box>
149        ))}
150        <Box marginRight={4}>
151          <Text color={WEEK_COLOR} bold>{`7d ${fmt(week)}`}</Text>
152          <Text>{'   '}</Text>
153          <Text color={FIVE_COLOR} bold>{`5h ${fmt(five)}`}</Text>
154        </Box>
155        <Box marginBottom={1} marginRight={4}>
156          <Text dimColor>{`↻${week?.resetsAt ? eta(week.resetsAt) : '--'}`}</Text>
157          <Text>{'   '}</Text>
158          <Text dimColor>{`↻${five?.resetsAt ? eta(five.resetsAt) : '--'}`}</Text>
159        </Box>
160      </Box>
161    )
162  } else {
163    speedo = (
164      <Box marginTop={vertical ? 1 : 0}>
165        <Text color={FIVE_COLOR} bold>{`5h ${fmt(five)}`}</Text>
166        <Text dimColor>{' · '}</Text>
167        <Text color={WEEK_COLOR} bold>{`7d ${fmt(week)}`}</Text>
168      </Box>
169    )
170  }
171
172  const cp = await read($, commitPush)
173  const cpBusy = cp.phase === 'running'
174  const cpColor = cp.phase === 'done' ? 'green' : cp.phase === 'nothing' ? undefined : cp.phase === 'blocked' ? 'yellow' : 'red'
175  const cpMark = cp.phase === 'done' ? '✓ ' : cp.phase === 'nothing' ? '· ' : cp.phase === 'blocked' ? '! ' : '✗ '
176  const commitButton = (
177    <Button key="commit-push" onPress={() => void (cpBusy ? undefined : $.command.run({ command: 'commit-push' }))}>
178      {cpBusy ? '… Committing' : '⇡ Commit & Push'}
179    </Button>
180  )
181  const commitResult = cpBusy ? (
182    <Text dimColor>{cp.step}</Text>
183  ) : cp.phase === 'idle' ? null : (
184    <Box flexDirection="column">
185      <Text color={cpColor} bold>{`${cpMark}${cp.summary}`}</Text>
186      {cp.message ? <Text>{cp.message}</Text> : null}
187      {cp.detail ? <Text dimColor>{cp.detail.length > 500 ? `${cp.detail.slice(0, 499)}…` : cp.detail}</Text> : null}
188    </Box>
189  )
190
191  const ho = await read($, handoff)
192  const hoBusy = ho.phase === 'writing' || ho.phase === 'clearing'
193  const hoColor = ho.phase === 'done' ? 'green' : ho.phase === 'error' ? 'red' : undefined
194  const handoffButton = (
195    <Button key="handoff" onPress={() => void (hoBusy ? undefined : $.command.run({ command: 'handoff' }))}>
196      {hoBusy ? '… Handing off' : '⇄ Handoff'}
197    </Button>
198  )
199  const newChatButton = (
200    <Button key="new-chat" onPress={() => void $.command.run({ command: 'clear' })}>
201      {'+ New chat'}
202    </Button>
203  )
204  const handoffResult = ho.phase === 'idle' ? null : <Text color={hoColor} dimColor={hoBusy}>{ho.summary}</Text>
205
206  const sep = vertical ? null : <Text dimColor>{'  │  '}</Text>
207  return (
208    <Box flexDirection={vertical ? 'column' : 'row'}>
209      <Box>
210        <Text color="cyan" bold>{g === null ? 'model --' : `● ${badge(g.model)}`}</Text>
211      </Box>
212      {sep}
213      <Box marginTop={vertical ? 1 : 0}>
214        <Text bold>{'Context '}</Text>
215        <Text color={ctxColor}>{ctxOn}</Text>
216        <Text dimColor>{ctxOff}</Text>
217        <Text color={ctxColor} bold>{g === null ? ' --' : ` ${pct}%`}</Text>
218        <Text dimColor>{g === null || vertical ? '' : ` ${k(g.tokens)}/${k(g.window)}`}</Text>
219      </Box>
220      {sep}
221      <Box marginTop={vertical ? 1 : 0}>
222        <Text bold>{'Cost '}</Text>
223        <Text color={costColor}>{costOn}</Text>
224        <Text dimColor>{costOff}</Text>
225        <Text color={costColor} bold>{usd === null ? ' --' : ` $${usd.toFixed(usd < 10 ? 3 : 2)}`}</Text>
226        {vertical ? null : <Text dimColor>{` of $${budget}`}</Text>}
227      </Box>
228      {sep}
229      {speedo}
230      {vertical ? (
231        <Box flexGrow={1} flexDirection="column" justifyContent="flex-end" marginTop={1}>
232          {commitResult}
233          {commitButton}
234          {handoffResult}
235          {handoffButton}
236          {newChatButton}
237          <Button key="reload" onPress={() => void $.command.run({ command: 'reload-plugins' })}>
238            {'↻ Reload plugins'}
239          </Button>
240        </Box>
241      ) : (
242        <Box>
243          {sep}
244          {commitButton}
245          {cpBusy || cp.phase === 'idle' ? null : <Text color={cpColor}>{` ${cpMark}${cp.summary}`}</Text>}
246          <Text> </Text>
247          {handoffButton}
248          <Text> </Text>
249          {newChatButton}
250        </Box>
251      )}
252    </Box>
253  )
254}
255
256export const register: Register = (on, options) => {
257  on('session.start', async ($, e, next) => {
258    void $.ui.open({ id: PANE, title: 'Sidebar' })
259    await refresh($)
260    return next(e)
261  })
262
263  on('turn.complete', async ($, e, next) => {
264    const result = await next(e)
265    await refresh($, e.usage?.model)
266    return result
267  })
268
269  on('session.measure', async ($, e, next) => {
270    await update($, limits, () => e.rateLimits ?? [])
271    return next(e)
272  })
273
274  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => draw($, e, true, options))
275
276  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
277    if (e.props.hasSurvey) return next(e)
278    const panes = await $.ui.panes()
279    if (panes.some((p: any) => p.id === PANE && p.isPlaced)) return next(e)
280    return draw($, e, false, options)
281  })
282}
283
types/index.d.ts 9 lines
1export type Gauge = { percent: number; tokens: number; window: number; usd: number | null; model: string }
2export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
3
4declare module 'claude-code' {
5  interface PluginState {
6    sidebar: { gauge: Gauge | null; limits: Limit[] }
7  }
8}
9