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

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

| Plugin | What it draws |
|---|---|
| limit-bars | Four 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-fx | Animated 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-highlight | Sets 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-pane | A 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-fx | Restyles 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.
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.
--.fable, or any other model-specific weekly window.○ ◔ ◑ ◕ ●) instead.| Command | Does |
|---|---|
/progress | Show or hide the bars |
/progress-demo | Run a sample plan |
/progress-sounds | Play the decision, error and done sounds |
/progress-clear | Remove all bars |
/progress-debug | Show the widths the bars were laid out by (the terminal's, and what the prompt line was told) |
| Command | Does | |
|---|---|---|
/fx | List 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 debug | Show 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.
The bars only move when Claude reports progress, so this plugin changes what Claude does, not just what the terminal draws:
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.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.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
MIT. plan-progress-fx is a fork of plan-progress by Kirill Serditov (MIT); see its LICENSE.
hooks/register.tsx 285 lines1import { 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}
285types/index.d.ts 9 lines1export 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