Pixel-art pomodoro timer in a side pane: /focus [minutes]

A few mods for Claude Code, packaged as a plugin marketplace.
| Mod | What it does |
|---|---|
| pomodoro | A pixel-art pomodoro timer in a side pane. Day and night skies, animated sun, clouds, stars and meteors, a grass field and a daily tomato tally. Toast and chime when a phase ends. |
| agent-progress | Live Powerline-style progress bars for running subagents, with a moving glint and the model each one runs on. |
| cmd-guard | Stops destructive shell commands (rm -rf on /, ~ or *, force pushes, hard resets, dropping tables, mkfs, dd…) and asks what to do: refuse, allow once, trust for this session, or use a safer alternative. |
| model-router | Runs Explore subagents on haiku and general-purpose subagents on sonnet to save cost. An explicit model on the Agent call always wins; forks and workflows are left alone. Optionally lets Jev pick the model per task. |
<img src="docs/pomodoro-focus.png" alt="Pomodoro focus mode: day sky with sun and clouds" width="280"> <img src="docs/pomodoro-break.png" alt="Pomodoro break mode: night sky with moon and stars" width="280">
claude plugin marketplace add redjackfred/claude-code-mods
claude plugin install pomodoro@claude-code-mods
claude plugin install agent-progress@claude-code-mods
claude plugin install cmd-guard@claude-code-mods
claude plugin install model-router@claude-code-mods
Install only the ones you want. Restart Claude Code afterwards.
/focus [minutes] start a focus session (default 25)
/focus skip skip to the next phase
/focus stop stop the timer
/focus show|hide show or hide the side pane
afplay, so sound is macOS only.
/agent-progress toggles the bars on and off.

