Cofoundy brand spinner for Claude Code (terminal and desktop app)

<img src="media/desktop-light.gif" alt="Desktop app, light theme" width="49%"> <img src="media/desktop-dark.gif" alt="Desktop app, dark theme" width="49%">
<h1 align="center">Cofoundy spinner for Claude Code</h1>
In the terminal. A 2×2 square of three cubes and a turning gap. One glint in Cofoundy's colors crosses the square and carries on through the word for what Claude is doing: «Mulling», «Connecting dots», «Cooking ideas», «Grinding», «Grepping the universe», «Orchestrating agents», «Hotfixing», «Spinning up subagents». In Ghostty or kitty run directly, your logo sits at the right edge of that row.
In the desktop app (Code tab). Your logo with a soft sweep every few seconds, and the word in the brand color, readable on light and dark themes.
At the end of each turn. Instead of «Baked for 12s», a word of yours: «Shipped», «Cooked», «Tokenmaxxed», «Vibecoded», «Deployed».
This folder is its own marketplace. Push it to a repo, then in Claude Code:
/plugin marketplace add <owner>/<repo>
/plugin install cofoundy-spinner@cofoundy
Try it without installing, from the folder that contains it:
claude --plugin-dir ./cofoundy-spinner
Open preview.html in a browser to see it moving before you install.
| Command | What it does |
|---|---|
/cofoundy-spinner off | Back to Claude Code's own spinner. Remembered across sessions. |
/cofoundy-spinner on | Turns it back on. |
Inside herdr, tmux or zellij the terminal logo doesn't show (they don't pass images through); the square and the word do.
Everything comes from mod.json in your brand package. Edit it and regenerate with the brand-mod skill:
python3 brand-mod.py path/to/mod.json --media --check
hooks/brand.ts is generated from it; don't edit it by hand.
Claude Code with mods (function hooks). The mods API is early access: if a Claude Code update changes it, the spinner falls back to Claude Code's own and nothing else breaks.
hooks/register.tsx 122 lines1// Branded spinner for Claude Code, generated by the brand-mod skill (cofoundy/brand-skills).
2// While Claude works: a turning 2×2 square of cubes + the word for the turn's phase, with one
3// brand-color glint crossing both; at the end of the turn, «<word> for 12s». In the desktop app,
4// the SVG logo with a soft sweep. /<brand>-spinner on|off turns it off, remembered across sessions.
5import type { Register } from 'claude-code'
6
7import { BRAND, DESKTOP_WORD, LOGO_PNG, LOGO_SVG } from './brand'
8import type { Cell } from './spinner'
9import { doneWord, elapsed, FRAME_MS, glintPos, hasGraphics, shimmerAt, spinnerWord, square } from './spinner'
10
11const MUTED = '#8A97A3'
12
13let on = true
14let graphics = false
15let startedAt = 0
16let seed = 0
17let timer: { cancel: () => void } | undefined
18
19const stop = () => {
20 timer?.cancel()
21 timer = undefined
22}
23
24function cells(Text: any, list: Cell[], prefix: string) {
25 return list.map((c, i) => (
26 <Text key={`${prefix}${i}`} color={c.color} backgroundColor={c.bg}>{c.ch}</Text>
27 ))
28}
29
30export const register: Register = hook => {
31 hook('session.start', async ($, e, next) => {
32 const started = await next(e)
33 try {
34 on = (await $.store.get('spinner')) !== 'off'
35 graphics = hasGraphics({
36 TMUX: await $.env.get('TMUX'),
37 STY: await $.env.get('STY'),
38 HERDR_ENV: await $.env.get('HERDR_ENV'),
39 ZELLIJ: await $.env.get('ZELLIJ'),
40 TERM_PROGRAM: await $.env.get('TERM_PROGRAM'),
41 KITTY_WINDOW_ID: await $.env.get('KITTY_WINDOW_ID'),
42 })
43 if (e.isInteractive) await $.command.register({ name: BRAND.command, description: `${BRAND.name} spinner: on | off` })
44 } catch {
45 // no store or no commands: the spinner stays on
46 }
47 return started
48 })
49
50 hook('prompt.submit', async ($, e, next) => {
51 if (on) {
52 try {
53 startedAt = await $.clock.now()
54 seed = Math.floor(Math.random() * 1000)
55 stop()
56 timer = $.clock.every(FRAME_MS, () => $.ui.invalidate('ui.render'))
57 } catch {
58 stop()
59 }
60 }
61 return next(e)
62 })
63
64 hook('turn.complete', async ($, e, next) => {
65 stop()
66 return next(e)
67 })
68
69 hook('session.end', async ($, e, next) => {
70 stop()
71 return next(e)
72 })
73
74 hook('command.run', { command: BRAND.command }, async ($, e) => {
75 const arg = e.args.trim().toLowerCase()
76 on = arg === 'on' ? true : arg === 'off' ? false : !on
77 await $.store.set('spinner', on ? 'on' : 'off').catch(() => undefined)
78 if (!on) stop()
79 return { text: on ? `${BRAND.name} spinner on: you will see it from your next turn.` : `${BRAND.name} spinner off: back to Claude Code's own.` }
80 })
81
82 hook('ui.render', { component: 'Spinner' }, async ($, e, next) => {
83 if (!on || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
84 const now = await $.clock.now()
85 const frame = Math.floor(now / FRAME_MS)
86 const word = e.props.message ?? spinnerWord(e.props.mode, seed)
87 const tail = `… ${startedAt ? elapsed(now - startedAt) : ''}`
88 const { Box, Image, Svg, Text } = $.ui.resolve(e) as any
89
90 // Desktop app: the SVG logo as an image (an interactive frame is rebuilt every frame and
91 // flickers) and the word in one color (the light glint tones are unreadable on a light theme).
92 if (e.surface === 'desktop') {
93 return (
94 <Box>
95 {LOGO_SVG ? <Svg key="logo" source={LOGO_SVG} alt={BRAND.name} width={16} height={16} /> : null}
96 <Text> </Text>
97 <Text color={DESKTOP_WORD} bold>{word}</Text>
98 <Text color={MUTED}>{tail}</Text>
99 </Box>
100 )
101 }
102
103 const pos = glintPos([...word].length, frame)
104 const logo = graphics && LOGO_PNG
105 return (
106 <Box>
107 {cells(Text, square(now, pos), 'i')}
108 <Text> </Text>
109 {cells(Text, shimmerAt(word, pos - 3), 'w')}
110 <Text color={MUTED}>{tail}</Text>
111 {logo ? <Box flexGrow={1} /> : null}
112 {logo ? <Image key="logo" source={{ png: LOGO_PNG }} columns={2} rows={1} alt=" " /> : null}
113 </Box>
114 )
115 })
116
117 hook('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
118 if (!on) return next(e)
119 return next({ ...e, props: { ...e.props, word: doneWord(e.props.durationMs) } })
120 })
121}
122hooks/brand.ts 19 lines1// GENERATED by skills/brand-mod/scripts/brand-mod.py from mod.json: do not edit by hand.
2export const BRAND = {"name": "Cofoundy", "slug": "cofoundy", "url": "cofoundy.dev", "command": "cofoundy-spinner"}
3
4// Brand scale, shadow to light: [0..2] darks, [3] primary, [4..6] lights, [7] white.
5export const STOPS = ["#091D26", "#103546", "#1A5671", "#2885AE", "#57A0C0", "#94C2D6", "#D4E7EF", "#FFFFFF"]
6
7// Word color in the desktop app: one color, readable on light and dark themes.
8export const DESKTOP_WORD = "#2984AD"
9
10// One word per turn phase (Spinner.props.mode).
11export const WORDS: Record<string, string[]> = {"thinking": ["Mulling", "Connecting dots", "Cooking ideas"], "tool-use": ["Grinding", "Grepping the universe", "Orchestrating agents", "Hotfixing"], "tool-input": ["Grinding", "Spinning up subagents"], "responding": ["Shipping", "Tokenmaxxing", "Vibecoding", "Cooking the PR"], "requesting": ["Warming up GPUs", "Waking the model", "Loading context"]}
12
13// End of turn: the engine draws «<word> for 12s».
14export const DONE = ["Shipped", "Cooked", "Tokenmaxxed", "Vibecoded", "Deployed"]
15
16// Desktop app logo (SVG with an SMIL sweep) and terminal logo (64 px PNG, base64).
17export const LOGO_SVG = "<svg width=\"701\" height=\"701\" viewBox=\"0 0 701 701\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><style>.bm-light{display:none}@media (prefers-color-scheme: dark){.bm-light{display:inline}.bm-color{display:none}}</style><g class=\"bm-color\">\n<path d=\"M653.012 426.724L516.512 347.79C510.318 344.208 502.682 344.208 496.488 347.79L359.988 426.724C356.86 428.533 354.34 431.117 352.613 434.153L496.5 517.227C499.594 519.013 503.047 519.906 506.5 519.906C509.953 519.906 513.406 519.013 516.5 517.227L660.387 434.153C658.661 431.117 656.14 428.533 653.012 426.724Z\" fill=\"#2885AE\"/>\n<path d=\"M663 601.962V444.038C663 440.511 662.071 437.117 660.387 434.153L516.5 517.227C513.406 519.013 509.953 519.906 506.5 519.906V700.897C509.957 700.897 513.415 700.001 516.512 698.21L653.012 619.276C659.193 615.701 663 609.103 663 601.962Z\" fill=\"#17597B\"/>\n<path d=\"M359.988 619.276L496.488 698.21C499.585 700.001 503.043 700.897 506.5 700.897V519.906C503.047 519.906 499.594 519.013 496.5 517.227L352.613 434.153C350.929 437.117 350 440.511 350 444.038V601.962C350 609.103 353.807 615.701 359.988 619.276Z\" fill=\"#0B3957\"/>\n<path d=\"M307.012 426.621L170.512 347.686C164.318 344.105 156.682 344.105 150.488 347.686L13.988 426.621C10.8596 428.43 8.3395 431.014 6.61323 434.05L150.5 517.123C153.594 518.91 157.047 519.803 160.5 519.803C163.953 519.803 167.406 518.91 170.5 517.123L314.387 434.05C312.661 431.014 310.14 428.43 307.012 426.621Z\" fill=\"#0B3957\"/>\n<path d=\"M317 601.859V443.935C317 440.408 316.071 437.013 314.387 434.05L170.5 517.123C167.406 518.91 163.953 519.803 160.5 519.803V700.793C163.957 700.793 167.415 699.898 170.512 698.107L307.012 619.173C313.193 615.598 317 608.999 317 601.859Z\" fill=\"#0B3049\"/>\n<path d=\"M13.988 619.173L150.488 698.107C153.585 699.898 157.043 700.793 160.5 700.793V519.803C157.047 519.803 153.594 518.91 150.5 517.123L6.61323 434.05C4.9286 437.013 4 440.408 4 443.935V601.859C4 608.999 7.80673 615.598 13.988 619.173Z\" fill=\"#072135\"/>\n<path d=\"M687 164.58C700.333 172.278 700.333 191.523 687 199.221L540 284.091C526.667 291.789 510 282.167 510 266.771V97.0298C510 81.6338 526.667 72.0113 540 79.7093L687 164.58Z\" fill=\"#354552\"/>\n<path d=\"M49 204.176C35.6667 211.874 35.6667 231.119 49 238.817L127.75 284.283C141.083 291.981 157.75 282.359 157.75 266.963V176.03C157.75 160.634 141.083 151.011 127.75 158.709L49 204.176Z\" fill=\"#354552\"/>\n<path d=\"M483 300.454V149.984V116.719C483 116.719 481 139.219 466.5 160.219C457.126 173.794 438.474 186.719 438.474 186.719L354.5 235.542C354.5 235.542 345.25 239.219 338.5 251.219C334 259.219 334 277.219 334 277.219V394.719C337.292 394.719 340.584 393.866 343.532 392.16L473.491 316.951C479.376 313.545 483 307.258 483 300.454Z\" fill=\"#072135\"/>\n<path d=\"M194.509 316.951L324.468 392.16C327.416 393.866 330.708 394.719 334 394.719V277.219C334 277.219 334 259.219 338.5 251.219C345.25 239.219 354.5 235.542 354.5 235.542L438.474 186.719C438.474 186.719 457.126 173.794 466.5 160.219C481 139.219 483 116.719 483 116.719V21.7188C483 21.7188 482.5 8.11886 471.5 3.71875C459 -1.28137 448 6.21883 448 6.21883L353 66.7191C353 66.7191 338.447 75.9747 332 86.7191C326 96.7191 322.5 104.43 322.5 116.719V211.219L209 146.219C209 146.219 199.778 140.219 192 145.219C185 149.719 185 155.359 185 158.719V300.454C185 307.258 188.624 313.545 194.509 316.951Z\" fill=\"#103D5C\"/>\n</g><svg class=\"bm-light\" x=\"0\" y=\"0\" width=\"701\" height=\"701\" viewBox=\"0 0 701 701\">\n<path d=\"M653.012 426.724L516.512 347.79C510.318 344.208 502.682 344.208 496.488 347.79L359.988 426.724C356.86 428.533 354.34 431.117 352.613 434.153L496.5 517.227C499.594 519.013 503.047 519.906 506.5 519.906C509.953 519.906 513.406 519.013 516.5 517.227L660.387 434.153C658.661 431.117 656.14 428.533 653.012 426.724Z\" fill=\"#E6E6E6\"/>\n<path d=\"M663 601.962V444.038C663 440.511 662.071 437.117 660.387 434.153L516.5 517.227C513.406 519.013 509.953 519.906 506.5 519.906V700.897C509.957 700.897 513.415 700.001 516.512 698.21L653.012 619.276C659.193 615.701 663 609.103 663 601.962Z\" fill=\"#C4C4C4\"/>\n<path d=\"M359.988 619.276L496.488 698.21C499.585 700.001 503.043 700.897 506.5 700.897V519.906C503.047 519.906 499.594 519.013 496.5 517.227L352.613 434.153C350.929 437.117 350 440.511 350 444.038V601.962C350 609.103 353.807 615.701 359.988 619.276Z\" fill=\"#9F9F9F\"/>\n<path d=\"M307.012 426.621L170.512 347.686C164.318 344.105 156.682 344.105 150.488 347.686L13.988 426.621C10.8596 428.43 8.3395 431.014 6.61323 434.05L150.5 517.123C153.594 518.91 157.047 519.803 160.5 519.803C163.953 519.803 167.406 518.91 170.5 517.123L314.387 434.05C312.661 431.014 310.14 428.43 307.012 426.621Z\" fill=\"#CACACA\"/>\n<path d=\"M317 601.859V443.935C317 440.408 316.071 437.013 314.387 434.05L170.5 517.123C167.406 518.91 163.953 519.803 160.5 519.803V700.793C163.957 700.793 167.415 699.898 170.512 698.107L307.012 619.173C313.193 615.598 317 608.999 317 601.859Z\" fill=\"#A7A7A7\"/>\n<path d=\"M13.988 619.173L150.488 698.107C153.585 699.898 157.043 700.793 160.5 700.793V519.803C157.047 519.803 153.594 518.91 150.5 517.123L6.61323 434.05C4.9286 437.013 4 440.408 4 443.935V601.859C4 608.999 7.80673 615.598 13.988 619.173Z\" fill=\"#848484\"/>\n<path d=\"M687 164.58C700.333 172.278 700.333 191.523 687 199.221L540 284.091C526.667 291.789 510 282.167 510 266.771V97.0298C510 81.6338 526.667 72.0113 540 79.7093L687 164.58Z\" fill=\"#A7A7A7\"/>\n<path d=\"M49 204.176C35.6667 211.874 35.6667 231.119 49 238.817L127.75 284.283C141.083 291.981 157.75 282.359 157.75 266.963V176.03C157.75 160.634 141.083 151.011 127.75 158.709L49 204.176Z\" fill=\"#A7A7A7\"/>\n<path d=\"M483 300.454V149.984V116.719C483 116.719 481 139.219 466.5 160.219C457.126 173.794 438.474 186.719 438.474 186.719L354.5 235.542C354.5 235.542 345.25 239.219 338.5 251.219C334 259.219 334 277.219 334 277.219V394.719C337.292 394.719 340.584 393.866 343.532 392.16L473.491 316.951C479.376 313.545 483 307.258 483 300.454Z\" fill=\"#A7A7A7\"/>\n<path d=\"M194.509 316.951L324.468 392.16C327.416 393.866 330.708 394.719 334 394.719V277.219C334 277.219 334 259.219 338.5 251.219C345.25 239.219 354.5 235.542 354.5 235.542L438.474 186.719C438.474 186.719 457.126 173.794 466.5 160.219C481 139.219 483 116.719 483 116.719V21.7188C483 21.7188 482.5 8.11886 471.5 3.71875C459 -1.28137 448 6.21883 448 6.21883L353 66.7191C353 66.7191 338.447 75.9747 332 86.7191C326 96.7191 322.5 104.43 322.5 116.719V211.219L209 146.219C209 146.219 199.778 140.219 192 145.219C185 149.719 185 155.359 185 158.719V300.454C185 307.258 188.624 313.545 194.509 316.951Z\" fill=\"#CACACA\"/>\n</svg><defs><linearGradient id=\"bm-sweep\" x1=\"0\" y1=\"0\" x2=\"1\" y2=\"0.45\"><stop offset=\"0.3\" stop-color=\"#fff\" stop-opacity=\"0\"/><stop offset=\"0.5\" stop-color=\"#fff\" stop-opacity=\"1\"/><stop offset=\"0.7\" stop-color=\"#fff\" stop-opacity=\"0\"/><animateTransform attributeName=\"gradientTransform\" type=\"translate\" values=\"-1.2 0;-1.2 0;1.2 0;1.2 0\" keyTimes=\"0;0.15;0.65;1\" dur=\"3.2s\" repeatCount=\"indefinite\"/></linearGradient><mask id=\"bm-mask\" maskUnits=\"userSpaceOnUse\" x=\"0\" y=\"0\" width=\"701\" height=\"701\"><rect x=\"0\" y=\"0\" width=\"701\" height=\"701\" fill=\"url(#bm-sweep)\"/></mask></defs><g mask=\"url(#bm-mask)\" opacity=\"0.45\">\n<path d=\"M653.012 426.724L516.512 347.79C510.318 344.208 502.682 344.208 496.488 347.79L359.988 426.724C356.86 428.533 354.34 431.117 352.613 434.153L496.5 517.227C499.594 519.013 503.047 519.906 506.5 519.906C509.953 519.906 513.406 519.013 516.5 517.227L660.387 434.153C658.661 431.117 656.14 428.533 653.012 426.724Z\" fill=\"#FFFFFF\"/>\n<path d=\"M663 601.962V444.038C663 440.511 662.071 437.117 660.387 434.153L516.5 517.227C513.406 519.013 509.953 519.906 506.5 519.906V700.897C509.957 700.897 513.415 700.001 516.512 698.21L653.012 619.276C659.193 615.701 663 609.103 663 601.962Z\" fill=\"#FFFFFF\"/>\n<path d=\"M359.988 619.276L496.488 698.21C499.585 700.001 503.043 700.897 506.5 700.897V519.906C503.047 519.906 499.594 519.013 496.5 517.227L352.613 434.153C350.929 437.117 350 440.511 350 444.038V601.962C350 609.103 353.807 615.701 359.988 619.276Z\" fill=\"#FFFFFF\"/>\n<path d=\"M307.012 426.621L170.512 347.686C164.318 344.105 156.682 344.105 150.488 347.686L13.988 426.621C10.8596 428.43 8.3395 431.014 6.61323 434.05L150.5 517.123C153.594 518.91 157.047 519.803 160.5 519.803C163.953 519.803 167.406 518.91 170.5 517.123L314.387 434.05C312.661 431.014 310.14 428.43 307.012 426.621Z\" fill=\"#FFFFFF\"/>\n<path d=\"M317 601.859V443.935C317 440.408 316.071 437.013 314.387 434.05L170.5 517.123C167.406 518.91 163.953 519.803 160.5 519.803V700.793C163.957 700.793 167.415 699.898 170.512 698.107L307.012 619.173C313.193 615.598 317 608.999 317 601.859Z\" fill=\"#FFFFFF\"/>\n<path d=\"M13.988 619.173L150.488 698.107C153.585 699.898 157.043 700.793 160.5 700.793V519.803C157.047 519.803 153.594 518.91 150.5 517.123L6.61323 434.05C4.9286 437.013 4 440.408 4 443.935V601.859C4 608.999 7.80673 615.598 13.988 619.173Z\" fill=\"#FFFFFF\"/>\n<path d=\"M687 164.58C700.333 172.278 700.333 191.523 687 199.221L540 284.091C526.667 291.789 510 282.167 510 266.771V97.0298C510 81.6338 526.667 72.0113 540 79.7093L687 164.58Z\" fill=\"#FFFFFF\"/>\n<path d=\"M49 204.176C35.6667 211.874 35.6667 231.119 49 238.817L127.75 284.283C141.083 291.981 157.75 282.359 157.75 266.963V176.03C157.75 160.634 141.083 151.011 127.75 158.709L49 204.176Z\" fill=\"#FFFFFF\"/>\n<path d=\"M483 300.454V149.984V116.719C483 116.719 481 139.219 466.5 160.219C457.126 173.794 438.474 186.719 438.474 186.719L354.5 235.542C354.5 235.542 345.25 239.219 338.5 251.219C334 259.219 334 277.219 334 277.219V394.719C337.292 394.719 340.584 393.866 343.532 392.16L473.491 316.951C479.376 313.545 483 307.258 483 300.454Z\" fill=\"#FFFFFF\"/>\n<path d=\"M194.509 316.951L324.468 392.16C327.416 393.866 330.708 394.719 334 394.719V277.219C334 277.219 334 259.219 338.5 251.219C345.25 239.219 354.5 235.542 354.5 235.542L438.474 186.719C438.474 186.719 457.126 173.794 466.5 160.219C481 139.219 483 116.719 483 116.719V21.7188C483 21.7188 482.5 8.11886 471.5 3.71875C459 -1.28137 448 6.21883 448 6.21883L353 66.7191C353 66.7191 338.447 75.9747 332 86.7191C326 96.7191 322.5 104.43 322.5 116.719V211.219L209 146.219C209 146.219 199.778 140.219 192 145.219C185 149.719 185 155.359 185 158.719V300.454C185 307.258 188.624 313.545 194.509 316.951Z\" fill=\"#FFFFFF\"/>\n</g></svg>"
18export const LOGO_PNG = "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"
19hooks/spinner.ts 56 lines1// Pure spinner logic (no `$`): rendering lives in register.tsx.
2// Icon: a square 2×2 grid of half blocks (▀ ▄), three cubes and a hole turning clockwise; each
3// character carries two cubes (top = color, bottom = bg). One glint crosses the square diagonally
4// and carries on through the word. Why this and not something else: the brand-mod skill's method.md.
5import { DONE, STOPS, WORDS } from './brand'
6
7export type Cell = { ch: string; color: string; bg?: string }
8
9export const FRAME_MS = 100 // the engine redraws the spinner at most 10 times a second
10export const HOLE_MS = 150
11const BASE = 3
12const tone = (i: number): string => STOPS[Math.max(0, Math.min(STOPS.length - 1, i))] ?? '#888888'
13const lit = (distance: number) => tone(Math.max(BASE, STOPS.length - 1 - Math.round(distance * 1.4)))
14
15// The glint crosses the square (columns -3 and -2) and carries on through the word (column 0 on;
16// -1 is the space), 1.5 columns per frame, then rests for 10 columns.
17export function glintPos(wordLength: number, frame: number): number {
18 return (Math.floor(frame * 1.5) % (wordLength + 3 + 10)) - 5
19}
20
21// Hole, clockwise: 0 top-left → 1 top-right → 2 bottom-right → 3 bottom-left.
22export function square(now: number, pos: number): Cell[] {
23 const hole = Math.floor(now / HOLE_MS) % 4
24 const cube = (x: number, y: number) => lit(Math.abs(x - 3 + 0.5 * y - pos))
25 const column = (x: number, up: number, down: number): Cell => {
26 if (hole === up) return { ch: '▄', color: cube(x, 1) }
27 if (hole === down) return { ch: '▀', color: cube(x, 0) }
28 return { ch: '▀', color: cube(x, 0), bg: cube(x, 1) }
29 }
30 return [column(0, 0, 3), column(1, 1, 2)]
31}
32
33export function shimmerAt(text: string, pos: number): Cell[] {
34 return [...text].map((ch, i) => ({ ch, color: lit(Math.abs(i - pos)) }))
35}
36
37export function spinnerWord(mode: string, seed: number): string {
38 const pool = WORDS[mode] ?? WORDS.responding ?? ['Working']
39 return pool[seed % pool.length] ?? 'Working'
40}
41
42export function doneWord(durationMs: number): string {
43 return DONE[durationMs % DONE.length] ?? 'Done'
44}
45
46export function elapsed(ms: number): string {
47 const s = Math.max(0, Math.floor(ms / 1000))
48 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
49}
50
51// Kitty graphics: Ghostty or kitty run directly; tmux, screen, herdr and zellij don't pass them through.
52export function hasGraphics(env: Record<string, string | undefined>): boolean {
53 if (env.TMUX || env.STY || env.HERDR_ENV || env.ZELLIJ) return false
54 return env.TERM_PROGRAM === 'ghostty' || Boolean(env.KITTY_WINDOW_ID)
55}
56