SLOPSHOPPER

limit-bars

Four animated braille rings under the prompt: context, session, weekly and Fable limits, plus model and folder

newspinnertimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · limit-bars
› fix the failing auth test and add an audit log call ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ ⢠⡶⠛⠛⢶⡄ ⢠⡶⠛⠛⢶⡄ ⢠⡶⠛⠛⢶⡄ ⢠⡶⠛⠛⢶⡄ ◆ opus-5-5 ⣿49% ⣿ ⣿31% ⣿ ⣿ -- ⣿ ⣿ -- ⣿ ▸ app ⠘⠷⣤⣤⠾⠃ ⠘⠷⣤⣤⠾⠃ ⠘⠷⣤⣤⠾⠃ ⠘⠷⣤⣤⠾⠃ 97k / 200k

Draws

Prompt hint
⟨Claude Code's own drawing⟩ ⢠⡶⠛⠛⢶⡄ ⢠⡶⠛⠛⢶⡄ ⢠⡶⠛⠛⢶⡄ ⢠⡶⠛⠛⢶⡄ ◆ opus-5-5 ⣿49% ⣿ ⣿31% ⣿ ⣿ -- ⣿ ⣿ -- ⣿ ▸ app ⠘⠷⣤⣤⠾⠃ ⠘⠷⣤⣤⠾⠃ ⠘⠷⣤⣤⠾⠃ ⠘⠷⣤⣤⠾⠃ 97k / 200k
README

claude-code-mods

Stylish HUD mods for the Claude Code terminal, drawn under the prompt.

a highlighted reply with rainbow headings and bold text, and the limit rings and progress bars under the Claude Code prompt

PluginWhat it draws
limit-barsFour braille rings, filled clockwise with the percentage inside: context (green → amber → red), session 5-hour limit (cyan → violet), weekly limit (pink → orange) and Fable weekly limit (mint → blue). A light orbits each filled arc while Claude works (the rings hold still while it is idle, so typing stays quick); a ring pulses red past 80% (context) or 90% (limits). Beside them: model with its effort level (italic, shading violet → pink from low to max), workspace folder, tokens used and when the session limit resets (↻ 4:30 PM).
plan-progress-fxAnimated rainbow progress bars for multi-step tasks, with gradient titles and percentages, placed to the right of the rings. On a narrow terminal they stay collapsed until you open them with ◉ Progress, and then take the rings' place. It also changes how Claude works so the bars stay current; see below.
reply-highlightSets Claude's replies apart in the transcript: a rainbow edge down the left (red at the top of each block to violet at the bottom) and a violet tint behind them. Text keeps the terminal's colour; what Claude bolds, and headings, run through a rainbow letter by letter. Code blocks and tables keep Claude Code's own drawing inside the panel.
backlog-paneA side pane for the project: its git status on top (branch, ahead/behind, changed files coloured by kind), shown in any git repository, then its Backlog.md tasks when it has a backlog/ folder (in its root, or a parent inside the same repository). Tasks in progress carry a rainbow bar of checked acceptance criteria and a ✓ done button; the backlog list has ▸ start buttons; priority, type and first label sit in front of each title; a box at the bottom creates a task. Buttons run the backlog CLI, so files stay as Backlog.md writes them. Each section sits in a rounded card in its colour, titled on its first row with its count on the right. Rereads every few seconds; /backlog reopens it.
transcript-fxRestyles the rest of the transcript to match. Tool calls are gold rows (an icon per kind, a short summary, ✓ / ✗ / ◐ / ⊘) with their results in a gold-edged panel, and folded runs are one gold count line (Read ×3 · Grep ×2). Your prompts sit in a padded teal box with a rainbow frame running clockwise around it. A small rounded square sits beside the spinner with a rainbow comet circling its border; the spinner keeps its time and tokens. Each turn closes with ✦ baked in 2m 50s · 3:42 PM, and a rainbow rule sits above the prompt when nothing else uses that band. /fx turns each part on or off.

Each works alone; limit-bars and plan-progress-fx share the line under the prompt when both are installed.

Install

Requires a Claude Code build with plugin hooks (ui.render).

