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

| Mod | What it does |
|---|---|
| token-buddy | A cute critter in a side panel showing how much context, 5-hour and weekly usage you have left. /tokens reopens it. |
| my-questions | A side panel listing every question you asked in the session; click one to jump to it in the chat. /questions reopens it. |
| maryville-theme | Maryville 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. |
| clipboard | A 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. |
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.
hooks/register.tsx 404 lines1import { 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}
404hooks/palette.ts 256 lines1/**
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}
256types/index.d.ts 21 lines1export 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