Works automatically; /guard off turns it off for the session and /guard on back on. When nobody answers the prompt (or it fails), the command is blocked.
The dialog speaks the language you mostly write in during the session: English, or Traditional Chinese (繁體中文).
It matches commands with regexes, not a shell parser, so treat it as a seatbelt against slips rather than a sandbox: a determined command can still get past it. It also can't tell running a command from mentioning one, so a commit message or heredoc that only quotes a risky command gets stopped too. Reword the text, or /guard off for a moment.
<img src="docs/model-router.png" alt="model-router toasts: Explore routed to haiku, general-purpose to sonnet" width="360">
Works automatically and shows a toast for each subagent it reroutes. To steer the main agent, you can add this to your ~/.claude/CLAUDE.md:
A model-router mod runs Explore subagents on haiku and general-purpose ones on sonnet
unless the Agent call names a model. For complex reasoning, large refactors, or
hard-to-find bugs, pass `model: "opus"` on the Agent call.
Jev is a fast "System One" decision model. With it on, each general-purpose subagent's task is classified as haiku, sonnet or opus work before it starts. In a quick test it took about 0.7 s per call and costs well under a cent.
export TYPESAFE_API_KEY=... # then restart Claude Code
/router jev on # /router jev off to stop
general-purpose → haiku · jev 1.00.Each mod is a Claude Code plugin with TypeScript hooks in hooks/.
claude plugin validate . # the marketplace
claude plugin validate pomodoro # one plugin
claude plugin test pomodoro # its tests
MIT
hooks/register.tsx 253 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { PomodoroDay, PomodoroTimer } from '../types'
5import { CONTENT_ROWS, layout } from './pane'
6import type { View } from './pane'
7import type { Mood } from './pixel'
8
9const timer = atom({ plugin: 'pomodoro', key: 'timer' } as const, null)
10const now = atom({ plugin: 'pomodoro', key: 'now' } as const, 0)
11const day = atom({ plugin: 'pomodoro', key: 'day' } as const, { date: '', count: 0 })
12const paneShown = atom({ plugin: 'pomodoro', key: 'paneShown' } as const, false)
13
14const PANE = 'pomodoro'
15const FRAME_MS = 500
16const WIDTH = 20
17const C = {
18 red: '#f38ba8', green: '#a6e3a1', peach: '#fab387', lavender: '#b4befe',
19 base: '#1e1e2e', overlay: '#45475a', text: '#cdd6f4', sub: '#9399b2', dim: '#585b70',
20}
21const SEP = ''
22const CAP = ''
23
24// ponytail: local date via the runtime's timezone; fine for one person on one machine
25export const today = (t: number) => new Date(t).toLocaleDateString('sv-SE')
26// break is a fifth of the focus (min 5); every 4th is a long one, three times as long
27export const breakMin = (focusMin: number, count: number) =>
28 Math.max(5, Math.round(focusMin / 5)) * (count > 0 && count % 4 === 0 ? 3 : 1)
29export const clock = (ms: number) => {
30 const s = Math.max(0, Math.ceil(ms / 1000))
31 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
32}
33export const moodOf = (cur: PomodoroTimer, t: number): Mood =>
34 cur.phase === 'break' ? (t - cur.start < 4000 ? 'cheer' : 'sleep') : cur.ms - (t - cur.start) <= 60_000 ? 'hurry' : 'focus'
35
36let ticker: Timer | undefined
37
38const loadDay = async ($: EngineInterface, t: number): Promise<PomodoroDay> => {
39 const saved = (await $.store.get('day')) as PomodoroDay | undefined
40 return saved && saved.date === today(t) ? saved : { date: today(t), count: 0 }
41}
42
43const finish = async ($: EngineInterface, cur: PomodoroTimer, t: number) => {
44 if (cur.phase === 'focus') {
45 const d = await loadDay($, t)
46 const next = { ...d, count: d.count + 1 }
47 await $.store.set('day', next)
48 await update($, day, () => next)
49 const rest = breakMin(cur.focusMin, next.count)
50 await update($, timer, () => ({ phase: 'break', start: t, ms: rest * 60_000, focusMin: cur.focusMin }))
51 $.ui.toast(`🍅 專注完成!今天第 ${next.count} 個。休息 ${rest} 分鐘 ☕`, { timeoutMs: 8000 })
52 void $.audio.play({ asset: 'sounds/focus-done.wav' }).catch(() => {})
53 } else {
54 await update($, timer, () => null)
55 $.ui.toast(`☕ 休息結束,/focus 開始下一輪`, { timeoutMs: 8000 })
56 void $.audio.play({ asset: 'sounds/break-done.wav' }).catch(() => {})
57 }
58}
59
60const tick = ($: EngineInterface) => {
61 if (ticker) return
62 ticker = $.clock.every(FRAME_MS, async () => {
63 const cur = await read($, timer)
64 const t = await $.clock.now()
65 await update($, now, () => t)
66 if (!cur) {
67 ticker?.cancel()
68 ticker = undefined
69 return
70 }
71 if (t - cur.start >= cur.ms) await finish($, cur, t)
72 })
73}
74
75const showPane = async ($: EngineInterface) => {
76 const { isPlaced } = await $.ui.open({ id: PANE, title: '🍅 Pomodoro', columns: 36 })
77 await update($, paneShown, () => isPlaced)
78}
79
80export const register: Register = on => {
81 on('session.start', async ($, e, next) => {
82 await $.command.register({ name: 'focus', description: 'Pomodoro: /focus [minutes] · skip · stop · show · hide' })
83 const d = await loadDay($, await $.clock.now())
84 await update($, day, () => d)
85 if (await read($, timer)) tick($)
86 return next(e)
87 })
88
89 on('command.run', { command: 'focus' }, async ($, e) => {
90 const arg = e.args.trim()
91 const t = await $.clock.now()
92 const cur = await read($, timer)
93
94 if (arg === 'stop') {
95 await update($, timer, () => null)
96 await $.ui.close({ id: PANE })
97 return { text: cur ? '🍅 番茄鐘已停止(這輪不計入)。' : '🍅 目前沒有番茄鐘在跑。' }
98 }
99 if (arg === 'skip') {
100 if (!cur) return { text: '🍅 目前沒有番茄鐘在跑。' }
101 await finish($, cur, t)
102 return { text: cur.phase === 'focus' ? '🍅 已跳到休息。' : '☕ 已結束休息。' }
103 }
104 if (arg === 'show') {
105 await showPane($)
106 return { text: '🍅 面板已開啟。' }
107 }
108 if (arg === 'hide') {
109 await $.ui.close({ id: PANE })
110 return { text: '🍅 面板已收起,倒數改顯示在輸入框上方。' }
111 }
112
113 const min = arg === '' ? 25 : Number(arg)
114 if (!Number.isFinite(min) || min < 1 || min > 180) {
115 return { text: '用法:/focus [1-180 分鐘,預設 25] · skip · stop · show · hide' }
116 }
117 await update($, timer, () => ({ phase: 'focus', start: t, ms: min * 60_000, focusMin: min }))
118 await update($, now, () => t)
119 const d = await loadDay($, t)
120 await update($, day, () => d)
121 tick($)
122 await showPane($)
123 return { text: `🍅 開始專注 ${min} 分鐘,休息 ${breakMin(min, 1)} 分鐘。` }
124 })
125
126 on('ui.close', { id: PANE }, async ($, e, next) => {
127 await update($, paneShown, () => false)
128 return next(e)
129 })
130
131 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
132 const { Box, Text } = $.ui.resolve(e)
133 const cur = await read($, timer)
134 const t = Math.max(await read($, now), cur?.start ?? 0)
135 const d = await read($, day)
136 const frame = Math.floor(t / FRAME_MS)
137 const rows = Math.max(CONTENT_ROWS, Math.min(e.props.scroll.bodyRows, 60))
138
139 // xterm-256 colors for the digits and bar on the grass
140 const ink = {
141 focus: { time: '#ffffd7', shadow: '#005f00', hurry: '#ffaf5f', bar: ['#ff5f5f', '#ffaf5f'] as const },
142 night: { time: '#d7ffff', shadow: '#000000', hurry: '#ffaf5f', bar: ['#5fd7d7', '#af87d7'] as const },
143 }
144 // badges and strips, per mode, tuned to the sky (title) and grass (today) behind them
145 const badge = {
146 focus: { title: ['#d70000', '#ffffff', '#870000', '#ffd7d7'], dot: '#87af5f', count: '#d7ffaf', footer: '#005f00', icon: '#ffffd7', word: '#afd787', sep: '#5f875f' },
147 night: { title: ['#afafff', '#121212', '#5f5faf', '#ffffff'], dot: '#008787', count: '#87afaf', footer: '#121212', icon: '#87d7ff', word: '#87afaf', sep: '#585858' },
148 } as const
149 type Badge = (typeof badge)['focus' | 'night']
150 // today's tally against a daily goal, in pixels: label above, mini tomatoes below
151 const GOAL = 8
152 const todayRow = (b: Badge) => ({
153 done: Math.min(d.count, GOAL),
154 goal: GOAL,
155 label: d.count > GOAL ? `TODAY +${d.count - GOAL}` : `TODAY ${d.count}/${GOAL}`,
156 color: b.count,
157 empty: b.dot,
158 })
159 // the bottom strip: each command with its icon, quiet separators between
160 const footer = (b: Badge, cmds: [string, string][]) => ({
161 bg: b.footer,
162 spans: [
163 { text: '/focus ', color: b.sep },
164 ...cmds.flatMap(([icon, word], i) => [
165 { text: `${icon} `, color: b.icon },
166 { text: word, color: b.word },
167 ...(i < cmds.length - 1 ? [{ text: ' · ', color: b.sep }] : []),
168 ]),
169 ],
170 })
171 let view: View
172 if (!cur) {
173 const b = badge.night
174 view = {
175 mood: 'sleep',
176 night: true,
177 title: { label: 'POMODORO', bg: b.title[0], fg: b.title[1], valueBg: b.title[2], valueFg: b.title[3] },
178 today: todayRow(b),
179 footer: footer(b, [['\uf04b', '25 min'], ['\uf013', 'any length']]),
180 }
181 } else {
182 const focus = cur.phase === 'focus'
183 const k = focus ? ink.focus : ink.night
184 const b = focus ? badge.focus : badge.night
185 const mood = moodOf(cur, t)
186 view = {
187 mood,
188 night: !focus,
189 title: {
190 label: focus ? 'FOCUS' : 'BREAK',
191 value: String(Math.round(cur.ms / 60_000)),
192 bg: b.title[0], fg: b.title[1], valueBg: b.title[2], valueFg: b.title[3],
193 },
194 time: { text: clock(cur.ms - (t - cur.start)), color: mood === 'hurry' && frame % 2 === 0 ? k.hurry : k.time, shadow: k.shadow },
195 bar: { ratio: (t - cur.start) / cur.ms, fill: k.bar },
196 today: todayRow(b),
197 footer: footer(b, [['\uf051', 'skip'], ['\uf04d', 'stop'], ['\uf070', 'hide']]),
198 }
199 }
200
201 return (
202 <Box flexDirection="column">
203 {layout(view, e.props.bodyColumns, rows, frame).map(row =>
204 row.kind === 'pixels' ? (
205 <Text>{row.runs.map(r => <Text color={r.fg} backgroundColor={r.bg}>{r.text}</Text>)}</Text>
206 ) : (
207 <Text backgroundColor={row.bg}>
208 {row.spans.map(sp => <Text color={sp.color} backgroundColor={sp.bg ?? row.bg} bold={sp.bold}>{sp.text}</Text>)}
209 </Text>
210 ),
211 )}
212 </Box>
213 )
214 })
215
216 // one-line fallback above the prompt while the pane is not on screen
217 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
218 const cur = await read($, timer)
219 if (e.props.hasSurvey || !cur || (await read($, paneShown))) return next(e)
220
221 const { Box, Text } = $.ui.resolve(e)
222 const t = Math.max(await read($, now), cur.start)
223 const d = await read($, day)
224 const left = cur.ms - (t - cur.start)
225 const done = Math.round(((t - cur.start) / cur.ms) * WIDTH)
226 const focus = cur.phase === 'focus'
227 const accent = focus ? C.red : C.green
228 const below = await next(e)
229
230 return (
231 <Box flexDirection="column">
232 <Text wrap="truncate-end">
233 <Text color={accent}>{CAP}</Text>
234 <Text color={C.base} backgroundColor={accent} bold>{focus ? ' 🍅 FOCUS ' : ' ☕ BREAK '}</Text>
235 <Text color={accent} backgroundColor={C.overlay}>{SEP}</Text>
236 <Text backgroundColor={C.overlay}>
237 {' '}
238 {Array.from({ length: WIDTH }, (_, i) => (
239 <Text color={i < done ? accent : C.dim}>{i < done ? '▰' : '▱'}</Text>
240 ))}
241 <Text color={C.text} bold> {clock(left)} </Text>
242 </Text>
243 <Text color={C.overlay} backgroundColor={C.base}>{SEP}</Text>
244 <Text color={C.peach} backgroundColor={C.base}> 今日 🍅×{d.count} </Text>
245 <Text color={C.base}>{SEP}</Text>
246 <Text color={C.sub}> /focus show 開啟面板</Text>
247 </Text>
248 {below}
249 </Box>
250 )
251 })
252}
253hooks/pane.ts 142 lines1// Lays out the whole pane as one pixel canvas: sky above, grass below the
2// tomato, and text rows drawn on the ground's own color so nothing breaks it.
3import {
4 badgeWidth, digitsWidth, GRASS, grassAt, mergeRuns, MINI_W, paintBadge, paintBar, paintDecor, paintDigits, paintFireflies, paintGrass, paintTally, paintTomato,
5 sky, SKIES, skyAt, skyCell, toCells, TOMATO_SIZE,
6} from './pixel'
7import type { Badge, Grid, Mood, Px, Run } from './pixel'
8import { sextantCell, skyRows } from './skyhd'
9
10export type Span = { text: string; color?: string; bg?: string; bold?: boolean }
11
12// a rounded badge: Powerline caps in the badge's color over the row's own background
13export const pill = (text: string, bg: string, color: string, bold = true): Span[] => [
14 { text: '\ue0b6', color: bg },
15 { text: ` ${text} `, color, bg, bold },
16 { text: '\ue0b4', color: bg },
17]
18export type Row = { kind: 'pixels'; runs: Run[] } | { kind: 'text'; spans: Span[]; bg: string }
19
20export type View = {
21 mood: Mood
22 night: boolean
23 title: Badge
24 time?: { text: string; color: string; shadow: string }
25 bar?: { ratio: number; fill: readonly [string, string] }
26 // the daily tally: a pixel-font label over a row of mini tomatoes
27 today: { done: number; goal: number; label: string; color: string; empty: string }
28 // the pane's last row: a full-width strip in its own color
29 footer?: { spans: Span[]; bg: string }
30}
31
32export const CONTENT_ROWS = 26
33const GROUND_ROWS = 3
34const BAR_W = 26
35
36// terminal cells a string takes: CJK and emoji are two wide
37export const cells = (s: string) =>
38 [...s].reduce((n, ch) => {
39 const cp = ch.codePointAt(0)!
40 // Nerd Font glyphs live in the private use areas and are one cell wide
41 const pua = (cp >= 0xe000 && cp <= 0xf8ff) || cp >= 0xf0000
42 return n + (!pua && (cp >= 0x2e80 || (cp >= 0x2600 && cp <= 0x27bf) || cp >= 0x1f000) ? 2 : 1)
43 }, 0)
44
45const centered = (spans: Span[], width: number): Span[] => {
46 const used = spans.reduce((n, s) => n + cells(s.text), 0)
47 const left = Math.max(0, Math.floor((width - used) / 2))
48 return [{ text: ' '.repeat(left) }, ...spans, { text: ' '.repeat(Math.max(0, width - used - left)) }]
49}
50
51// the colors a drawn cell shows at its top and bottom halves
52const halves = (c: Run): [string, string] => {
53 const solid = c.bg ?? c.fg ?? '#000000'
54 if (c.text === '▀') return [c.fg!, c.bg ?? c.fg!]
55 if (c.text === '▄') return [c.bg ?? c.fg!, c.fg!]
56 return [solid, solid]
57}
58
59// draw at sextant resolution (2×3 sub-pixels per cell) over `n` cell rows from
60// `row`: cells the drawing touches become sextant glyphs over what was beneath
61const overlay = (cells: Run[][], row: number, n: number, w: number, draw: (sub: Grid) => void) => {
62 const sub: Grid = Array.from({ length: n * 3 }, () => Array<Px>(w * 2).fill(null))
63 draw(sub)
64 for (let r = 0; r < n; r++)
65 for (let cx = 0; cx < w; cx++) {
66 const mine = [0, 1, 2].flatMap(dy => [0, 1].map(dx => sub[r * 3 + dy]![cx * 2 + dx]!))
67 const under = cells[row + r]?.[cx]
68 if (!under || mine.every(c => c === null)) continue
69 const [topC, bottomC] = halves(under)
70 cells[row + r]![cx] = sextantCell(mine.map((c, k) => c ?? (k < 4 ? topC : bottomC)))
71 }
72}
73
74export const layout = (view: View, width: number, rows: number, frame: number): Row[] => {
75 const w = Math.max(width, 28)
76 const h = rows * 2
77 const skyColors = view.night ? SKIES.night : SKIES.focus
78 const grass = view.night ? GRASS.night : GRASS.focus
79
80 // cell rows
81 // content sits low, a little ground under it; every spare row goes to the sky
82 // `top` anchors the tomato; the pixel badge sits in the three rows above it
83 const top = Math.max(3, rows - CONTENT_ROWS - GROUND_ROWS + 3)
84 const titleRow = top - 3
85 const footerRow = view.footer ? rows - 1 : -1
86 // pixel rows
87 const tx = Math.floor((w - TOMATO_SIZE) / 2)
88 const ty = (top + 2) * 2
89 const horizon = ty + TOMATO_SIZE - 1
90 const g = sky(w, h)
91 const digitsY = horizon + 3
92 const barY = (top + 15) * 2
93 const barX = Math.floor((w - BAR_W) / 2)
94 const dw = view.time ? digitsWidth(view.time.text) : 0
95 const dx = Math.floor((w - dw) / 2)
96 // the badge (3 rows) and the TODAY label (2 rows) are drawn at sextant
97 // resolution on top of the canvas, so their pixel font is a size smaller
98 const labelRow = top + 17
99 const tallyY = (top + 19) * 2
100 const tallyW = view.today.goal * (MINI_W + 1) - 1
101 const tallyX = Math.floor((w - tallyW) / 2)
102
103 const overlayRows = [titleRow, titleRow + 1, titleRow + 2, labelRow, labelRow + 1]
104 const textPx = (y: number) => Math.floor(y / 2) === footerRow || overlayRows.includes(Math.floor(y / 2))
105 const busy = (x: number, y: number) =>
106 textPx(y) ||
107 (x >= tx - 3 && x <= tx + TOMATO_SIZE + 9 && y >= ty - 4 && y <= horizon) ||
108 (view.time !== undefined && x >= dx - 2 && x <= dx + dw + 1 && y >= digitsY - 2 && y <= digitsY + 7) ||
109 (view.bar !== undefined && x >= barX - 2 && x <= barX + BAR_W + 1 && y >= barY - 2 && y <= barY + 5) ||
110 (x >= tallyX - 2 && x <= tallyX + tallyW + 1 && y >= tallyY - 2 && y <= tallyY + 4)
111
112 paintGrass(g, horizon, frame, grass, busy)
113 paintDecor(g, horizon, view.night, (x, y) => !busy(x, y))
114 if (view.night) paintFireflies(g, frame, horizon, (x, y) => !busy(x, y))
115 paintTomato(g, view.mood, frame, tx, ty)
116 if (view.time) paintDigits(g, view.time.text, view.time.color, dx, digitsY, view.time.shadow)
117 if (view.bar) paintBar(g, barX, barY, BAR_W, view.bar.ratio, view.bar.fill, '#005f00', '#000000')
118 paintTally(g, view.today.done, view.today.goal, tallyX, tallyY, view.today.empty)
119
120 const bgOf = (row: number) => {
121 const y = row * 2
122 return y >= horizon ? grassAt(grass) : skyAt(y, horizon, skyColors)
123 }
124 const skyPx = (y: number) => skyAt(y, horizon, skyColors)
125 // the open sky above the title is drawn at sextant resolution
126 const hd = skyRows(w, titleRow, frame, view.night, skyColors, horizon)
127 const cells = toCells(g, skyPx, y => skyCell(y, horizon, skyColors))
128 overlay(cells, titleRow, 3, w, sub => {
129 const bw = badgeWidth(view.title)
130 paintBadge(sub, view.title, Math.floor((w * 2 - bw) / 4) * 2, 0)
131 })
132 overlay(cells, labelRow, 2, w, sub => {
133 const lw = digitsWidth(view.today.label)
134 paintDigits(sub, view.today.label, view.today.color, Math.floor((w * 2 - lw) / 4) * 2, 0)
135 })
136 return cells.map(mergeRuns).map((runs, row): Row => {
137 if (row < titleRow) return { kind: 'pixels', runs: hd[row]! }
138 if (row === footerRow && view.footer) return { kind: 'text', spans: centered(view.footer.spans, w), bg: view.footer.bg }
139 return { kind: 'pixels', runs }
140 })
141}
142hooks/pixel.ts 363 lines1// A tiny pixel canvas drawn with half blocks: each terminal cell holds two
2// pixels, the top one as the text color of ▀ and the bottom one as its background.
3
4export type Px = string | null
5export type Grid = Px[][]
6export type Run = { text: string; fg?: string; bg?: string }
7export type Mood = 'focus' | 'hurry' | 'sleep' | 'cheer'
8
9// every color here is an exact xterm-256 color: Claude Code draws in 256 colors,
10// so anything in between would be rounded to a neighbor and shift
11const PAL: Record<string, string> = {
12 g: '#008700', // stem, dark
13 G: '#5fd75f', // leaf
14 r: '#af0000', // tomato shade
15 R: '#d70000', // tomato
16 H: '#ffafaf', // highlight
17 W: '#ffffff', // eye white
18 K: '#121212', // pupil, mouth
19 P: '#ff87af', // blush
20 S: '#5fd7ff', // sweat
21 Y: '#ffd75f', // sparkle
22 Z: '#afafff', // sleepy z
23}
24
25// 16×16 tomato; rows 6-7 are the eyes, row 1 the leaves
26const TOMATO = [
27 '.......gg.......',
28 '....gGGggGGg....',
29 '...rRRgGGgRRr...',
30 '..rRRRRggRRRRr..',
31 '.rRHHRRRRRRRRRr.',
32 '.rRHRRRRRRRRRRr.',
33 'rRRRWWRRRRWWRRRr',
34 'rRRRWKRRRRWKRRRr',
35 'rRRRRRRRRRRRRRRr',
36 'rRPPRRKRRKRRPPRr',
37 'rRRRRRRKKRRRRRRr',
38 '.rRRRRRRRRRRRRr.',
39 '.rrRRRRRRRRRRrr.',
40 '..rrRRRRRRRRrr..',
41 '...rrrrrrrrrr...',
42 '................',
43]
44const LEAVES_SWAY = '...gGGGggGGGg...'
45const EYES_SHUT = ['rRRRRRRRRRRRRRRr', 'rRRRKKRRRRKKRRRr']
46const MOUTH_O = ['rRPPRRRKKRRRPPRr', 'rRRRRRRKKRRRRRRr']
47const ZED = ['1111', '0001', '0110', '1000', '1111']
48const ZED_SMALL = ['111', '011', '110', '111']
49export const TOMATO_SIZE = 16
50
51const FONT: Record<string, string[]> = {
52 '0': ['111', '101', '101', '101', '111'],
53 '1': ['010', '110', '010', '010', '111'],
54 '2': ['111', '001', '111', '100', '111'],
55 '3': ['111', '001', '111', '001', '111'],
56 '4': ['101', '101', '111', '001', '001'],
57 '5': ['111', '100', '111', '001', '111'],
58 '6': ['111', '100', '111', '101', '111'],
59 '7': ['111', '001', '010', '010', '010'],
60 '8': ['111', '101', '111', '101', '111'],
61 '9': ['111', '101', '111', '001', '111'],
62 ':': ['0', '1', '0', '1', '0'],
63 '/': ['001', '001', '010', '100', '100'],
64 '+': ['000', '010', '111', '010', '000'],
65 ' ': ['0', '0', '0', '0', '0'],
66 T: ['111', '010', '010', '010', '010'],
67 F: ['111', '100', '110', '100', '100'],
68 C: ['011', '100', '100', '100', '011'],
69 U: ['101', '101', '101', '101', '111'],
70 S: ['011', '100', '010', '001', '110'],
71 B: ['110', '101', '110', '101', '110'],
72 R: ['110', '101', '110', '101', '101'],
73 E: ['111', '100', '110', '100', '111'],
74 K: ['101', '101', '110', '101', '101'],
75 P: ['110', '101', '110', '100', '100'],
76 M: ['101', '111', '101', '101', '101'],
77 O: ['010', '101', '101', '101', '010'],
78 D: ['110', '101', '101', '101', '110'],
79 A: ['010', '101', '111', '101', '101'],
80 Y: ['101', '101', '010', '010', '010'],
81}
82
83// sky bands from the top of the pane down to the horizon (xterm-256 colors)
84export const SKIES = {
85 focus: ['#005faf', '#0087d7', '#0087ff', '#5fafff', '#87d7ff', '#afd7ff', '#d7ffff'],
86 night: ['#000000', '#080808', '#121212', '#1c1c1c', '#00005f'],
87} as const
88// [blade tip, field, field, tuft] (xterm-256 colors)
89export const GRASS = {
90 focus: ['#87d75f', '#5faf5f', '#5faf5f', '#5f875f'],
91 night: ['#00af87', '#005f5f', '#005f5f', '#005f00'],
92} as const
93
94export const mix = (a: string, b: string, t: number) =>
95 '#' + [1, 3, 5].map(i => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - t) + parseInt(b.slice(i, i + 2), 16) * t)
96 .toString(16).padStart(2, '0')).join('')
97
98// Claude Code draws in xterm's 256 colors here, so a smooth gradient would round
99// into hard steps. Plain sky cells are drawn with the font's shade glyphs instead
100// (░ ▒ ▓ over the band's color), which blend two palette colors at the font's own
101// fine stipple, five steps between each pair of bands; cells with something drawn
102// in them fall back to half-block pixels on the nearest band.
103const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5]
104export const bayer = (x: number, y: number) => (BAYER[(y % 4) * 4 + (x % 4)]! + 0.5) / 16
105
106// a pixel nothing has drawn on yet: still plain sky
107export const SKY = '~'
108
109const bandPos = (y: number, span: number, stops: readonly string[]) => {
110 const t = Math.min(1, Math.max(0, span > 1 ? y / (span - 1) : 0)) * (stops.length - 1)
111 const i = Math.min(stops.length - 2, Math.floor(t))
112 return { i, f: t - i }
113}
114
115// one flat color for a pixel or a whole text row: the nearest band
116export const skyAt = (y: number, span: number, stops: readonly string[]) => {
117 const { i, f } = bandPos(y, span, stops)
118 return stops[f >= 0.5 ? i + 1 : i]!
119}
120
121const SHADES = [' ', '░', '▒', '▓']
122// a plain sky cell for the cell row whose pixels are y and y + 1
123export const skyCell = (y: number, span: number, stops: readonly string[]): Run => {
124 const { i, f } = bandPos(y + 0.5, span, stops)
125 const level = Math.round(f * 4)
126 return level === 4 ? { text: ' ', bg: stops[i + 1]! } : level === 0 ? { text: ' ', bg: stops[i]! } : { text: SHADES[level]!, fg: stops[i + 1]!, bg: stops[i]! }
127}
128
129// nearest palette color, for colors made by mixing
130const CUBE = [0, 95, 135, 175, 215, 255]
131export const snap = (color: string) =>
132 '#' + [1, 3, 5].map(i => {
133 const v = parseInt(color.slice(i, i + 2), 16)
134 return CUBE.reduce((a, b) => (Math.abs(b - v) < Math.abs(a - v) ? b : a)).toString(16).padStart(2, '0')
135 }).join('')
136
137// a w×h canvas of plain sky, resolved to colors when it becomes runs
138export const sky = (w: number, h: number): Grid => Array.from({ length: h }, () => Array<Px>(w).fill(SKY))
139
140const stamp = (g: Grid, rows: string[], x: number, y: number, color?: string) =>
141 rows.forEach((row, dy) =>
142 [...row].forEach((ch, dx) => {
143 const c = color ? (ch === '1' ? color : null) : ch === '.' ? null : PAL[ch]!
144 const line = g[y + dy]
145 if (c && line && x + dx >= 0 && x + dx < line.length) line[x + dx] = c
146 }),
147 )
148
149// the tomato with its top-left at (tx, ty), and its mood's effects around it
150export const paintTomato = (g: Grid, mood: Mood, frame: number, tx: number, ty: number) => {
151 const art = [...TOMATO]
152 if (frame % 2 === 1) art[1] = LEAVES_SWAY
153 const blink = mood !== 'sleep' && frame % 12 === 0
154 if (mood === 'sleep' || blink) [art[6], art[7]] = EYES_SHUT as [string, string]
155 if (mood === 'cheer') [art[9], art[10]] = MOUTH_O as [string, string]
156 const bob = mood === 'cheer' && frame % 2 === 0 ? -1 : 0
157 stamp(g, art, tx, ty + bob)
158
159 if (mood === 'hurry') stamp(g, ['S', 'S'], tx + 16, ty + 1 + (frame % 3))
160 if (mood === 'cheer') {
161 const spots = frame % 2 === 0 ? [[-2, -1], [17, 1], [20, 11]] : [[-1, 11], [18, -2], [21, 5]]
162 for (const [dx, dy] of spots) stamp(g, ['Y'], tx + dx!, ty + dy!)
163 }
164 if (mood === 'sleep') {
165 const rise = frame % 6
166 stamp(g, ZED, tx + 18, ty + 6 - rise, PAL.Z)
167 if (rise > 1) stamp(g, ZED_SMALL, tx + 21, ty + 2 - rise, PAL.Z)
168 }
169}
170
171
172
173const hash = (x: number, y: number) => ((x * 73856093) ^ (y * 19349663)) >>> 0
174
175// grass from `horizon` down: swaying blades on the edge, a soil gradient, tufts and
176// flowers; pixels in `plain` stay flat soil so text and digits sit cleanly on them
177export const paintGrass = (g: Grid, horizon: number, frame: number, colors: readonly string[], plain: (x: number, y: number) => boolean) => {
178 const [tip, top, , tuft] = colors as [string, string, string, string]
179 const h = g.length
180 const w = g[0]?.length ?? 0
181 for (let y = horizon; y < h; y++) g[y]!.fill(top)
182 for (let x = 0; x < w; x++) {
183 const tall = hash(x, 1) % 3
184 const lean = (hash(x, 2) % 2 === 0) !== (frame % 4 < 2) ? 1 : 0
185 for (let k = 1; k <= tall; k++) {
186 const bx = x + (k === tall ? lean : 0)
187 if (g[horizon - k] && bx < w) g[horizon - k]![bx] = k === tall ? tip : top
188 }
189 if (g[horizon]) g[horizon]![x] = hash(x, 3) % 3 === 0 ? tip : top
190 }
191 // short two-pixel blades scattered through the field: a lit tip over a darker base
192 const lit = tip
193 for (let y = horizon + 3; y < h - 1; y++)
194 for (let x = 0; x < w; x++) {
195 if (hash(x, y) % 23 !== 0 || plain(x, y) || plain(x, y + 1)) continue
196 g[y]![x] = lit
197 g[y + 1]![x] = tuft
198 }
199}
200
201export const grassAt = (colors: readonly string[]) => colors[1]!
202
203// a 4px-tall rounded pixel progress bar: outline, a highlight row and a fill row
204export const paintBar = (g: Grid, x0: number, y0: number, width: number, ratio: number, fill: readonly [string, string], empty: string, outline: string) => {
205 const done = Math.round(Math.min(1, Math.max(0, ratio)) * (width - 2))
206 for (let x = 0; x < width; x++) {
207 const edge = x === 0 || x === width - 1
208 const line0 = g[y0]
209 const line3 = g[y0 + 3]
210 if (!edge && line0) line0[x0 + x] = outline
211 if (!edge && line3) line3[x0 + x] = outline
212 for (const dy of [1, 2]) {
213 const line = g[y0 + dy]
214 if (!line) continue
215 if (edge) line[x0 + x] = outline
216 else {
217 const i = x - 1
218 const c = i < done ? mix(fill[0], fill[1], done > 1 ? i / (done - 1) : 0) : empty
219 line[x0 + x] = snap(i < done && dy === 1 ? mix(c, '#ffffff', 0.35) : c)
220 }
221 }
222 }
223}
224
225// a rounded pixel badge: a label section and an optional darker value section,
226// text in the pixel font
227export type Badge = { label: string; value?: string; bg: string; fg: string; valueBg: string; valueFg: string }
228// sections are padded to even widths so, drawn from an even x at sextant resolution
229// (two sub-pixels per cell), every cell holds at most two colors and stays crisp
230const even = (n: number) => n + (n % 2)
231export const badgeWidth = (b: Badge) => even(digitsWidth(b.label) + 4) + (b.value ? even(digitsWidth(b.value) + 4) : 0)
232// 9 rows tall (exactly three cells at sextant resolution, so its edges fall on cell
233// edges): the text with two rows of padding above and below, corners rounded
234export const BADGE_H = 9
235export const paintBadge = (g: Grid, b: Badge, x0: number, y0: number) => {
236 const lw = even(digitsWidth(b.label) + 4)
237 const total = badgeWidth(b)
238 for (let y = 0; y < BADGE_H; y++)
239 for (let x = 0; x < total; x++) {
240 const corner = (x === 0 || x === total - 1) && (y === 0 || y === BADGE_H - 1)
241 if (!corner && g[y0 + y]?.[x0 + x] !== undefined) g[y0 + y]![x0 + x] = b.value && x >= lw ? b.valueBg : b.bg
242 }
243 paintDigits(g, b.label, b.fg, x0 + 2, y0 + 2)
244 if (b.value) paintDigits(g, b.value, b.valueFg, x0 + lw + 2, y0 + 2)
245}
246
247// a 3×4 tomato for the daily tally: lit when done, a faint silhouette when not
248const MINI = ['.G.', 'RRR', 'RRR', 'rrr']
249const MINI_SHAPE = MINI.map(r => r.replace(/[^.]/g, '1'))
250export const MINI_W = 3
251export const paintTally = (g: Grid, done: number, goal: number, x0: number, y0: number, empty: string) => {
252 for (let i = 0; i < goal; i++)
253 if (i < done) stamp(g, MINI, x0 + i * (MINI_W + 1), y0)
254 else stamp(g, MINI_SHAPE, x0 + i * (MINI_W + 1), y0, empty)
255}
256
257export const digitsWidth = (text: string) =>
258 [...text].reduce((n, ch) => n + (FONT[ch]?.[0]!.length ?? 0) + 1, -1)
259
260// digits with a 1px drop shadow straight below, like an old handheld's score
261export const paintDigits = (g: Grid, text: string, color: string, x0: number, y0: number, shadow?: string) => {
262 for (const [dx, c] of shadow ? ([[1, shadow], [0, color]] as const) : ([[0, color]] as const)) {
263 let x = x0 + dx
264 for (const ch of text) {
265 const glyph = FONT[ch]
266 if (!glyph) continue
267 stamp(g, glyph, x - dx, y0 + dx, c)
268 x += glyph[0]!.length + 1
269 }
270 }
271}
272
273// sprites: one char per pixel, '.' clear; colors from the sprite's own key
274type Sprite = { rows: string[]; key: Record<string, string> }
275const DECOR: Sprite[] = [
276 { rows: ['.p.', 'ppp', '.g.', 'gg.'], key: { p: '#ffafd7', g: '#008700' } }, // tulip
277 { rows: ['.w.', 'wyw', '.w.', '.g.'], key: { w: '#ffffff', y: '#ffd75f', g: '#008700' } }, // daisy
278 { rows: ['.MM.', 'MwMM', '.ss.'], key: { M: '#d70000', w: '#ffffff', s: '#ffffd7' } }, // mushroom
279 { rows: ['.oo', 'ooo'], key: { o: '#878787' } }, // rock
280 { rows: ['.y.', 'yoy', '.g.', '.g.'], key: { y: '#ffd75f', o: '#ff875f', g: '#008700' } }, // marigold
281]
282const NIGHT_KEY: Record<string, string> = { M: '#5fd7ff', w: '#d7ffff', g: '#005f5f', s: '#afafd7', o: '#5f5f87' } // mushrooms glow at night
283// moonlight: everything else shifts toward periwinkle and dims a little
284const moonlit = (c: string, ch: string) => NIGHT_KEY[ch] ?? snap(mix(mix(c, '#8fa0ff', 0.4), '#0b0b1a', 0.2))
285
286const fits = (g: Grid, sp: Sprite, x: number, y: number, free: (x: number, y: number) => boolean) =>
287 sp.rows.every((row, dy) => [...row].every((_, dx) => g[y + dy]?.[x + dx] !== undefined && free(x + dx, y + dy)))
288
289const draw = (g: Grid, sp: Sprite, x: number, y: number, tint?: (c: string, ch: string) => string) =>
290 sp.rows.forEach((row, dy) =>
291 [...row].forEach((ch, dx) => {
292 const line = g[y + dy]
293 const c = sp.key[ch]
294 if (c && line && x + dx >= 0 && x + dx < line.length) line[x + dx] = tint ? tint(c, ch) : c
295 }),
296 )
297
298
299
300// a sparse scatter of flowers, mushrooms and rocks across the field
301export const paintDecor = (g: Grid, horizon: number, night: boolean, free: (x: number, y: number) => boolean) => {
302 const h = g.length
303 const w = g[0]?.length ?? 0
304 const tint = night ? moonlit : undefined
305
306 // each decoration tries a few heights until one fits
307 const room = Math.max(1, h - horizon - 7)
308 for (let x = 2, i = 0; x < w - 3; i++) {
309 const sp = DECOR[hash(x, i) % DECOR.length]!
310 for (let k = 0; k < 6; k++) {
311 const y = horizon + 3 + (hash(i + k * 13, x) % room)
312 if (fits(g, sp, x, y, free)) {
313 draw(g, sp, x, y, tint)
314 break
315 }
316 }
317 x += 7 + (hash(i, 7) % 5)
318 }
319}
320
321// night only: a few fireflies that drift and blink just above the grass
322export const paintFireflies = (g: Grid, frame: number, horizon: number, free: (x: number, y: number) => boolean) => {
323 const w = g[0]?.length ?? 0
324 for (let i = 0; i < 4; i++) {
325 if ((frame + i * 2) % 5 > 2) continue
326 const x = (hash(i, 9) % w + Math.round(Math.sin((frame + i * 5) / 3) * 2) + w) % w
327 const y = horizon - 6 + (hash(i, 11) % 4) + (frame % 4 < 2 ? 0 : 1)
328 if (g[y] && free(x, y)) g[y]![x] = (frame + i) % 3 ? '#d7ff87' : '#ffffaf'
329 }
330}
331
332// one Run per cell, row by row: half blocks for drawn pixels; a cell of plain sky
333// becomes `skyCell(y)`, a plain sky pixel next to a drawn one `skyPx(y)`
334export const toCells = (g: Grid, skyPx: (y: number) => string = () => '#000000', skyCell?: (y: number) => Run): Run[][] => {
335 const out: Run[][] = []
336 for (let y = 0; y < g.length; y += 2)
337 out.push(
338 g[y]!.map((rawT, x) => {
339 const rawB = g[y + 1]?.[x] ?? null
340 const t = rawT === SKY ? skyPx(y) : rawT
341 const b = rawB === SKY ? skyPx(y + 1) : rawB
342 return rawT === SKY && rawB === SKY && skyCell ? { ...skyCell(y) }
343 : t && b ? { text: '▀', fg: t, bg: b }
344 : t ? { text: '▀', fg: t }
345 : b ? { text: '▄', fg: b }
346 : { text: ' ' }
347 }),
348 )
349 return out
350}
351
352// adjacent cells of the same look merged into one run
353export const mergeRuns = (cells: Run[]): Run[] =>
354 cells.reduce<Run[]>((runs, cell) => {
355 const last = runs[runs.length - 1]
356 if (last && last.fg === cell.fg && last.bg === cell.bg && [...last.text][0] === cell.text) last.text += cell.text
357 else runs.push({ ...cell })
358 return runs
359 }, [])
360
361export const toRuns = (g: Grid, skyPx?: (y: number) => string, skyCell?: (y: number) => Run): Run[][] =>
362 toCells(g, skyPx, skyCell).map(mergeRuns)
363hooks/skyhd.ts 168 lines1// The open sky above the title, drawn at three times the pixel resolution: each
2// terminal cell is a 2×3 grid of sub-pixels shown with a Unicode sextant glyph.
3// A cell holds two colors (glyph and background), so each cell keeps its two most
4// common sub-pixel colors. Cells with nothing in them use the shade-glyph gradient.
5import { skyAt, skyCell } from './pixel'
6import type { Run } from './pixel'
7
8type Sub = string | null // null: plain sky
9
10// sextant glyph for a 6-bit pattern (bit 0 top-left … bit 5 bottom-right)
11export const sextant = (n: number) =>
12 n === 0 ? ' ' : n === 63 ? '█' : n === 21 ? '▌' : n === 42 ? '▐'
13 : String.fromCodePoint(0x1fb00 + n - 1 - (n > 21 ? 1 : 0) - (n > 42 ? 1 : 0))
14
15const hash = (x: number, y: number) => ((x * 73856093) ^ (y * 19349663)) >>> 0
16// a sub-pixel is 3/4 as wide as it is tall: scale x when measuring round things
17const AX = 0.75
18
19const disc = (g: Sub[][], cx: number, cy: number, r: number, color: (x: number, y: number) => string | null) => {
20 for (let y = Math.floor(cy - r); y <= cy + r; y++)
21 for (let x = Math.floor(cx - r / AX); x <= cx + r / AX; x++)
22 if (g[y]?.[x] !== undefined && Math.hypot((x - cx) * AX, y - cy) <= r) {
23 const c = color(x, y)
24 if (c) g[y]![x] = c
25 }
26}
27
28// a shooting star now and then: a white head with a tail that dims behind it,
29// each one starting somewhere new
30const TAIL = ['#ffffff', '#d7d7ff', '#afafd7', '#8787af', '#5f5f87', '#3a3a3a']
31const meteor = (g: Sub[][], frame: number) => {
32 const h = g.length
33 const w = g[0]?.length ?? 0
34 const period = 22
35 const age = frame % period
36 if (age > 6) return
37 const n = Math.floor(frame / period)
38 const x0 = w * 0.35 + (hash(n, 31) % Math.max(1, Math.floor(w * 0.6)))
39 const y0 = 1 + (hash(n, 32) % Math.max(1, Math.floor(h * 0.4)))
40 // head moves down-left; the tail trails up-right, fading as the meteor burns out
41 for (let k = 0; k < TAIL.length * 2; k++) {
42 const x = Math.round(x0 - age * 4 + k * 1.33)
43 const y = Math.round(y0 + age * 2 - k * 0.5)
44 const shade = TAIL[Math.min(TAIL.length - 1, Math.floor(k / 2) + (age > 4 ? age - 4 : 0))]!
45 if (g[y]?.[x] !== undefined) g[y]![x] = shade
46 }
47}
48
49// a plane crossing slowly, its red and white lights blinking in turn
50const plane = (g: Sub[][], frame: number) => {
51 const h = g.length
52 const w = g[0]?.length ?? 0
53 const x = Math.floor(frame * 0.6) % (w + 30) - 15
54 const y = Math.round(h * 0.28)
55 const red = frame % 4 < 2
56 if (g[y]?.[x] !== undefined) g[y]![x] = red ? '#ff5f5f' : '#3a3a3a'
57 if (g[y]?.[x + 1] !== undefined) g[y]![x + 1] = '#4e4e4e'
58 if (g[y]?.[x + 2] !== undefined) g[y]![x + 2] = red ? '#3a3a3a' : '#ffffff'
59}
60
61const stars = (g: Sub[][], frame: number) => {
62 const h = g.length
63 const w = g[0]?.length ?? 0
64 const count = Math.floor((w * h) / 70)
65 for (let i = 0; i < count; i++) {
66 const x = hash(i, 21) % w
67 const y = hash(i * 7 + 3, 22) % h
68 const phase = (frame + i * 3) % 7
69 if (phase > 4 || g[y]![x] !== null) continue
70 g[y]![x] = phase === 0 ? '#ffffff' : phase < 3 ? '#d7d7ff' : '#8787af'
71 }
72}
73
74const moon = (g: Sub[][]) => {
75 const w = g[0]?.length ?? 0
76 const cx = w - 10
77 const cy = 6
78 disc(g, cx, cy, 4.2, (x, y) => (Math.hypot((x - cx - 2.4) * AX, y - cy + 1.2) > 3.6 ? '#ffd7af' : null))
79}
80
81const sun = (g: Sub[][], frame: number) => {
82 const w = g[0]?.length ?? 0
83 const cx = w - 12
84 const cy = 8
85 for (let k = 0; k < 8; k++) {
86 const angle = (k / 8) * Math.PI * 2 + frame * 0.08
87 for (let d = 6.2; d <= 8.6; d += 0.5) {
88 const x = Math.round(cx + (Math.cos(angle) * d) / AX)
89 const y = Math.round(cy + Math.sin(angle) * d)
90 if (g[y]?.[x] !== undefined) g[y]![x] = '#ffff87'
91 }
92 }
93 disc(g, cx, cy, 4.8, (x, y) => (Math.hypot((x - cx) * AX, y - cy) > 4 ? '#ffaf00' : '#ffd75f'))
94}
95
96// a cloud: overlapping puffs over a flat base, shaded underneath
97const cloud = (g: Sub[][], x0: number, y0: number, scale: number, light: string, shade: string) => {
98 const puffs = [[0, 0, 3.2], [4.5, -1.8, 4.2], [9.5, -0.4, 3.6], [13, 0.8, 2.6]] as const
99 for (const [dx, dy, r] of puffs)
100 disc(g, x0 + (dx * scale) / AX, y0 + dy * scale, r * scale, (_, y) => (y > y0 + 2.4 * scale ? null : y > y0 + 0.8 * scale ? shade : light))
101}
102
103const clouds = (g: Sub[][], frame: number) => {
104 const h = g.length
105 const w = g[0]?.length ?? 0
106 for (let i = 0; i < 3; i++) {
107 const span = w + 40
108 const x = ((hash(i, 5) % span) + Math.floor(frame / (2 + i))) % span - 30
109 const y = Math.round(h * (0.3 + 0.5 * ((i * 0.37) % 1))) + 2
110 cloud(g, x, y, i === 1 ? 0.8 : 1, '#ffffff', '#d7d7d7')
111 }
112}
113
114const bird = (g: Sub[][], frame: number) => {
115 const w = g[0]?.length ?? 0
116 const bx = (frame * 2) % (w * 3) - 6
117 if (bx > w) return
118 const by = Math.round(g.length * 0.4 + Math.sin(frame / 2) * 2)
119 const wings = frame % 2 ? [[-2, -1], [-1, 0], [0, 1], [1, 0], [2, -1]] : [[-2, 1], [-1, 0], [0, 0], [1, 0], [2, 1]]
120 for (const [dx, dy] of wings) if (g[by + dy!]?.[bx + dx!] !== undefined) g[by + dy!]![bx + dx!] = '#005f87'
121}
122
123const dist = (a: string, b: string) =>
124 [1, 3, 5].reduce((n, i) => n + (parseInt(a.slice(i, i + 2), 16) - parseInt(b.slice(i, i + 2), 16)) ** 2, 0)
125
126// one cell from its six sub-pixel colors (row-major, top-left first): it can show
127// two colors, so the two most common win and the rest go to whichever is nearer
128export const sextantCell = (colors: string[]): Run => {
129 const count = new Map<string, number>()
130 for (const c of colors) count.set(c, (count.get(c) ?? 0) + 1)
131 const [bg, fg] = [...count.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0])
132 const bits = fg === undefined ? 0 : colors.reduce((n, c, k) => (dist(c, fg) < dist(c, bg!) ? n | (1 << k) : n), 0)
133 return { text: sextant(bits), fg, bg }
134}
135
136// cell rows 0 .. rows-1 of the pane, for a sky whose gradient spans `span` half-block pixels
137export const skyRows = (w: number, rows: number, frame: number, night: boolean, stops: readonly string[], span: number): Run[][] => {
138 const g: Sub[][] = Array.from({ length: rows * 3 }, () => Array<Sub>(w * 2).fill(null))
139 if (night) {
140 stars(g, frame)
141 plane(g, frame)
142 meteor(g, frame)
143 moon(g)
144 } else {
145 sun(g, frame)
146 clouds(g, frame)
147 bird(g, frame)
148 }
149
150 return Array.from({ length: rows }, (_, row) => {
151 const runs: Run[] = []
152 for (let cx = 0; cx < w; cx++) {
153 // the cell's six sub-pixels, plain sky resolved to its band at that height
154 const subs = [0, 1, 2].flatMap(dy => [0, 1].map(dx => g[row * 3 + dy]![cx * 2 + dx]!))
155 const plain = (dy: number) => skyAt(row * 2 + (dy * 2 + 1) / 3, span, stops)
156 let cell: Run
157 if (subs.every(s => s === null)) cell = skyCell(row * 2, span, stops)
158 else {
159 cell = sextantCell(subs.map((s, k) => s ?? plain(Math.floor(k / 2))))
160 }
161 const last = runs[runs.length - 1]
162 if (last && last.fg === cell.fg && last.bg === cell.bg && [...last.text][0] === cell.text) last.text += cell.text
163 else runs.push(cell)
164 }
165 return runs
166 })
167}
168types/index.d.ts 14 lines1export type PomodoroTimer = {
2 phase: 'focus' | 'break'
3 start: number
4 ms: number
5 focusMin: number
6}
7export type PomodoroDay = { date: string; count: number }
8
9declare module 'claude-code' {
10 interface PluginState {
11 pomodoro: { timer: PomodoroTimer | null; now: number; day: PomodoroDay; paneShown: boolean }
12 }
13}
14