SLOPSHOPPER

transcript-fx

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

newbandspinnerrowscommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · transcript-fx
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────────…╮ ││ fix the failing auth test and add an audit log call you ││ ││ ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────…╯ ⏺ Read(src/auth.ts) ⎿ Read 6 lines ✎ Edit src/auth.ts ✓ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✦ worked in 42s · 1:53 AM › /fx ⎿ transcript-fx: tools: on · spinner: on · prompts: on · footer: on · rule: on ╭──╮ ✻ Thinking… ╰──╯ ──────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
────────────────────────────────────────────────────────────────────────────────────────────────────
Spinner
╭──╮ ✻ Thinking… ╰──╯
Turn line
✦ worked in 42s · 1:53 AM
Your message
╭─────────────────────────────────────────────────────────────────────────────────────────────────── fix the failing auth test and add an audit log call you ╰───────────────────────────────────────────────────────────────────────────────────────────────────
Tool row
✎ Edit src/auth.ts ✓
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 524 lines
1import { 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}
524
types/index.d.ts 9 lines
1export 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