SLOPSHOPPER

maryville-theme

Maryville University colors for Claude Code: tool rows, edit cards and your messages drawn in maroon and red, plus matching terminal themes, switched with…

bandspinnerrowsguardcommand
v0.2.2no licenseupdated 2026-10-16Mengchutsai/mod/maryville-theme
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · maryville-theme
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ⏺ 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. ✻ Dreamed for 42s · done 4:20 PM › /maryville ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ The Maryville theme could not be applied: ENOENT: no such file /plugins/maryville-theme/themes/maryville.json. │ │ Pick "Maryville" in /theme instead. │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ✻ Soaring… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Tool row
✓ ✎ Edit …/src/auth.ts
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ The Maryville theme could not be applied: ENOENT: no such file │ │ /plugins/maryville-theme/themes/maryville.json. Pick "Maryville" in /theme instead. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

Claude Code mods

ModWhat it does
token-buddyA cute critter in a side panel showing how much context, 5-hour and weekly usage you have left. /tokens reopens it.
my-questionsA side panel listing every question you asked in the session; click one to jump to it in the chat. /questions reopens it.
maryville-themeMaryville University maroon and red in the transcript, in the terminal and the desktop app: tool rows with icons and timings, edit diff cards, shell output cards, your own messages in a red frame, brand spinner words. /maryville (dark) or /maryville light also installs a matching terminal theme into ~/.claude/themes/; /maryville off restores Claude Code's own drawing, /maryville band toggles a usage band above the prompt, /maryville status reports what it is drawing. In the desktop app, text follows the app's own light or dark appearance.
clipboardA Clipboard side panel for snippets you want to hold on to across sessions, one list per project folder. A red keep button under every reply files that reply; the panel also keeps Claude's last answer or a note you type, and each clip has copy and delete links. /keep <text> files any text, /keep answer the last reply, /keep export writes everything to clipboard.md in the project folder, /keep clear empties it; /clipboard reopens the panel. In the terminal, Keep selection files whatever you highlighted with the mouse.

Install

At the prompt of a Claude Code terminal session:

/plugin install token-buddy --marketplace Mengchutsai/mod
/plugin install my-questions --marketplace Mengchutsai/mod
/plugin install maryville-theme --marketplace Mengchutsai/mod
/plugin install clipboard --marketplace Mengchutsai/mod

Answer y to add the marketplace, then pick a scope.

From a shell:

claude plugin install token-buddy --marketplace Mengchutsai/mod
claude plugin install my-questions --marketplace Mengchutsai/mod
claude plugin install maryville-theme --marketplace Mengchutsai/mod
claude plugin install clipboard --marketplace Mengchutsai/mod

In the desktop app: click + next to the prompt box → Plugins → Add marketplace with Mengchutsai/mod → Add plugin.

