Sets Claude's replies apart in the transcript: a rainbow edge, a violet tint, and bold text and headings in a rainbow

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 193 lines1import type { Register } from 'claude-code'
2
3// a violet edge down the left of each reply block and a violet tint behind it; the prose keeps the
4// terminal's own colour, and what Claude bolds (and headings) runs through a rainbow
5const TINT = '#1b1726'
6const ACCENT = '#b388ff'
7const CODE_BG = '#2d2440'
8
9// a hue in degrees to #rrggbb at fixed saturation and lightness, bright enough for a dark panel
10export function rainbow(hue: number): string {
11 const s = 0.9
12 const l = 0.68
13 const k = (n: number) => (n + hue / 30) % 12
14 const a = s * Math.min(l, 1 - l)
15 const c = (n: number) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))
16 return `#${[0, 8, 4].map(n => c(n).toString(16).padStart(2, '0')).join('')}`
17}
18
19// about how many rows a block takes at this width; only sets how finely the edge is sliced
20export function estimateRows(text: string, columns: number): number {
21 const width = Math.max(10, columns - 6)
22 return text.split('\n').reduce((n, l) => n + Math.max(1, Math.ceil([...l].length / width)), 0) + 2
23}
24
25// the edge's slices: the layout shares the block's real height among them, so the spectrum runs
26// from its top row to its bottom row however the text wraps; on a short block the extra slices get
27// no rows, on a tall one each stretches
28export const edgeSegments = (rows: number) => Math.min(48, Math.max(6, rows))
29export const edgeColors = (segments: number) =>
30 Array.from({ length: segments }, (_, i) => rainbow((i / Math.max(1, segments - 1)) * 300))
31
32// one hue per character, a whole spectrum across the span however long it is
33export const rainbowColors = (text: string) => {
34 const n = [...text].length
35 return [...text].map((ch, i) => ({ ch, color: rainbow((i / Math.max(1, n)) * 300) }))
36}
37
38export type Span = { text: string; kind: 'plain' | 'bold' | 'bold-italic' | 'italic' | 'code' | 'link'; url?: string }
39export type Line = { kind: 'blank' | 'heading' | 'item' | 'quote' | 'text'; prefix: string; spans: Span[] }
40
41// fenced code and tables keep Claude Code's own drawing; everything else is drawn here in gold
42export const isProse = (text: string) => !/^\s*```/m.test(text) && !/^\s*\|.*\|\s*$/m.test(text)
43
44export function spans(text: string): Span[] {
45 const out: Span[] = []
46 const re = /\*\*\*([^*]+)\*\*\*|\*\*([^*]+)\*\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)|(?<![\w*])\*([^*\s][^*]*)\*(?!\w)|(?<!\w)_([^_\s][^_]*)_(?!\w)/g
47 let at = 0
48 for (const m of text.matchAll(re)) {
49 if (m.index! > at) out.push({ text: text.slice(at, m.index), kind: 'plain' })
50 if (m[1] !== undefined) out.push({ text: m[1], kind: 'bold-italic' })
51 else if (m[2] !== undefined) out.push({ text: m[2], kind: 'bold' })
52 else if (m[3] !== undefined) out.push({ text: m[3], kind: 'code' })
53 else if (m[4] !== undefined) out.push({ text: m[4], kind: 'link', url: m[5] })
54 else out.push({ text: (m[6] ?? m[7])!, kind: 'italic' })
55 at = m.index! + m[0].length
56 }
57 if (at < text.length) out.push({ text: text.slice(at), kind: 'plain' })
58 return out
59}
60
61// the href a Link takes (https, or http on localhost, printable ASCII with no raw @, as URL spells
62// it); null for any other, whose URL is then drawn as text beside the link's
63export function linkHref(url: string): string | null {
64 try {
65 const u = new URL(url)
66 if (u.protocol !== 'https:' && !(u.protocol === 'http:' && u.hostname === 'localhost')) return null
67 return /^[!-?A-~]{1,2048}$/.test(u.href) ? u.href : null
68 } catch {
69 return null
70 }
71}
72
73export function lines(text: string): Line[] {
74 return text
75 .replace(/\s+$/, '')
76 .split('\n')
77 .map(raw => {
78 if (raw.trim() === '') return { kind: 'blank', prefix: '', spans: [] }
79 const heading = /^#{1,6}\s+(.*)$/.exec(raw)
80 if (heading) return { kind: 'heading', prefix: '', spans: spans(heading[1]!) }
81 const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(raw)
82 if (item) {
83 const marker = /\d/.test(item[2]!) ? item[2]! : '•'
84 return { kind: 'item', prefix: `${item[1]}${marker} `, spans: spans(item[3]!) }
85 }
86 const quote = /^>\s?(.*)$/.exec(raw)
87 if (quote) return { kind: 'quote', prefix: '│ ', spans: spans(quote[1]!) }
88 return { kind: 'text', prefix: '', spans: spans(raw) }
89 })
90}
91
92export const register: Register = on => {
93 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
94 if (e.surface !== 'terminal') return next(e)
95 const { Box, Link, Text } = $.ui.resolve(e)
96 const prose = isProse(e.props.text)
97 const drawn = prose ? null : await next(e)
98
99 const bow = (text: string, key: string, extra: { underline?: boolean; italic?: boolean } = {}) => (
100 <Text key={key} bold underline={extra.underline} italic={extra.italic}>
101 {rainbowColors(text).map((c, i) => (
102 <Text key={`${key}-${i}`} color={c.color}>
103 {c.ch}
104 </Text>
105 ))}
106 </Text>
107 )
108
109 const span = (s: Span, key: string) => {
110 switch (s.kind) {
111 case 'bold':
112 return bow(s.text, key)
113 case 'bold-italic':
114 return bow(s.text, key, { italic: true })
115 case 'italic':
116 return (
117 <Text key={key} italic>
118 {s.text}
119 </Text>
120 )
121 case 'code':
122 return (
123 <Text key={key} color={ACCENT} backgroundColor={CODE_BG}>
124 {s.text}
125 </Text>
126 )
127 case 'link': {
128 // drawn here, so the URL would be lost: a Link keeps it, else it shows beside the text
129 const href = s.url === undefined ? null : linkHref(s.url)
130 const label = (
131 <Text underline color={ACCENT}>
132 {href || s.url === undefined || s.url === s.text ? s.text : `${s.text} (${s.url})`}
133 </Text>
134 )
135 return href ? (
136 <Link key={key} href={href}>
137 {label}
138 </Link>
139 ) : (
140 <Text key={key}>{label}</Text>
141 )
142 }
143 default:
144 return <Text key={key}>{s.text}</Text>
145 }
146 }
147
148 const body = prose ? (
149 <Box flexDirection="column">
150 {lines(e.props.text).map((l, i) => {
151 if (l.kind === 'blank') return <Text key={`l-${i}`}> </Text>
152 const text = l.spans.map(x => x.text).join('')
153 return (
154 <Text key={`l-${i}`} dimColor={l.kind === 'quote'}>
155 {l.prefix ? (
156 <Text key={`p-${i}`} color={ACCENT}>
157 {l.prefix}
158 </Text>
159 ) : null}
160 {l.kind === 'heading' ? bow(text, `h-${i}`) : l.spans.map((x, k) => span(x, `s-${i}-${k}`))}
161 </Text>
162 )
163 })}
164 </Box>
165 ) : (
166 drawn
167 )
168
169 return (
170 <Box flexDirection="row">
171 {/* placed over the row's left column, so its rows never make the block taller */}
172 <Box position="absolute" top={0} bottom={0} left={0} width={1} flexDirection="column" overflow="hidden">
173 {edgeColors(edgeSegments(estimateRows(e.props.text, e.viewport?.columns ?? 100))).map((c, i) => (
174 <Box key={`edge-${i}`} flexGrow={1} backgroundColor={c} />
175 ))}
176 </Box>
177 <Box width={1} flexShrink={0} />
178 <Box flexGrow={1} flexDirection="row" backgroundColor={TINT} paddingX={1} gap={1}>
179 {/* the reply's opening star; later blocks keep the same indent */}
180 {prose ? (
181 <Box flexShrink={0} width={1}>
182 <Text color={ACCENT}>{e.props.isFirstOfReply ? '✦' : ' '}</Text>
183 </Box>
184 ) : null}
185 <Box flexGrow={1} flexDirection="column">
186 {body}
187 </Box>
188 </Box>
189 </Box>
190 )
191 })
192}
193