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…

<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.
| Mod | What you get |
|---|---|
| sidebar | Model badge, context-window gauge, session cost, a 5-hour / weekly usage dial, and buttons for New chat, Commit & Push and Handoff |
| commit-push | One click to stage, commit (with a Claude-written message) and push |
| handoff | One click to save a handoff.md, clear the chat, and continue from it |
| repo | Pick a GitHub repo, clone it if needed, and move the session into it |
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.
Live status, redrawn after every turn:
Buttons, top to bottom: + New chat runs /clear. ⇡ Commit & Push and ⇄ Handoff run the mods below. ↻ Reload plugins runs /reload-plugins.
Click ⇡ Commit & Push, or type /commit-push.
git add -A).fix: handle empty repo list in picker. If that fails it falls back to naming the changed files.git push -u origin <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.
Click ⇄ Handoff, or type /handoff. Use it when your context is getting full.
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./clear.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.
Opens a picker when a session starts with no repo chosen, or type /repo.
list to see them all, then a number to pick.owner/repo, or a git URL.~/repos, ~/Documents, ~/Desktop, ~/code, ~/projects, ~/dev, ~/src, ~/GitHub and a few more) and clones the repo if it can't find one.Change these in /config (each mod's settings are listed under its name).
| Mod | Setting | Default | What it does |
|---|---|---|---|
| sidebar | budgetUsd | 5 | The session cost that fills the cost bar |
| sidebar | ascii | off | Draw bars with # and - and skip the dial, for terminals that can't show block characters |
| repo | owner | your gh login | Whose repos to list: another user or an organization |
| repo | cloneDir | ~/repos | Where missing repos are cloned |
| repo | openOnStart | on | Open the picker when a session starts with no active repo |
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>).
hooks/register.tsx 283 lines1import { 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}
283types/index.d.ts 9 lines1export 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