SLOPSHOPPER

reply-highlight

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

newrows
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reply-highlight
› 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. I made refresh reject expired claims and added an audit call. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Claude's reply
Done. I made refresh reject expired claims and added an audit call.
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 1 files
hooks/register.tsx 193 lines
1import 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