SLOPSHOPPER

pomodoro

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

newpanebandcommandtoasttimer
v0.1.0MITupdated 2026-10-09redjackfred/claude-code-mods/pomodoro
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pomodoro
│ ┃ 🍅 Pomodoro ✕ › fix the failing auth test and add an audit log call │ ┃ 🬞🬂 🬁🬭🬏 🬭🬂🬀 🬂🬂🬱 │ ┃ 🬑 🬂🬂 ⏺ Read(src/auth.ts) │ ┃ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒🬀🬭🬏🬞 🬞🬏🬏🬏🬞🬏 ⎿ Read 6 lines │ ┃ 🬭🬏🬭🬏🬁▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ⏺ Update(src/auth.ts) │ ┃ 🬠 ▐▐▐ ▐▐🬈🬏 🬚🬄🬯🬓 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒🬏🬀 🬁 🬁🬀🬂🬀🬂 ⎿ 3 pass, 1 fail │ ┃ 🬂🬀🬂🬀🬞▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ✻ Worked for 42s · done 4:20 PM │ ┃ ▒▒▒▒▒▒▒▒▒▒▒▒ │ ┃ ▀▀▀▀▀▀▀▀ › /focus │ ┃ ⎿ pomodoro: 🍅 開始專注 25 分鐘,休息 5 分鐘。 │ ┃ ░░░░░░░░░░░░░░░░░░░░░░▀▀▀▀▀▀▀▀▀▀▀▀░░░░░░░░░░ │ ┃ ░░░░░░░░░░░░ │ ┃ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▀▀▀▀▀▀▀▀▀▀▀▀▀▀▓▓▓▓▓▓▓▓▓ │ ┃ ▓▓▓▓▓▓▓▓▓▓▓▓ │ ┃ ░░░░░░░░░░░░░░░░░░░░▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀░░░░░░░░ │ ┃ ░░░░░░░░░░░░ │ ┃ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▓▓▓▓▓▓▓▓ │ ┃ ▓▓▓▓▓▓▓▓▓▓▓▓ │ ┃ ░░░░░░░░░░░░░░░░░░░░▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀░░░░░░░░ │ ┃ ░░░░░░░░░░░░ │ ┃ ▀▓▓▓▀▓▓▓▀▓▀▓▓▓▓▓▀▓▓▓▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▓▀▓▓▓▀▓▀▓ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🍅 Pomodoro
🬞🬂 🬁🬭🬏 🬭🬂🬀 🬂🬂🬱 🬑 🬂🬂 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒🬀🬭🬏🬞 🬞🬏🬏🬏🬞🬏 🬭🬏🬭🬏🬁▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ 🬠 ▐▐▐ ▐▐🬈🬏 🬚🬄🬯🬓 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒🬏🬀 🬁 🬁🬀🬂🬀🬂 🬂🬀🬂🬀🬞▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▀▀▀▀▀▀▀▀ ░░░░░░░░░░░░░░░░░░░░░░▀▀▀▀▀▀▀▀▀▀▀▀░░░░░░░░░░░░░░░░░░░░░░ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▀▀▀▀▀▀▀▀▀▀▀▀▀▀▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ░░░░░░░░░░░░░░░░░░░░▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀░░░░░░░░░░░░░░░░░░░░ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ░░░░░░░░░░░░░░░░░░░░▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀░░░░░░░░░░░░░░░░░░░░ ▀▓▓▓▀▓▓▓▀▓▀▓▓▓▓▓▀▓▓▓▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▓▀▓▓▓▀▓▀▓▀▓▀▓▓▓▓▓▓▓▀▓ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀🬓🬀🬔🬓🬦🬓🬈🬓🬘🬄▀🬦▐🬞🬄🬇▐▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀🬉▀🬈🬀🬯🬀🬄🬄🬉▀▀🬯🬄🬄▀🬯🬄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ /focus skip · stop · hide
README

claude-code-mods

A few mods for Claude Code, packaged as a plugin marketplace.

ModWhat it does
pomodoroA 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-progressLive Powerline-style progress bars for running subagents, with a moving glint and the model each one runs on.
cmd-guardStops 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-routerRuns 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"> &nbsp; <img src="docs/pomodoro-break.png" alt="Pomodoro break mode: night sky with moon and stars" width="280">

Install

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.

Usage

pomodoro

/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
  • Runs entirely locally: the timer never calls the model and costs no tokens.
  • Best in a terminal with a Nerd Font and Unicode 13 sextant glyphs (Ghostty, WezTerm, Kitty, iTerm2…).
  • The chime plays through afplay, so sound is macOS only.

agent-progress

agent-progress: three subagents running with Powerline progress bars

/agent-progress toggles the bars on and off.

cmd-guard

cmd-guard asking what to do with a broad rm -rf

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.

model-router

<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.
Optional: let Jev pick the model

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
  • Privacy: it's off by default because turning it on sends each general-purpose task (its description plus the first 4000 characters of the prompt) to TypeSafe's API.
  • Confidence: Jev's pick is used only when its confidence is at least 0.8.
  • Fallback: if Jev is slow (over 1.5 s), fails, or isn't sure, the subagent falls back to sonnet.
  • Explore subagents always stay on haiku.
  • Toast: shows Jev's pick, e.g. general-purpose → haiku · jev 1.00.

Development

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

License

MIT

Source 5 files
hooks/register.tsx 253 lines
1import { 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}
253
hooks/pane.ts 142 lines
1// 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}
142
hooks/pixel.ts 363 lines
1// 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)
363
hooks/skyhd.ts 168 lines
1// 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}
168
types/index.d.ts 14 lines
1export 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