Gold tool blocks, teal prompt panels, an animated spinner bar and a rainbow turn footer in the transcript

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 524 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Part, Settings } from '../types'
5
6export type { Part, Settings }
7
8export const PARTS: readonly Part[] = ['tools', 'spinner', 'prompts', 'footer', 'rule']
9export const DEFAULTS: Settings = { tools: true, spinner: true, prompts: true, footer: true, rule: true }
10
11// replies stay rainbow on violet (reply-highlight); tools are gold; the person's prompts are teal
12export const GOLD_HI = '#ffd86b'
13export const GOLD_LO = '#c9962b'
14export const GOLD = '#f5c542'
15export const GOLD_TINT = '#221d12'
16export const TEAL_TINT = '#12222a'
17export const OK = '#30a46c'
18export const ERR = '#e5484d'
19export const VIOLET = '#b388ff'
20
21// a hue in degrees to #rrggbb, bright enough for a dark panel (as reply-highlight's)
22export function rainbow(hue: number): string {
23 const s = 0.9
24 const l = 0.68
25 const k = (n: number) => (n + hue / 30) % 12
26 const a = s * Math.min(l, 1 - l)
27 const c = (n: number) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))
28 return `#${[0, 8, 4].map(n => c(n).toString(16).padStart(2, '0')).join('')}`
29}
30
31const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
32const toHex = (c: number[]) => `#${c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`
33
34// the tool edge: light gold at the top to deep gold at the bottom
35export function goldEdge(n: number): string[] {
36 const [a, b] = [rgb(GOLD_HI), rgb(GOLD_LO)]
37 return Array.from({ length: Math.max(1, n) }, (_, i) => {
38 const t = n <= 1 ? 0 : i / (n - 1)
39 return toHex(a.map((v, j) => v + (b[j]! - v) * t))
40 })
41}
42
43const KIND: Record<string, string> = {
44 Write: '✎', Edit: '✎', MultiEdit: '✎', NotebookEdit: '✎',
45 Read: '⌕', Glob: '⌕', Grep: '⌕',
46 Bash: '❯', PowerShell: '❯',
47 WebFetch: '◍', WebSearch: '◍',
48 Agent: '✦', Task: '✦', Skill: '✦',
49}
50
51export const iconFor = (tool: string) => KIND[tool] ?? (tool.startsWith('mcp__') ? '⬡' : '•')
52
53// mcp__<server>__<tool> as server·tool, the connector prefix dropped
54export function toolLabel(tool: string): string {
55 const m = /^mcp__(.+?)__(.+)$/.exec(tool)
56 return m ? `${m[1]!.replace(/^claude_ai_/, '')}·${m[2]}` : tool
57}
58
59const norm = (p: string) => p.replace(/\\/g, '/')
60
61// a path inside the cwd, relative to it; any other path as given, forward slashes
62export function relPath(path: string, cwd: string): string {
63 const p = norm(path)
64 const root = norm(cwd).replace(/\/+$/, '')
65 return root && p.toLowerCase().startsWith(`${root.toLowerCase()}/`) ? p.slice(root.length + 1) : p
66}
67
68export function truncate(s: string, n: number): string {
69 const chars = [...s]
70 return chars.length <= n ? s : `${chars.slice(0, Math.max(0, n - 1)).join('')}…`
71}
72
73// one dim line saying what the call is about
74export function summarize(tool: string, input: unknown, cwd: string): string {
75 if (input === null || typeof input !== 'object') return ''
76 const i = input as Record<string, unknown>
77 const str = (k: string) => (typeof i[k] === 'string' ? (i[k] as string) : undefined)
78 const file = str('file_path') ?? str('notebook_path')
79 if (file) return relPath(file, cwd)
80 const command = str('command')
81 if (command) return command.split('\n')[0]!.trim()
82 if (tool === 'Grep' && str('pattern')) return `"${str('pattern')}"${str('path') ? ` in ${relPath(str('path')!, cwd)}` : ''}`
83 return str('pattern') ?? str('url') ?? str('query') ?? str('description') ?? str('skill') ?? ''
84}
85
86export function groupLine(calls: ReadonlyArray<{ tool: string }>): string {
87 const counts = new Map<string, number>()
88 for (const c of calls) counts.set(c.tool, (counts.get(c.tool) ?? 0) + 1)
89 return [...counts].map(([t, n]) => (n > 1 ? `${t} ×${n}` : t)).join(' · ')
90}
91
92export function fmtDuration(ms: number): string {
93 const s = Math.floor(ms / 1000)
94 if (s < 60) return `${s}s`
95 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
96 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
97}
98
99export function fmtClock(ms: number): string {
100 const d = new Date(ms)
101 const h = d.getHours()
102 return `${h % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
103}
104
105export const FRAME_MS = 160
106const STEP_MS = FRAME_MS
107const DEFAULT_BG = 0x01000000
108
109// a rounded 4×2 square; the ring runs clockwise: top left→right, then bottom right→left
110export const RING_W = 4
111export const RING_H = 2
112const RING_GLYPHS = '╭──╮╰──╯'
113const RING: ReadonlyArray<readonly [number, number]> = [[0, 0], [1, 0], [2, 0], [3, 0], [3, 1], [2, 1], [1, 1], [0, 1]]
114// brightness by distance behind the head; the rest of the ring stays dim
115const TRAIL = [1, 0.7, 0.45, 0.25]
116const DIM = 0.12
117
118export const ringHead = (t: number) => Math.floor(t / STEP_MS) % RING.length
119
120export const ringLevels = (t: number) => {
121 const head = ringHead(t)
122 return RING.map((_, p) => TRAIL[(head - p + RING.length) % RING.length] ?? DIM)
123}
124
125// row-major cells: each ring cell takes the rainbow at its place, the spectrum turning slowly, scaled by its level
126export function ringFrame(t: number): { cp: number; fg: number }[] {
127 const levels = ringLevels(t)
128 const glyphs = [...RING_GLYPHS].map(ch => ch.codePointAt(0)!)
129 const cells = glyphs.map(cp => ({ cp, fg: 0 }))
130 RING.forEach(([x, y], p) => {
131 const [r, g, b] = rgb(rainbow(((p / RING.length) * 360 + t / 8) % 360)).map(v => Math.round(v * levels[p]!))
132 cells[y * RING_W + x]!.fg = (r! << 16) | (g! << 8) | b!
133 })
134 return cells
135}
136
137const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
138// the cells are a multiple of 12 bytes, so there is never padding (as plan-progress-fx)
139function base64(bytes: Uint8Array): string {
140 let out = ''
141 for (let i = 0; i < bytes.length; i += 3) {
142 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
143 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
144 }
145 return out
146}
147
148// one frame of the ring as a Raster's cells
149export function ringCells(t: number): string {
150 const frame = ringFrame(t)
151 const words = new Uint32Array(frame.length * 3)
152 frame.forEach((c, i) => {
153 words[i * 3] = c.cp
154 words[i * 3 + 1] = c.fg
155 words[i * 3 + 2] = DEFAULT_BG
156 })
157 return base64(new Uint8Array(words.buffer))
158}
159
160export function parseFx(args: string): { kind: 'show' } | { kind: 'debug' } | { kind: 'set'; part: Part; on: boolean } | { kind: 'error' } {
161 const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
162 if (words.length === 0) return { kind: 'show' }
163 if (words.length === 1 && words[0] === 'debug') return { kind: 'debug' }
164 const [part, value] = words
165 if (words.length !== 2 || !PARTS.includes(part as Part) || (value !== 'on' && value !== 'off')) return { kind: 'error' }
166 return { kind: 'set', part: part as Part, on: value === 'on' }
167}
168
169const settings = atom({ plugin: 'transcript-fx', key: 'settings' } as const, DEFAULTS)
170// when the last main-loop turn ended and how long it ran; written at turn.complete, since drawing may not write state
171const lastEnd = atom({ plugin: 'transcript-fx', key: 'lastEnd' } as const, null as { at: number; durationMs: number } | null)
172// a footer is that turn's when it is first drawn soon after and its duration matches; any other shows no time
173const FRESH_MS = 10_000
174const SAME_TURN_MS = 2_000
175const USAGE = `Usage: /fx [${PARTS.join('|')}] [on|off]`
176
177const isOn = async ($: EngineInterface, part: Part) => (await read($, settings))[part]
178
179// how many slices an edge gets: the layout shares the block's real height among them
180export const edgeSegments = (text: string, columns: number) => {
181 const width = Math.max(10, columns - 6)
182 const rows = text.split('\n').reduce((n, l) => n + Math.max(1, Math.ceil([...l].length / width)), 0) + 2
183 return Math.min(48, Math.max(6, rows))
184}
185
186// the prompt box's colours, one spectrum clockwise from the top-left corner:
187// top 0→90, right side 90→150, bottom (right to left) 150→240, left side (bottom to top) 240→300
188export function boxFrame(columns: number, sideSegments: number) {
189 const span = (from: number, to: number, n: number, inner: boolean) =>
190 Array.from({ length: n }, (_, i) => rainbow(from + (to - from) * (inner ? (i + 1) / (n + 1) : i / Math.max(1, n - 1))))
191 const dashes = Math.max(1, columns - 2)
192 return {
193 corners: { topLeft: rainbow(0), topRight: rainbow(90), bottomRight: rainbow(150), bottomLeft: rainbow(240) },
194 top: span(0, 90, dashes, true),
195 right: span(90, 150, sideSegments, true),
196 // drawn left to right, so it runs from the bottom-left's hue back to the bottom-right's
197 bottom: span(240, 150, dashes, true),
198 // drawn top to bottom, so it runs from the top-left's end of the spectrum down
199 left: span(300, 240, sideSegments, true),
200 }
201}
202
203// a 1-column edge laid over the row's first (or last) column, so it never adds height
204function edge(Box: any, colors: string[], side: 'left' | 'right') {
205 return (
206 <Box position="absolute" top={0} bottom={0} {...(side === 'left' ? { left: 0 } : { right: 0 })} width={1} flexDirection="column" overflow="hidden">
207 {colors.map((c, i) => (
208 <Box key={`edge-${i}`} flexGrow={1} backgroundColor={c} />
209 ))}
210 </Box>
211 )
212}
213
214const status = (p: { isRunning: boolean; isErrored: boolean; isInterrupted: boolean }) =>
215 p.isInterrupted
216 ? { glyph: '⊘', color: undefined, dim: true }
217 : p.isErrored
218 ? { glyph: '✗', color: ERR, dim: false }
219 : p.isRunning
220 ? { glyph: '◐', color: GOLD, dim: false }
221 : { glyph: '✓', color: OK, dim: false }
222
223export const register: Register = on => {
224 // each footer's end time, null for one that is not the last turn's; a reload starts it over
225 const footerAt = new Map<string, number | null>()
226 // when each footer was first drawn, and the duration it showed
227 const footerSeen = new Map<string, { seen: number; durationMs: number }>()
228 // spinners on screen, repainted in place each frame without a render pass
229 const spinners = new Set<string>()
230 // tool calls drawn as rows of an expanded group, which show their results inline
231 const inExpandedGroup = new Set<string>()
232
233 on('turn.complete', async ($, e, next) => {
234 if (e.agentId === undefined) {
235 const now = await $.clock.now()
236 await update($, lastEnd, () => ({ at: now, durationMs: e.durationMs }))
237 }
238 return next(e)
239 })
240
241 on('session.start', async ($, e, next) => {
242 $.clock.every(FRAME_MS, async () => {
243 if (spinners.size === 0) return
244 const cells = ringCells(await $.clock.now())
245 // a spinner that is gone answers { deny } and leaves the set; its next drawing adds it back
246 for (const requestId of spinners)
247 void $.ui
248 .blit({ requestId, key: 'ring', cells })
249 .then(r => {
250 if (r.deny !== undefined) spinners.delete(requestId)
251 })
252 .catch(() => spinners.delete(requestId))
253 })
254 try {
255 const saved = await $.store.get('settings')
256 if (saved && typeof saved === 'object') await update($, settings, () => ({ ...DEFAULTS, ...(saved as Partial<Settings>) }))
257 await $.command.register({ name: 'fx', description: 'Show or toggle transcript-fx parts' })
258 } catch {
259 // the defaults stand; the session goes on
260 }
261 return next(e)
262 })
263
264 on('command.run', { command: 'fx' }, async ($, e) => {
265 const cmd = parseFx(e.args)
266 if (cmd.kind === 'error') return { text: USAGE }
267 if (cmd.kind === 'debug') {
268 // what the footer clock went by: the last turn's end, and each recent footer's first drawing and decision
269 const end = await read($, lastEnd)
270 const lines = [`last turn end: ${end === null ? 'none' : `${fmtClock(end.at)}, ${fmtDuration(end.durationMs)} (${end.durationMs} ms)`}`]
271 for (const [id, f] of [...footerSeen].slice(-5)) {
272 const at = footerAt.get(id)
273 const decided = at === undefined ? 'waiting' : at === null ? 'no time' : fmtClock(at)
274 lines.push(`footer ${id}: first drawn ${fmtClock(f.seen)}, ${fmtDuration(f.durationMs)} (${f.durationMs} ms) → ${decided}`)
275 }
276 return { text: lines.join('\n') }
277 }
278 if (cmd.kind === 'set') {
279 const now = { ...(await read($, settings)), [cmd.part]: cmd.on }
280 await update($, settings, () => now)
281 await $.store.set('settings', now)
282 return { text: `transcript-fx: ${cmd.part} ${cmd.on ? 'on' : 'off'}.` }
283 }
284 const s = await read($, settings)
285 return { text: PARTS.map(p => `${p}: ${s[p] ? 'on' : 'off'}`).join(' · ') }
286 })
287
288 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
289 // a row of an expanded group draws its result inline: the engine's row keeps it
290 if (e.surface !== 'terminal' || inExpandedGroup.has(e.props.tool_use_id) || !(await isOn($, 'tools'))) return next(e)
291 try {
292 const { Box, Text } = $.ui.resolve(e)
293 const label = toolLabel(e.props.tool)
294 const room = (e.viewport?.columns ?? 100) - label.length - 10
295 // an unknown cwd only costs the relative paths, never the row
296 const cwd = await $.session.cwd().catch(() => '')
297 const summary = truncate(summarize(e.props.tool, e.props.input, cwd), room)
298 const s = status(e.props)
299 return (
300 <Box flexDirection="row">
301 {edge(Box, goldEdge(2), 'left')}
302 <Box width={1} flexShrink={0} />
303 <Box flexGrow={1} flexDirection="row" gap={1} paddingX={1}>
304 <Text color={GOLD}>{iconFor(e.props.tool)}</Text>
305 {/* the viewport is the window's width, not the transcript column's: the layout clips at the real one */}
306 <Text bold color={GOLD} wrap="truncate-end">
307 {label}
308 </Text>
309 <Box flexGrow={1}>
310 <Text dimColor wrap="truncate-end">
311 {summary}
312 </Text>
313 </Box>
314 <Text color={s.color} dimColor={s.dim}>
315 {s.glyph}
316 </Text>
317 </Box>
318 </Box>
319 )
320 } catch {
321 return next(e)
322 }
323 })
324
325 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
326 if (e.surface !== 'terminal' || !(await isOn($, 'tools'))) return next(e)
327 // drawn once, so a failure below hands back this tree rather than calling next again
328 const drawn = await next(e)
329 try {
330 const { Box } = $.ui.resolve(e)
331 const text = typeof e.props.output === 'string' ? e.props.output : JSON.stringify(e.props.output ?? '')
332 return (
333 <Box flexDirection="row">
334 {edge(Box, goldEdge(edgeSegments(text, e.viewport?.columns ?? 100)), 'left')}
335 <Box width={1} flexShrink={0} />
336 <Box flexGrow={1} flexDirection="column" backgroundColor={GOLD_TINT} paddingX={1}>
337 {drawn}
338 </Box>
339 </Box>
340 )
341 } catch {
342 return drawn
343 }
344 })
345
346 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
347 if (e.props.isExpanded) for (const c of e.props.calls) if (c.tool_use_id !== undefined) inExpandedGroup.add(c.tool_use_id)
348 if (e.surface !== 'terminal' || e.props.isExpanded || !(await isOn($, 'tools'))) return next(e)
349 try {
350 const { Box, Text } = $.ui.resolve(e)
351 const first = e.props.calls[0]?.tool ?? ''
352 // a group of one call reads like that call's own row: what it ran and how it ended
353 const only = e.props.calls.length === 1 ? e.props.calls[0]! : undefined
354 const summary = only ? summarize(only.tool, only.input, await $.session.cwd().catch(() => '')) : ''
355 const s = only ? status(only) : undefined
356 return (
357 <Box flexDirection="row">
358 {edge(Box, goldEdge(2), 'left')}
359 <Box width={1} flexShrink={0} />
360 <Box flexGrow={1} flexDirection="row" gap={1} paddingX={1}>
361 <Text color={GOLD}>{iconFor(first)}</Text>
362 <Text bold={only !== undefined} color={GOLD} wrap="truncate-end">
363 {groupLine(e.props.calls)}
364 </Text>
365 <Box flexGrow={1}>
366 <Text dimColor wrap="truncate-end">
367 {summary}
368 </Text>
369 </Box>
370 {s ? (
371 <Text color={s.color} dimColor={s.dim}>
372 {s.glyph}
373 </Text>
374 ) : e.props.isActive ? (
375 <Text color={GOLD}>◐</Text>
376 ) : null}
377 </Box>
378 </Box>
379 )
380 } catch {
381 return next(e)
382 }
383 })
384
385 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
386 // the person typing, at the terminal or through Remote Control; every other sender keeps its row
387 const mine = e.props.origin.kind === 'composer' || e.props.origin.kind === 'bridge'
388 if (e.surface !== 'terminal' || !mine || !(await isOn($, 'prompts'))) return next(e)
389 try {
390 const { Box, Text } = $.ui.resolve(e)
391 const columns = e.viewport?.columns ?? 100
392 // the middle row's height: the text's rows at the panel's width, plus a padding row above and below
393 const textRows = e.props.text.split('\n').reduce((n, l) => n + Math.max(1, Math.ceil([...l].length / Math.max(10, columns - 10))), 0)
394 const f = boxFrame(columns, textRows + 2)
395 // a row of the frame: two corners and the dashes between, which clip at the column's real width
396 const line = (key: string, corners: [string, string], cornerColors: [string, string], dashes: string[]) => (
397 <Box key={key} flexDirection="row" height={1}>
398 <Text color={cornerColors[0]}>{corners[0]}</Text>
399 <Box flexGrow={1} flexShrink={1} overflow="hidden">
400 <Text wrap="truncate-end">
401 {dashes.map((c, i) => (
402 <Text key={`d-${i}`} color={c}>
403 ─
404 </Text>
405 ))}
406 </Text>
407 </Box>
408 <Text color={cornerColors[1]}>{corners[1]}</Text>
409 </Box>
410 )
411 // a side lies over the middle row's first or last column, so the text and padding alone set the height;
412 // one segment per row, each drawing its own │
413 const side = (key: string, colors: string[], at: 'left' | 'right') => (
414 <Box key={key} position="absolute" top={0} bottom={0} {...(at === 'left' ? { left: 0 } : { right: 0 })} width={1} flexDirection="column" overflow="hidden">
415 {colors.map((c, i) => (
416 <Box key={`s-${i}`} flexGrow={1} overflow="hidden">
417 <Text color={c}>│</Text>
418 </Box>
419 ))}
420 </Box>
421 )
422 return (
423 <Box key="prompt-box" flexDirection="column" marginTop={1} marginBottom={1}>
424 {line('box-top', ['╭', '╮'], [f.corners.topLeft, f.corners.topRight], f.top)}
425 <Box flexDirection="row">
426 <Box width={1} flexShrink={0} />
427 <Box key="panel" flexGrow={1} flexDirection="row" backgroundColor={TEAL_TINT} paddingX={1} paddingY={1} gap={1}>
428 <Box flexGrow={1}>
429 <Text>{e.props.text}</Text>
430 </Box>
431 <Text dimColor>you</Text>
432 </Box>
433 <Box width={1} flexShrink={0} />
434 {side('box-left', f.left, 'left')}
435 {side('box-right', f.right, 'right')}
436 </Box>
437 {line('box-bottom', ['╰', '╯'], [f.corners.bottomLeft, f.corners.bottomRight], f.bottom)}
438 </Box>
439 )
440 } catch {
441 return next(e)
442 }
443 })
444
445 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
446 if (e.surface !== 'terminal' || !(await isOn($, 'spinner'))) return next(e)
447 // the engine's line keeps the time and tokens no prop carries
448 const drawn = await next(e)
449 try {
450 const { Box, Raster } = $.ui.resolve(e)
451 spinners.add(e.requestId)
452 return (
453 // pinned to the bottom of the block, beside the spinner line and its tip; the block grows no taller
454 <Box key="spinner" flexDirection="row" gap={1} alignItems="flex-end" marginTop={1}>
455 <Raster key="ring" columns={RING_W} rows={RING_H} cells={ringCells(await $.clock.now())} />
456 {drawn}
457 </Box>
458 )
459 } catch {
460 return drawn
461 }
462 })
463
464 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
465 const drawn = await next(e)
466 // free only when core draws its own band and no survey is up; another plugin's band (pokecli's battle) stays
467 const isFree = (drawn as { type?: string }).type === 'engine' && !e.props.hasSurvey
468 if (e.surface !== 'terminal' || !isFree || !(await isOn($, 'rule'))) return drawn
469 try {
470 const { Box, Text } = $.ui.resolve(e)
471 // the band's body, which a docked pane narrows below the window's width
472 const width = Math.max(1, e.props.bodyColumns || e.viewport?.columns || 80)
473 return (
474 <Box key="rule" flexDirection="row">
475 {Array.from({ length: width }, (_, i) => (
476 <Text key={`r-${i}`} color={rainbow((i / Math.max(1, width - 1)) * 300)}>
477 ─
478 </Text>
479 ))}
480 </Box>
481 )
482 } catch {
483 return drawn
484 }
485 })
486
487 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
488 if (e.surface !== 'terminal' || !(await isOn($, 'footer'))) return next(e)
489 try {
490 const { Box, Text } = $.ui.resolve(e)
491 // the footer can be drawn before its turn.complete arrives: it waits for a turn that ended near when it
492 // first appeared, with its duration; once that window has passed it settles on no time, so a later turn
493 // never stamps an older footer
494 const now = await $.clock.now()
495 if (!footerSeen.has(e.requestId)) footerSeen.set(e.requestId, { seen: now, durationMs: e.props.durationMs })
496 if (!footerAt.has(e.requestId)) {
497 const { seen } = footerSeen.get(e.requestId)!
498 const end = await read($, lastEnd)
499 const isThisTurn = end !== null && Math.abs(end.at - seen) < FRESH_MS && Math.abs(end.durationMs - e.props.durationMs) < SAME_TURN_MS
500 if (isThisTurn) footerAt.set(e.requestId, end.at)
501 else if (now - seen >= FRESH_MS) footerAt.set(e.requestId, null)
502 }
503 const at = footerAt.get(e.requestId) ?? null
504 const word = e.props.word.toLowerCase()
505 return (
506 // a blank line from the reply above; the star lines up with the one that opens replies
507 <Box key="footer" flexDirection="row" gap={1} marginTop={1} paddingLeft={2}>
508 <Text color={VIOLET}>✦</Text>
509 <Text bold>
510 {[...word].map((ch, i) => (
511 <Text key={`w-${i}`} color={rainbow((i / Math.max(1, word.length)) * 300)}>
512 {ch}
513 </Text>
514 ))}
515 </Text>
516 <Text dimColor>{`in ${fmtDuration(e.props.durationMs)}${at === null ? '' : ` · ${fmtClock(at)}`}`}</Text>
517 </Box>
518 )
519 } catch {
520 return next(e)
521 }
522 })
523}
524types/index.d.ts 9 lines1export type Part = 'tools' | 'spinner' | 'prompts' | 'footer' | 'rule'
2export type Settings = Record<Part, boolean>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'transcript-fx': { settings: Settings; lastEnd: { at: number; durationMs: number } | null }
7 }
8}
9