claude plugin marketplace add LegendSilvia/claude-code-mods
claude plugin install limit-bars@claude-code-mods
claude plugin install plan-progress-fx@claude-code-mods
claude plugin install reply-highlight@claude-code-mods
claude plugin install backlog-pane@claude-code-mods
claude plugin install transcript-fx@claude-code-mods

Restart Claude Code. If you use a statusLine command in settings.json that shows the model, folder or context, you can remove it: limit-bars shows the same.

Notes

  • The rate-limit rings fill in after the first reply of a session (Claude Code learns the limits from API responses) and only on a subscription account. A limit your account doesn't report shows --.
  • The Fable ring reads the weekly window whose name contains fable, or any other model-specific weekly window.
  • The rings and animation need the terminal; the desktop app gets pie glyphs (○ ◔ ◑ ◕ ●) instead.

plan-progress-fx commands

CommandDoes
/progressShow or hide the bars
/progress-demoRun a sample plan
/progress-soundsPlay the decision, error and done sounds
/progress-clearRemove all bars
/progress-debugShow the widths the bars were laid out by (the terminal's, and what the prompt line was told)

transcript-fx commands

CommandDoes
/fxList the parts and whether each is on
`/fx <part> on\off`Turn one part on or off: tools, spinner, prompts, footer, rule (remembered across sessions)
/fx debugShow what the footer clock went by: the last turn's end and each recent footer's first drawing and decision

The footer's clock time is taken when the turn ends; footers already on screen when the plugin reloads show only their duration.

How plan-progress-fx steers Claude

The bars only move when Claude reports progress, so this plugin changes what Claude does, not just what the terminal draws:

  • Rules in the system prompt. Every session it adds a short section telling Claude to create a bar (through the plugin's plan_progress tool) for any task needing more than about three edits or commands, to update it as steps finish, and to mark it "needs input" before asking you to decide.
  • One refused call per turn. While no bar is open, the 4th file-changing Edit, Write, MultiEdit, NotebookEdit, Bash or PowerShell call of a turn is refused once, with a message telling Claude to create a bar first. Read-only shell calls (ls, git status, grep) don't count, and subagents are never refused.
  • Sent back once at the end of a turn. If the turn did work and a bar is still open when Claude stops, Claude is sent back once to update it. A reply that ends in a question marks the bar as waiting on you instead.
  • Reminders. With a bar open, every six changing calls without an update add a one-line reminder to the tool result, and each prompt you send carries one line naming the open bars.

Develop

claude plugin validate plugins/limit-bars
claude plugin test plugins/limit-bars
claude plugin test plugins/plan-progress-fx
claude plugin test plugins/reply-highlight
claude plugin test plugins/backlog-pane
claude plugin test plugins/transcript-fx

License

MIT. plan-progress-fx is a fork of plan-progress by Kirill Serditov (MIT); see its LICENSE.

Source 2 files
hooks/register.tsx 285 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Info, Limit } from '../types'
5
6const limits = atom({ plugin: 'limit-bars', key: 'limits' } as const, [] as Limit[])
7const info = atom({ plugin: 'limit-bars', key: 'info' } as const, null as Info | null)
8// the main loop's effort as its last model request carried it; null for a model without effort
9const effort = atom({ plugin: 'limit-bars', key: 'effort' } as const, null as string | null)
10
11// one ring each, told apart by colour: context, session, weekly, Fable
12export const RINGS: { key: string; stops: number[][]; hotAt: number }[] = [
13  { key: 'context', stops: [[80, 250, 140], [255, 210, 60], [255, 70, 90]], hotAt: 80 },
14  { key: 'session', stops: [[0, 229, 255], [124, 77, 255]], hotAt: 90 },
15  { key: 'weekly', stops: [[255, 64, 160], [255, 170, 60]], hotAt: 90 },
16  { key: 'fable', stops: [[60, 255, 170], [60, 140, 255]], hotAt: 90 },
17]
18const HOT = [255, 70, 70]
19const TRACK = [58, 58, 66]
20const WHITE = [255, 255, 255]
21const DEFAULT_BG = 0x01000000
22const FPS_MS = 250
23const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
24
25// which window feeds which limit ring: five_hour, seven_day, then the model's own weekly window
26export function pick(list: Limit[]): (Limit | undefined)[] {
27  const model = list.find(l => /fable/i.test(l.kind)) ?? list.find(l => /^seven_day_/.test(l.kind))
28  return [list.find(l => l.kind === 'five_hour'), list.find(l => l.kind === 'seven_day'), model]
29}
30
31const mix = (a: number[], b: number[], t: number) => a.map((v, i) => Math.round(v + ((b[i] ?? 0) - v) * t))
32const pack = (c: number[]) => ((c[0]! & 255) << 16) | ((c[1]! & 255) << 8) | (c[2]! & 255)
33const along = (stops: number[][], t: number) => {
34  const k = Math.min(stops.length - 1.0001, Math.max(0, t) * (stops.length - 1))
35  return mix(stops[Math.floor(k)]!, stops[Math.floor(k) + 1]!, k - Math.floor(k))
36}
37const hex = (c: number[]) => `#${c.map(v => v.toString(16).padStart(2, '0')).join('')}`
38
39function base64(bytes: Uint8Array): string {
40  let out = ''
41  for (let i = 0; i < bytes.length; i += 3) {
42    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
43    out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
44  }
45  return out
46}
47
48export function tokens(n: number | undefined): string {
49  if (n === undefined) return '—'
50  if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`
51  if (n >= 1e3) return `${Math.round(n / 1e3)}k`
52  return String(n)
53}
54
55// the model without its family prefix, so the effort meter fits beside it: opus-5-5[1m]
56export const shortModel = (model: string | undefined) => (model ? model.replace(/^claude-/, '') : '…')
57
58// when the session window resets, as the clock reads it: "4:30 PM" today, "Thu 9:00 AM" later
59const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
60export function resetAt(iso: string | undefined, now: number): string | null {
61  const at = iso ? Date.parse(iso) : NaN
62  if (Number.isNaN(at)) return null
63  const d = new Date(at)
64  const h = d.getHours()
65  const clock = `${h % 12 === 0 ? 12 : h % 12}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
66  return new Date(now).toDateString() === d.toDateString() ? clock : `${DAYS[d.getDay()]} ${clock}`
67}
68
69export const folderOf = (path: string) => path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || path
70
71// a braille ring, RING_COLS x RING_ROWS cells (2x4 dots each), filled clockwise from 12 o'clock,
72// the percentage drawn in the hole; a light orbits the filled arc
73export const RING_COLS = 6
74export const RING_ROWS = 3
75const BRAILLE_BITS = [
76  [0x01, 0x02, 0x04, 0x40],
77  [0x08, 0x10, 0x20, 0x80],
78]
79
80export function ringCells(ring: number, pct: number | undefined, now: number): string {
81  const { stops, hotAt } = RINGS[ring]!
82  const W = RING_COLS
83  const H = RING_ROWS
84  const words = new Uint32Array(W * H * 3)
85  const cx = W - 0.5 // dot-space centre: (2W - 1) / 2
86  const cy = H * 2 - 0.5
87  const r = Math.min(cx, cy) - 0.2
88  const share = Math.max(0, Math.min(1, (pct ?? 0) / 100))
89  const heat = pct !== undefined && pct >= hotAt ? 0.4 + 0.3 * Math.sin(now / 180) : 0
90  // each ring's light starts at its own phase so they don't orbit in lockstep
91  const orbit = ((now / 1600 + ring * 0.27) % 1) * share
92  const label = pct === undefined ? ' -- ' : `${Math.round(pct)}%`.padStart(pct >= 100 ? 4 : 3).padEnd(4)
93  for (let row = 0; row < H; row++) {
94    for (let col = 0; col < W; col++) {
95      let on = 0
96      let track = 0
97      let angle = 0
98      let lit = 0
99      for (let dx = 0; dx < 2; dx++) {
100        for (let dy = 0; dy < 4; dy++) {
101          const x = col * 2 + dx - cx
102          const y = row * 4 + dy - cy
103          const d = Math.hypot(x, y)
104          if (d < r - 1.3 || d > r + 0.45) continue
105          const a = (Math.atan2(x, -y) / (2 * Math.PI) + 1) % 1
106          if (a <= share && share > 0) {
107            on |= BRAILLE_BITS[dx]![dy]!
108            angle = Math.max(angle, a)
109            lit++
110          } else {
111            track |= BRAILLE_BITS[dx]![dy]!
112          }
113        }
114      }
115      const i = (row * W + col) * 3
116      const inHole = row === Math.floor(H / 2) && col >= 1 && col <= W - 2
117      if (inHole) {
118        words[i] = label.codePointAt(col - 1) ?? 32
119        words[i + 1] = pack(pct === undefined ? TRACK : mix(along(stops, share), HOT, heat))
120      } else if (lit > 0) {
121        const glow = Math.max(0, 1 - Math.abs(angle - orbit) * 8)
122        words[i] = 0x2800 | on
123        words[i + 1] = pack(mix(mix(along(stops, angle), HOT, heat), WHITE, glow * 0.75))
124      } else {
125        words[i] = 0x2800 | track
126        words[i + 1] = pack(TRACK)
127      }
128      words[i + 2] = DEFAULT_BG
129    }
130  }
131  return base64(new Uint8Array(words.buffer))
132}
133
134// the pie glyph nearest the share, where no Raster draws
135export const pie = (pct: number | undefined) => '○◔◑◕●'[Math.round(Math.max(0, Math.min(1, (pct ?? 0) / 100)) * 4)]!
136
137// plan-progress-fx draws its bars as a Box keyed 'pp-bars' (beside this status) or 'pp-solo' (in its place,
138// on a screen too narrow for both); STATUS_W must match its copy there
139export const STATUS_KEY = 'lb-status'
140export const STATUS_W = 66
141type El = { type: string; props?: Record<string, unknown>; children?: unknown[] }
142const isEl = (n: unknown): n is El => typeof n === 'object' && n !== null && !Array.isArray(n) && 'type' in n
143
144// the tree without the first element whose key is one of `keys`, and that element
145export function pluck(node: unknown, keys: string[]): [unknown, El | null] {
146  if (Array.isArray(node)) {
147    let found: El | null = null
148    const out: unknown[] = []
149    for (const n of node) {
150      if (found) {
151        out.push(n)
152        continue
153      }
154      const [m, f] = pluck(n, keys)
155      found = f
156      if (m !== undefined) out.push(m)
157    }
158    return [out, found]
159  }
160  if (!isEl(node)) return [node, null]
161  if (keys.includes(String(node.props?.key))) return [undefined, node]
162  if (!node.children) return [node, null]
163  const [children, found] = pluck(node.children, keys)
164  return [found ? { ...node, children } : node, found]
165}
166
167// effort as its label alone, coloured along the model's violet-to-pink gradient by level;
168// a numeric budget draws as its number
169const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
170const EFFORT_STOPS = [[124, 77, 255], [179, 136, 255], [255, 95, 210]]
171export function effortBadge(level: string | null): { label: string; color: string } | null {
172  if (level === null) return null
173  const i = EFFORT_LEVELS.indexOf(level)
174  return { label: level, color: hex(along(EFFORT_STOPS, i < 0 ? 0.5 : i / (EFFORT_LEVELS.length - 1))) }
175}
176
177// what the line last drew, so the frame clock can repaint the rings between renders
178let mounted: { requestId: string; pcts: (number | undefined)[] } | null = null
179
180async function measureInfo($: EngineInterface, context: { percent?: number; tokens?: number; window: number }) {
181  const [model, root] = await Promise.all([$.session.model(), $.session.root()])
182  await update($, info, () => ({ model, folder: folderOf(root), percent: context.percent, tokens: context.tokens, window: context.window }))
183}
184
185export const register: Register = on => {
186  on('session.start', async ($, e, next) => {
187    const ran = await next(e)
188    const usage = await $.session.usage()
189    await update($, limits, () => usage.rateLimits)
190    await measureInfo($, usage.context)
191    $.clock.every(FPS_MS, async () => {
192      if (!mounted) return
193      const now = await $.clock.now()
194      const { requestId, pcts } = mounted
195      pcts.forEach((p, i) => {
196        void $.ui.blit({ requestId, key: `ring-${i}`, cells: ringCells(i, p, now) }).catch(() => undefined)
197      })
198    })
199    return ran
200  })
201
202  // the effort rides on each model request of the main loop: note it, change nothing
203  on('turn.step', async function* ($, e, next) {
204    if (!e.agentId) {
205      const level = e.effort === undefined ? null : String(e.effort)
206      if ((await read($, effort)) !== level) await update($, effort, () => level)
207    }
208    return yield* next(e)
209  })
210
211  on('session.measure', async ($, e, next) => {
212    if (e.changed.includes('rateLimits')) await update($, limits, () => e.rateLimits)
213    await measureInfo($, e.context)
214    return next(e)
215  })
216
217  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
218    const engine = await next(e)
219    const meta = await read($, info)
220    const badge = effortBadge(await read($, effort))
221    const windows = pick(await read($, limits))
222    const pcts = [meta?.percent, ...windows.map(l => l?.percentUsed)]
223    const now = await $.clock.now()
224    const reset = resetAt(windows[0]?.resetsAt, now)
225    const t = $.ui.resolve(e)
226    const { Box, Text } = t
227    const Raster = e.surface === 'terminal' && 'Raster' in t ? t.Raster : null
228    // the light orbits only while Claude works: an idle repaint every frame slows typing in a long session
229    mounted = Raster && e.props.isWorking ? { requestId: e.requestId, pcts } : null
230
231    const rings = pcts.map((p, i) => {
232      if (Raster) return <Raster key={`ring-${i}`} columns={RING_COLS} rows={RING_ROWS} cells={ringCells(i, p, now)} />
233      const { stops, hotAt } = RINGS[i]!
234      const color = p !== undefined && p >= hotAt ? HOT : along(stops, (p ?? 0) / 100)
235      return <Text key={`ring-${i}`} color={hex(color)}>{`${pie(p)} ${p === undefined ? '—' : `${Math.round(p)}%`}`}</Text>
236    })
237
238    const status = (
239      <Box key={STATUS_KEY} flexDirection="row" gap={2} width={STATUS_W} flexShrink={0}>
240        {rings}
241        <Box flexDirection="column" flexShrink={1}>
242          <Box flexDirection="row" gap={1}>
243            <Text bold wrap="truncate" color="#b388ff">◆ {shortModel(meta?.model)}</Text>
244            {badge ? (
245              <Box flexShrink={0}>
246                <Text italic color={badge.color}>
247                  {badge.label}
248                </Text>
249              </Box>
250            ) : null}
251          </Box>
252          <Text bold wrap="truncate" color="#4dd0e1">▸ {meta?.folder ?? '…'}</Text>
253          <Box flexDirection="row" gap={2}>
254            <Text dimColor>{`${tokens(meta?.tokens)} / ${tokens(meta?.window)}`}</Text>
255            {reset ? (
256              <Text color={hex(RINGS[1]!.stops[0]!)}>
257                ↻ {reset}
258              </Text>
259            ) : null}
260          </Box>
261        </Box>
262      </Box>
263    )
264    // progress bars drawn beneath us move beside the status, or take its place when alone
265    const [rest, bars] = pluck(engine, ['pp-bars', 'pp-solo'])
266    const solo = bars?.props?.key === 'pp-solo'
267    if (solo) mounted = null
268    return (
269      <Box flexDirection="column">
270        {rest as RenderElement}
271        {solo ? (
272          (bars as RenderElement)
273        ) : bars ? (
274          <Box flexDirection="row" gap={2}>
275            {status}
276            {bars as RenderElement}
277          </Box>
278        ) : (
279          status
280        )}
281      </Box>
282    )
283  })
284}
285
types/index.d.ts 9 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Info = { model: string; folder: string; percent?: number; tokens?: number; window: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'limit-bars': { limits: Limit[]; info: Info | null; effort: string | null }
7  }
8}
9