Source 3 files
hooks/register.tsx 404 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
3
4import type { Usage } from '../types'
5import {
6  DONE_WORDS,
7  ICONS,
8  SPINNER_WORDS,
9  paletteFor,
10  diffCounts,
11  diffSource,
12  fold,
13  formatMs,
14  hunksOf,
15  kindOf,
16  label,
17  meter,
18  modeOf,
19  summarize,
20  wordFor,
21} from './palette'
22import type { Mode } from './palette'
23
24const COMMAND = 'maryville'
25const THEMES = { dark: 'maryville', light: 'maryville-light' } as const
26type Variant = keyof typeof THEMES
27
28const FOLD_LINES = 12
29
30const mode = atom({ plugin: 'maryville-theme', key: 'mode' } as const, 'dark' as Mode)
31const isOn = atom({ plugin: 'maryville-theme', key: 'isOn' } as const, true)
32const band = atom({ plugin: 'maryville-theme', key: 'band' } as const, false)
33const usage = atom({ plugin: 'maryville-theme', key: 'usage' } as const, null as Usage | null)
34
35/** Which variant the person asked for: `light` or anything else means dark. */
36export function pick(args: string): Variant {
37  return args.trim().toLowerCase() === 'light' ? 'light' : 'dark'
38}
39
40/** The theme preference stored for a user theme file `~/.claude/themes/<slug>.json`. */
41export function preference(variant: Variant): string {
42  return `custom:${THEMES[variant]}`
43}
44
45/**
46 * The user settings file with its `theme` set to `value`, every other key
47 * kept. An absent file becomes `{ "theme": value }`; a file that is not a
48 * JSON object throws, so the caller leaves it alone.
49 */
50export function withTheme(settings: string | undefined, value: string): string {
51  const parsed: unknown = settings === undefined || settings.trim() === '' ? {} : JSON.parse(settings)
52  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
53    throw new Error('settings.json is not a JSON object')
54  }
55  return JSON.stringify({ ...(parsed as Record<string, unknown>), theme: value }, null, 2) + '\n'
56}
57
58function toUsage(context: SessionContextUsage, limits: readonly SessionRateLimit[]): Usage {
59  return {
60    tokens: context.tokens,
61    window: context.window,
62    percent: context.percent,
63    limits: limits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
64  }
65}
66
67const LIMIT_NAMES: Record<string, string> = {
68  five_hour: '5h',
69  seven_day: 'week',
70  spend_limit: 'spend',
71}
72
73/** When each tool call started and ended, for the elapsed time on its row. */
74const timings = new Map<string, { start: number; end?: number }>()
75
76/** How often each drawing hook ran since the module loaded, per surface: `/maryville status` reports it. */
77const fired: Record<string, number> = {}
78function count(component: string, surface: string, outcome: 'drew' | 'passed'): void {
79  const key = `${component}@${surface}:${outcome}`
80  fired[key] = (fired[key] ?? 0) + 1
81}
82
83export const register: Register = on => {
84  on('session.start', async ($, e, next) => {
85    await $.command.register({
86      name: COMMAND,
87      description: 'Maryville colors: dark | light | on | off | band | status',
88      argumentHint: 'dark | light | on | off | band | status',
89    })
90
91    const theme = (await $.config.list()).find(row => row.key === 'theme')
92    await update($, mode, () => modeOf(theme?.value))
93    const storedOn = await $.store.get('isOn')
94    if (typeof storedOn === 'boolean') await update($, isOn, () => storedOn)
95    const storedBand = await $.store.get('band')
96    if (typeof storedBand === 'boolean') await update($, band, () => storedBand)
97
98    const u = await $.session.usage()
99    await update($, usage, () => toUsage(u.context, u.rateLimits))
100
101    return next(e)
102  })
103
104  on('session.measure', async ($, e, next) => {
105    await update($, usage, () => toUsage(e.context, e.rateLimits))
106    return next(e)
107  })
108
109  // Follow the theme when the person changes it in /theme or /config.
110  on('config.set', { key: 'theme' }, async ($, e, next) => {
111    const result = await next(e)
112    if (!('deny' in result && result.deny)) await update($, mode, () => modeOf(e.value))
113    return result
114  })
115
116  on('tool.call', async ($, e, next) => {
117    const start = await $.clock.now()
118    timings.set(e.tool_use_id, { start })
119    const result = await next(e)
120    timings.set(e.tool_use_id, { start, end: await $.clock.now() })
121    return result
122  })
123
124  on('command.run', { command: COMMAND }, async ($, e) => {
125    const arg = e.args.trim().toLowerCase()
126
127    if (arg === 'status') {
128      const surfaces = (await $.session.surfaces()).join(', ') || 'none'
129      const counts = Object.entries(fired)
130        .sort(([a], [b]) => a.localeCompare(b))
131        .map(([k, n]) => `${k} ${n}`)
132        .join('\n')
133      return {
134        text: [
135          `surfaces: ${surfaces}`,
136          `mode: ${await read($, mode)}, drawing: ${(await read($, isOn)) ? 'on' : 'off'}, band: ${(await read($, band)) ? 'on' : 'off'}`,
137          'drawing hooks asked since load:',
138          counts || '(none yet)',
139        ].join('\n'),
140      }
141    }
142
143    if (arg === 'off' || arg === 'on') {
144      const value = arg === 'on'
145      await update($, isOn, () => value)
146      await $.store.set('isOn', value)
147      return { text: value ? 'Maryville drawing is on.' : "Maryville drawing is off; Claude Code's own drawing is back. /maryville on restores it." }
148    }
149
150    if (arg === 'band') {
151      const value = !(await read($, band))
152      await update($, band, () => value)
153      await $.store.set('band', value)
154      return { text: value ? 'Usage band above the prompt is on.' : 'Usage band above the prompt is off.' }
155    }
156
157    const variant = pick(arg)
158    const slug = THEMES[variant]
159    await update($, mode, () => variant)
160    await update($, isOn, () => true)
161    await $.store.set('isOn', true)
162
163    const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
164    if (!home) return { text: `Maryville ${variant} drawing is on, but your home folder was not found, so the terminal theme was not installed.` }
165
166    // Plugin themes are read-only and keyed by plugin; a copy in the person's
167    // own themes folder is what `custom:<slug>` selects, so install it there.
168    const theme = await $.fs.read(`${$.plugin.root}/themes/${slug}.json`)
169    await $.fs.write(`${home}/.claude/themes/${slug}.json`, theme)
170
171    // The /config theme row takes only the built-in names, so the custom
172    // preference goes into settings.json directly, as /theme itself stores it.
173    const settingsPath = `${home}/.claude/settings.json`
174    const current = await $.fs.read(settingsPath).catch(() => undefined)
175    let updated: string
176    try {
177      updated = withTheme(current, preference(variant))
178    } catch {
179      return { text: `Maryville ${variant} drawing is on. ~/.claude/settings.json could not be parsed, so the terminal theme was left alone; pick "Maryville" in /theme.` }
180    }
181    await $.fs.write(settingsPath, updated)
182
183    return { text: `Maryville ${variant} is on: tool rows, edit cards and your messages are drawn in brand colors here, and the terminal theme is set in ~/.claude/settings.json. /maryville off restores Claude Code's own drawing.` }
184  }).catch((_$, _e, next) => ({
185    text: `The Maryville theme could not be applied: ${next.error.message ?? next.error.kind}. Pick "Maryville" in /theme instead.`,
186  }))
187
188  // --- the transcript -------------------------------------------------------
189
190  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
191    const kind = kindOf(e.props.tool)
192    if (!(await read($, isOn)) || kind === null) {
193      count('ToolUse', e.surface, 'passed')
194      return next(e)
195    }
196    count('ToolUse', e.surface, 'drew')
197
198    const p = paletteFor(await read($, mode), e.surface)
199    const { Box, Text } = $.ui.resolve(e)
200    const { isRunning, isErrored, isInterrupted } = e.props
201    const state = isInterrupted ? { glyph: '⊘', color: p.warn } : isErrored ? { glyph: '✗', color: p.err } : isRunning ? { glyph: '◌', color: p.accent } : { glyph: '✓', color: p.ok }
202    const target = summarize(e.props.tool, e.props.input)
203    const timing = timings.get(e.props.tool_use_id)
204    const elapsed = timing?.end !== undefined ? formatMs(timing.end - timing.start) : ''
205    const hunks = kind === 'write' ? hunksOf(e.props.output) : []
206    const counts = hunks.length ? diffCounts(hunks) : null
207
208    return (
209      <Box key="tool-row" flexDirection="row" columnGap={1}>
210        <Text color={state.color}>{state.glyph}</Text>
211        <Text color={p[kind]} bold>
212          {ICONS[kind]} {label(e.props.tool)}
213        </Text>
214        {target !== '' && (
215          <Text color={p.muted} wrap="truncate-end">
216            {target}
217          </Text>
218        )}
219        {counts !== null && (counts.added > 0 || counts.removed > 0) && (
220          <Text>
221            <Text color={p.ok}>+{counts.added}</Text> <Text color={p.err}>−{counts.removed}</Text>
222          </Text>
223        )}
224        {elapsed !== '' && <Text color={p.muted} dimColor>{elapsed}</Text>}
225      </Box>
226    )
227  })
228
229  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
230    const kind = kindOf(e.props.tool)
231    if (!(await read($, isOn)) || kind === null) {
232      count('ToolResult', e.surface, 'passed')
233      return next(e)
234    }
235    count('ToolResult', e.surface, 'drew')
236
237    const p = paletteFor(await read($, mode), e.surface)
238    const tint = p.surface ? { backgroundColor: p.surface } : {}
239    const { Box, Text, Code } = $.ui.resolve(e)
240
241    if (e.props.isErrored) {
242      const text = typeof e.props.output === 'string' ? e.props.output : JSON.stringify(e.props.output)
243      const { lines, hidden } = fold(text ?? '', FOLD_LINES)
244      return (
245        <Box key="error-card" flexDirection="column" borderStyle="round" borderColor={p.err} paddingX={1}>
246          {lines.map((line, i) => (
247            <Text key={`e${i}`} color={p.err}>
248              {line}
249            </Text>
250          ))}
251          {hidden > 0 && <Text color={p.muted}>… {hidden} more lines</Text>}
252        </Box>
253      )
254    }
255
256    if (kind === 'write') {
257      const hunks = hunksOf(e.props.output)
258      const out = (typeof e.props.output === 'object' && e.props.output !== null ? e.props.output : {}) as {
259        filePath?: string
260        type?: string
261        content?: string
262        staged?: boolean
263      }
264      const path = out.filePath ?? summarize(e.props.tool, undefined)
265      if (hunks.length === 0) {
266        const lines = typeof out.content === 'string' ? out.content.split('\n').length : 0
267        return (
268          <Box key="write-card" flexDirection="row" columnGap={1}>
269            <Text color={p.ok}>{out.type === 'create' ? 'created' : 'wrote'}</Text>
270            <Text color={p.muted}>{path}</Text>
271            {lines > 0 && <Text color={p.muted} dimColor>{lines} lines</Text>}
272            {out.staged && <Text color={p.warn}>held for review</Text>}
273          </Box>
274        )
275      }
276      const { added, removed } = diffCounts(hunks)
277      return (
278        <Box key="diff-card" flexDirection="column" borderStyle="round" borderColor={p.accent}>
279          <Box key="diff-head" flexDirection="row" columnGap={1} paddingX={1} {...tint}>
280            <Text color={p.accent} bold>
281              {ICONS.write} {path}
282            </Text>
283            <Text color={p.ok}>+{added}</Text>
284            <Text color={p.err}>−{removed}</Text>
285            {out.staged && <Text color={p.warn}>held for review</Text>}
286          </Box>
287          <Code source={diffSource(hunks)} format="diff" path={typeof path === 'string' ? path : undefined} />
288        </Box>
289      )
290    }
291
292    if (kind === 'run') {
293      const out = (typeof e.props.output === 'object' && e.props.output !== null ? e.props.output : {}) as {
294        stdout?: string
295        stderr?: string
296        interrupted?: boolean
297        backgroundTaskId?: string
298      }
299      const so = fold(out.stdout ?? '', FOLD_LINES)
300      const se = fold(out.stderr ?? '', FOLD_LINES)
301      if (so.lines.length === 0 && se.lines.length === 0 && !out.backgroundTaskId) {
302        return <Text key="shell-empty" color={p.muted} dimColor>(no output)</Text>
303      }
304      return (
305        <Box key="shell-card" flexDirection="column" borderStyle="single" borderColor={p.muted} borderDimColor paddingX={1}>
306          {out.backgroundTaskId && <Text color={p.accentSoft}>running in the background ({out.backgroundTaskId})</Text>}
307          {so.lines.map((line, i) => (
308            <Text key={`o${i}`} color={p.fg}>
309              {line}
310            </Text>
311          ))}
312          {so.hidden > 0 && <Text color={p.muted}>… {so.hidden} more lines</Text>}
313          {se.lines.map((line, i) => (
314            <Text key={`s${i}`} color={p.err}>
315              {line}
316            </Text>
317          ))}
318          {se.hidden > 0 && <Text color={p.err} dimColor>… {se.hidden} more lines of stderr</Text>}
319          {out.interrupted && <Text color={p.warn}>interrupted</Text>}
320        </Box>
321      )
322    }
323
324    // Reads, searches, web and MCP results keep Claude Code's own drawing.
325    return next(e)
326  })
327
328  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
329    // The person's own words arrive as `composer` at the terminal, `bridge`
330    // from Remote Control and `sdk` from the desktop app; rows that carry a
331    // task or another party's message keep Claude Code's own drawing.
332    const { kind } = e.props.origin
333    const isTheirs = kind === 'composer' || kind === 'bridge' || kind === 'sdk' || kind === 'unclassified'
334    if (!(await read($, isOn)) || e.props.task || e.props.from || !isTheirs) {
335      count('UserMessage', e.surface, 'passed')
336      return next(e)
337    }
338    count('UserMessage', e.surface, 'drew')
339
340    // Let the plugins beneath see the row (my-questions records prompts from
341    // it) before this tree replaces what they and the engine would draw.
342    await next(e)
343
344    const p = paletteFor(await read($, mode), e.surface)
345    const tint = p.surface ? { backgroundColor: p.surface } : {}
346    const { Box, Text } = $.ui.resolve(e)
347    return (
348      <Box key="you" paddingX={1} borderStyle="round" borderColor={p.accent} {...tint}>
349        <Text color={p.fg} wrap="wrap">
350          {e.props.text}
351        </Text>
352      </Box>
353    )
354  })
355
356  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
357    count('Spinner', e.surface, 'passed')
358    if (e.surface !== 'terminal' || e.props.message !== null || !(await read($, isOn))) return next(e)
359    return next({ ...e, props: { ...e.props, word: wordFor(SPINNER_WORDS, e.requestId) } })
360  })
361
362  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
363    if (!(await read($, isOn))) return next(e)
364    return next({ ...e, props: { ...e.props, word: wordFor(DONE_WORDS, e.requestId) } })
365  })
366
367  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
368    if (e.props.hasSurvey || !(await read($, isOn)) || !(await read($, band))) return next(e)
369    const u = await read($, usage)
370    if (!u || u.percent === undefined) return next(e)
371
372    const p = paletteFor(await read($, mode), e.surface)
373    const { Box, Text } = $.ui.resolve(e)
374    const used = u.percent
375    const color = used >= 90 ? p.err : used >= 70 ? p.warn : p.accent
376    return (
377      <Box key="band" flexDirection="row" columnGap={2} paddingX={1} borderStyle="round" borderColor={p.accent}>
378        <Text>
379          <Text color={color}>{meter(used)}</Text> <Text color={p.muted}>context {100 - used}% left</Text>
380        </Text>
381        {u.limits.map(l => (
382          <Text key={`limit-${l.kind}`} color={p.muted}>
383            {LIMIT_NAMES[l.kind] ?? l.kind} {Math.max(0, Math.round(100 - l.percentUsed))}% left
384          </Text>
385        ))}
386      </Box>
387    )
388  })
389
390  on('ui.render', { component: 'CommandOutput', props: { command: COMMAND } }, async ($, e, next) => {
391    if (e.props.isErrored) return next(e)
392    count('CommandOutput', e.surface, 'drew')
393    const p = paletteFor(await read($, mode), e.surface)
394    const { Box, Text } = $.ui.resolve(e)
395    return (
396      <Box key="mu-output" paddingX={1} borderStyle="round" borderColor={p.accent}>
397        <Text color={p.fg} wrap="wrap">
398          {e.props.text}
399        </Text>
400      </Box>
401    )
402  })
403}
404
hooks/palette.ts 256 lines
1/**
2 * The Maryville look: two palettes, the tool kinds they color, and the pure
3 * helpers the drawing hooks use. Nothing here touches `$`.
4 */
5
6export type Mode = 'dark' | 'light'
7export type Kind = 'read' | 'write' | 'run' | 'search' | 'web' | 'mcp'
8
9export type Palette = {
10  /** Brand red: the rail, the prompt marker, the running state. */
11  accent: string
12  /** A lighter red for shimmer and secondary accents. */
13  accentSoft: string
14  /** Brand maroon, used on light backgrounds where the red is too bright. */
15  maroon: string
16  /** Body text: a raw color, or the theme key `text` where the surface knows its own background. */
17  fg: string
18  muted: string
19  /** The tint behind the person's own messages and card headers; absent where the background is unknown. */
20  surface?: string
21  ok: string
22  err: string
23  warn: string
24  read: string
25  write: string
26  run: string
27  search: string
28  web: string
29  mcp: string
30}
31
32export const PALETTES: Record<Mode, Palette> = {
33  dark: {
34    accent: '#ce2431',
35    accentSoft: '#e8616b',
36    maroon: '#94151e',
37    fg: '#e6e6e6',
38    muted: '#9a9b9d',
39    surface: '#2b1114',
40    ok: '#7fb069',
41    err: '#ff6b6b',
42    warn: '#e9b949',
43    read: '#c4c5c7',
44    write: '#e8616b',
45    run: '#ce2431',
46    search: '#e0b04f',
47    web: '#8fb3d9',
48    mcp: '#c39bd3',
49  },
50  light: {
51    accent: '#94151e',
52    accentSoft: '#ce2431',
53    maroon: '#94151e',
54    fg: '#262626',
55    muted: '#5a5a5a',
56    surface: '#f6e7e8',
57    ok: '#2f7d32',
58    err: '#b3261e',
59    warn: '#9a6700',
60    read: '#5a5a5a',
61    write: '#ce2431',
62    run: '#94151e',
63    search: '#8a5a00',
64    web: '#1f5f9e',
65    mcp: '#6b3fa0',
66  },
67}
68
69/**
70 * The palette for a surface whose background a mod cannot know: the desktop
71 * app follows its own light or dark appearance, not the Claude Code theme
72 * setting. Text takes the surface's own `text` and `inactive` colors, and
73 * every accent is a mid-tone that reads on white and on near-black alike.
74 */
75export const NEUTRAL: Palette = {
76  accent: '#ce2431',
77  accentSoft: '#d9535e',
78  maroon: '#94151e',
79  fg: 'text',
80  muted: 'inactive',
81  ok: '#2e9e4f',
82  err: '#d3372f',
83  warn: '#c08a00',
84  read: '#7a7c80',
85  write: '#d9535e',
86  run: '#ce2431',
87  search: '#b8860b',
88  web: '#3b82c4',
89  mcp: '#8e5bb5',
90}
91
92/** The palette a drawing uses: the mode's on the terminal, the neutral one elsewhere. */
93export function paletteFor(mode: Mode, surface: string): Palette {
94  return surface === 'terminal' ? PALETTES[mode] : NEUTRAL
95}
96
97/** Which palette a Claude Code theme preference calls for. */
98export function modeOf(theme: unknown): Mode {
99  return typeof theme === 'string' && theme.includes('light') ? 'light' : 'dark'
100}
101
102const KINDS: Record<string, Kind> = {
103  Read: 'read',
104  NotebookRead: 'read',
105  Edit: 'write',
106  MultiEdit: 'write',
107  Write: 'write',
108  NotebookEdit: 'write',
109  Bash: 'run',
110  PowerShell: 'run',
111  Glob: 'search',
112  Grep: 'search',
113  WebFetch: 'web',
114  WebSearch: 'web',
115}
116
117/**
118 * The kind a tool row is colored by, or null for the rows left to Claude Code
119 * (agents, plan mode, questions, todo lists: they have live progress and
120 * dialogs of their own).
121 */
122export function kindOf(tool: string): Kind | null {
123  if (tool.startsWith('mcp__')) return 'mcp'
124  return KINDS[tool] ?? null
125}
126
127export const ICONS: Record<Kind, string> = {
128  read: '◇',
129  write: '✎',
130  run: '❯',
131  search: '⌕',
132  web: '⟡',
133  mcp: '⚙',
134}
135
136/** `mcp__github__search_code` reads as `github:search_code`; others as named. */
137export function label(tool: string): string {
138  if (!tool.startsWith('mcp__')) return tool
139  const [, server, ...rest] = tool.split('__')
140  return rest.length ? `${server}:${rest.join('_')}` : server
141}
142
143const MAX_TARGET = 120
144
145function firstString(input: Record<string, unknown>): string {
146  for (const value of Object.values(input)) {
147    if (typeof value === 'string' && value.trim() !== '') return value
148  }
149  return ''
150}
151
152function oneLine(text: string): string {
153  const flat = text.replace(/\s+/g, ' ').trim()
154  return flat.length > MAX_TARGET ? flat.slice(0, MAX_TARGET - 1) + '…' : flat
155}
156
157/** Shortens an absolute path to its last two segments. */
158export function shortPath(path: string): string {
159  const parts = path.split(/[\\/]+/).filter(Boolean)
160  return parts.length > 2 ? '…/' + parts.slice(-2).join('/') : parts.join('/')
161}
162
163/** The one thing worth a glance about a call: its file, command, pattern or query. */
164export function summarize(tool: string, input: unknown): string {
165  const fields: Record<string, unknown> =
166    typeof input === 'object' && input !== null && !Array.isArray(input) ? (input as Record<string, unknown>) : {}
167  const str = (key: string) => (typeof fields[key] === 'string' ? (fields[key] as string) : '')
168  switch (tool) {
169    case 'Bash':
170    case 'PowerShell':
171      return oneLine(str('command'))
172    case 'Read':
173    case 'Edit':
174    case 'MultiEdit':
175    case 'Write':
176      return shortPath(str('file_path'))
177    case 'NotebookEdit':
178    case 'NotebookRead':
179      return shortPath(str('notebook_path'))
180    case 'Glob':
181      return oneLine(str('pattern'))
182    case 'Grep':
183      return oneLine(str('path') ? `${str('pattern')} in ${shortPath(str('path'))}` : str('pattern'))
184    case 'WebFetch':
185      return oneLine(str('url').replace(/^https?:\/\//, ''))
186    case 'WebSearch':
187      return oneLine(str('query'))
188    default:
189      return oneLine(firstString(fields))
190  }
191}
192
193export type Hunk = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
194
195/** The hunks an Edit or Write result carries, as one unified diff `Code` draws. */
196export function diffSource(hunks: readonly Hunk[]): string {
197  return hunks
198    .map(h => `@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n${h.lines.join('\n')}`)
199    .join('\n')
200}
201
202export function diffCounts(hunks: readonly Hunk[]): { added: number; removed: number } {
203  let added = 0
204  let removed = 0
205  for (const h of hunks) {
206    for (const line of h.lines) {
207      if (line.startsWith('+')) added += 1
208      else if (line.startsWith('-')) removed += 1
209    }
210  }
211  return { added, removed }
212}
213
214/** Reads the hunks off a tool result, or none when it carries no patch. */
215export function hunksOf(output: unknown): Hunk[] {
216  if (typeof output !== 'object' || output === null) return []
217  const patch = (output as { structuredPatch?: unknown }).structuredPatch
218  if (!Array.isArray(patch)) return []
219  return patch.filter(
220    (h): h is Hunk =>
221      typeof h === 'object' && h !== null && Array.isArray((h as Hunk).lines) && typeof (h as Hunk).oldStart === 'number',
222  )
223}
224
225/** The first `max` lines of a text and how many it left out. */
226export function fold(text: string, max: number): { lines: string[]; hidden: number } {
227  const all = text.replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n')
228  if (all.length === 1 && all[0] === '') return { lines: [], hidden: 0 }
229  return { lines: all.slice(0, max), hidden: Math.max(0, all.length - max) }
230}
231
232export function formatMs(ms: number): string {
233  if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`
234  if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`
235  const m = Math.floor(ms / 60_000)
236  return `${m}m ${Math.round((ms % 60_000) / 1000)}s`
237}
238
239/** "We Power Dreams, Not Just Degrees": the words the spinner and turn line use. */
240export const SPINNER_WORDS = ['Powering', 'Dreaming', 'Rallying', 'Charting', 'Climbing', 'Blazing', 'Soaring'] as const
241export const DONE_WORDS = ['Powered', 'Dreamed', 'Rallied', 'Charted', 'Climbed', 'Soared'] as const
242
243/** One word per seed, stable for the seed, so a turn keeps its word. */
244export function wordFor(words: readonly string[], seed: string): string {
245  let h = 0
246  for (const ch of seed) h = (h * 31 + ch.charCodeAt(0)) >>> 0
247  return words[h % words.length]
248}
249
250/** A 10-cell meter of `percent` filled. */
251export function meter(percent: number, width = 10): string {
252  const p = Math.max(0, Math.min(100, percent))
253  const filled = Math.round((p / 100) * width)
254  return '█'.repeat(filled) + '░'.repeat(width - filled)
255}
256
types/index.d.ts 21 lines
1export type Usage = {
2  tokens?: number
3  window: number
4  percent?: number
5  limits: { kind: string; percentUsed: number; resetsAt?: string }[]
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'maryville-theme': {
11      /** Which palette the drawings use, from the Claude Code theme setting. */
12      mode: 'dark' | 'light'
13      /** Whether the mod draws at all; off restores Claude Code's own drawing. */
14      isOn: boolean
15      /** Whether the usage band above the prompt is drawn. */
16      band: boolean
17      usage: Usage | null
18    }
19  }
20}
21