DOPA MODE (DOPA is short for dopamine) for Claude Code: fireworks for everything you do and every milestone Claude reaches (finished turns, commits, pushes…

Claude Code に花火を。 打鍵もツールの呼び出しも、コミットも通ったテストも、入力欄の上で点字の花火になって打ち上がります。見ておくべき数字も、いつも目の届くところに出ています。<br> Fireworks for Claude Code. Every keystroke, tool call, commit and passing test goes up in braille fireworks above the prompt, and the numbers worth watching stay in view.
<img src="docs/demo.gif" alt="DOPA MODE in a real Claude Code session: typing that sets off 10HIT, DOPA spelled in rainbow fireworks, PASTE 173, GO! on Enter, small shells while a subagent works, then YES, PASS, COMMIT and PUSH, and a final show as the turn ends">
<sub>実機の Claude Code(Windows)で録画し、2 倍速にしています。サブエージェントを待つだけの 35 秒は切りました。Windows では DOPA MODE の音が鳴らないため、音はありません。<a href="docs/demo.mp4">動画</a><br> Recorded in a real Claude Code session on Windows and sped up 2x; 35 seconds of waiting on a subagent were cut. No sound, because DOPA MODE is silent on Windows. <a href="docs/demo.mp4">Video</a></sub>
DOPA は dopamine(ドーパミン)の略です。<br> DOPA is short for dopamine.
DOPA MODE は Claude Code の mod です。Claude Code の中に描かれ、あなたと Claude のすることに反応します。<br> DOPA MODE is a Claude Code mod: it draws inside Claude Code and reacts to what you and Claude do.
見ているだけです。プロンプトを書き換えず、勝手に何かを実行することもありません。モデルも呼ばないので、トークンを使いません。<br> It only watches. It never edits your prompts or runs anything on its own, and since it never calls a model, it costs no tokens.
Claude Code で、次の 3 行を 1 行ずつ入力します。<br> In Claude Code, enter these three lines one at a time:
/plugin marketplace add charimsma/dopa-mode
/plugin install dopa-mode@dopa-mode
/reload-plugins
Claude Code 2.1.287 以降が必要です。<br> Needs Claude Code 2.1.287 or later.
最初のショーは /dopa fireworks で、ほかのことは /dopa help で確かめられます。<br> Type /dopa fireworks for your first show and /dopa help for everything else.
花火は入力欄の上の枠に囲まれた空に点字のドットで描かれ、音も付きます。<br> Fireworks are drawn in braille dots across a framed sky above the prompt box, and they come with sound.
玉は日本の花火がもとです。牡丹、菊、星の色が途中で変わる八重芯、分砲、金色に垂れる冠菊、火花で文字を描く型物があり、まわりを噴出花火、金粉、ナイアガラが彩ります。<br> The shells are modeled on Japanese fireworks: a peony (botan), a chrysanthemum (kiku), a double chrysanthemum (yae-shin) whose stars change color midway, a crossette (bunpo), a gold willow (kamuro) and words spelled in sparks (kata-mono), with ground fountains, gold glitter and a niagara around them.
| いつ<br>When | 空に上がるもの<br>In the sky |
|---|---|
| 入力したとき<br>You type | 1 文字ごとに 1 HIT。打つたびに噴出花火が空のどこかへ飛び移って玉を上げ、10HIT・25HIT・50HIT・100HIT、その先は 100 ごとに文字花火になります。段階が上がるほど激しくなります<br>Every character you type is a HIT: a fountain leaps to a new spot in the sky and fires a shell, then 10HIT, 25HIT, 50HIT, 100HIT and every 100 after that go up in sparks, each tier wilder than the last |
| 貼り付けたとき<br>You paste | 貼り付けた文字数: PASTE 308<br>How many characters you pasted: PASTE 308 |
| Enter を押したとき<br>You press Enter | GO!。長いプロンプトほど大きく上がります<br>GO!, bigger for a longer prompt |
| Claude が作業しているとき<br>Claude works | ツールを 1 回使うごとに HIT 数の色の玉が上がり、5HIT から 30HIT で文字花火、50HIT でスターマインになります<br>A shell for every tool call, colored by the HIT count, then 5HIT to 30HIT in sparks and a full starmine (a rapid-fire barrage) at 50HIT |
| 許可の確認や質問に答えたとき<br>You answer a permission prompt or a question | YES・NO・OK。どの答えも同じ見た目なので、どれかに誘導しません<br>YES, NO or OK, all drawn alike, so no answer is favored |
| ターンが終わったとき<br>A turn finishes | Claude がしたこと: 3 FILES か DONE。そのターンの HIT 数が多いほど空が大きくなります<br>What Claude did: 3 FILES or DONE, in a sky that grows with the turn's HIT count |
| Claude がコミット、プッシュ、PR の作成やマージをしたとき<br>Claude commits, pushes, opens a pull request or merges one | COMMIT / PUSH / フラッシュを浴びる PR #42 / MERGED<br>COMMIT / PUSH / PR #42 under camera flashes / MERGED |
| Claude がタスクを 1 つ終えたとき<br>Claude finishes a task on its list | 3/5。最後の 1 つはフィナーレになります<br>3/5, with the last one as a finale |
| ターンが終わってもサブエージェントが動き続け、やがて終わったとき<br>A subagent keeps working after the turn ends, then finishes | ツールを使うたびに小玉、終わったときにもう 1 つ<br>A small shell for each tool call it makes, and one more when it finishes |
| 同じ型チェックや lint をもう一度実行して、エラーが減ったとき<br>A rerun of the same type check or lint finds fewer errors | -12 ERRORS、ゼロになると 0 ERRORS<br>-12 ERRORS, and 0 ERRORS once they are all gone |
| テストが赤から緑に戻ったとき、テストが増えたとき<br>Tests go from red to green, or the suite grows | PASS / +4 TESTS |
| CI がすべて通ったとき<br>CI goes all green | WIN |
| compact でコンテキストが空いたとき、プランの上限が回復したとき<br>Compacting frees up context, or your plan limit resets | HEAL / REFILL |
| レベルが上がったとき、トロフィーを取ったとき、集中タイマーを終えたとき<br>You level up, earn a trophy or finish a focus session | LV10 / GET! / DONE |
文字花火は一度に 1 つだけで、読めるように周りには他の花火を描きません。<br> Only one word hangs in the sky at a time, and the sky around it is kept clear so it reads.
大事な言葉は空にある言葉が読まれてから割り込むので、1 つのコマンドから出た YES、COMMIT、PUSH も順番に上がります。ほかの言葉は少し待ちます。<br> Important words cut in as soon as the word already in the sky has been read, so YES, COMMIT and PUSH from a single command still go up in turn. Other words wait a moment.
音はパチンコ風です。文字ができるときにキュイン、XP が出るときにジャラジャラ、打鍵とツールの呼び出しのたびに 8 ビットのピッ。<br> It all sounds like pachinko: a kyuin as each word forms, a cascade of balls when XP pays out, and an 8-bit bleep for every keystroke and tool call.
/dopa fireworks で、いつでも DOPA の文字花火を上げられます。<br> /dopa fireworks spells DOPA any time.
gh pr checks の出力も読みます。終わっていないチェックが BOSS の HP で、全部通るとボスが倒れます。gh pr checks を自分で実行するのは、/dopa boss を打ったときの 1 回だけです。<br> It also reads gh pr checks output: pending checks are the BOSS's HP, and the boss falls when every check passes. It runs gh pr checks itself only when you type /dopa boss, and then just once.gh pr checks だけで、それも /dopa boss を打ったときだけです。それ以外にネットワークへの接続もモデルの呼び出しもしません。<br> The one process it can start is gh pr checks, and only when you type /dopa boss. Beyond that, it makes no network requests and no model calls.claude plugin validate ./plugin を実行すると、フックしているイベントと呼び出せる処理がすべて表示されます。<br> claude plugin validate ./plugin prints the whole footprint: every event it hooks and every call it can make.
| コマンド<br>Command | すること<br>What it does | |
|---|---|---|
| `/dopa fireworks [on\ | off]` | DOPA を描くスターマイン。on と off で花火全体を切り替えます<br>A starmine that spells DOPA; on or off turns all fireworks on or off |
| `/dopa typing [on\ | off]` | 入力への反応(HIT、打鍵の玉、GO!)を切り替えます<br>Turn typing reactions (HITs, keystroke shells and GO!) on or off |
/dopa recap [copy] | このセッションのまとめ: 編集したファイル、使ったツール、トークン、上限<br>This session: files edited, tools used, tokens, limits | |
/dopa stats | レベル、クリアしたターン数、コミット、PR、最高コンボ、連続記録<br>Level, turns cleared, commits, pull requests, best combo and streak | |
/dopa daily, /dopa dex | 今日のコマンドカードと、まだ試していないコマンド<br>Today's command card, and the commands you have not tried | |
/dopa trophies | 獲得したトロフィーと、残りの取り方<br>Trophies earned, and how to earn the rest | |
/dopa focus [min], /dopa break [min] | 集中と休憩のタイマー(stop で止めます)<br>Focus and break timers (stop ends one) | |
/dopa boss | このブランチの PR を gh で 1 回確認します<br>Check this branch's pull request with gh, once | |
/dopa legend | 各数字が何を測っているか<br>What every number measures | |
/dopa sfx, alerts, meters, spinner, off | 各機能をオンとオフで切り替えます<br>Turn each feature on or off | |
/dopa reset confirm | LV 1 からやり直します(トロフィーも消えます)<br>Start over from LV 1 (this also erases your trophies) |
トークンを使いますか?<br> Does it cost tokens?
使いません。モデルを呼ばず、Claude が読むものに何も足しません。/dopa コマンドも手元で処理します。<br> No. It never calls a model and adds nothing to what Claude reads, and /dopa commands are handled locally.
プロンプトを読みますか?<br> Does it read my prompts?
入力の HIT と GO! のために、入力欄の文字数を数えます。文字そのものは保存も記録も送信も変更もしません。<br> To drive the typing HITs and GO!, it counts the characters in the prompt box. The text is never stored, logged, sent anywhere or changed.
エラーが減ったことをどうやって知るのですか?<br> How does it know errors went down?
チェッカー(tsc、eslint、ruff、mypy、pyright、cargo、javac)が出す要約の行を読み、まったく同じコマンドの前回の結果と比べます。プロジェクト全体を調べてから 1 ファイルだけ調べても、減ったとは数えません。<br> It reads the summary line that a checker (tsc, eslint, ruff, mypy, pyright, cargo or javac) prints and compares it with the last run of the exact same command, so checking the whole project and then a single file never counts as progress.
デスクトップアプリでも動きますか?<br> Does it work in the desktop app?
Code タブなら動きます。HUD、通知、タイマー、花火が出ます(花火は SVG で描きます)。<br> In the Code tab, yes: you get the HUD, alerts, timers and fireworks, with the fireworks drawn as SVG.
Windows でも動きますか?<br> Does it work on Windows?
動きます。Windows 11 の標準のターミナルである Windows Terminal で、PowerShell か Git Bash のタブから Claude Code を起動してください。<br> Yes. Run Claude Code in Windows Terminal, the default terminal on Windows 11, from a PowerShell or Git Bash tab.
テスト、型チェック、git の結果は、Bash と PowerShell のどちらのコマンドからも読み取ります。<br> Test, type-check and git results are read from PowerShell commands as well as Bash ones.
VS Code では、統合ターミナルで claude を起動してください。Claude Code 拡張機能のパネルには花火が出ません。<br> In VS Code, start claude in the integrated terminal; the Claude Code extension's own panel has no fireworks.
Windows では音は鳴りません。<br> There is no sound on Windows.
音は鳴りますか?<br> Does it play sound?
macOS なら鳴ります。Claude Code の音声機能で鳴らしていて、それが使えるのは macOS だけです。Linux と Windows のターミナルでは鳴りません。/dopa sfx off で消せます。<br> On macOS, yes: it plays through Claude Code's audio, which works only there. Linux and Windows terminals stay silent. /dopa sfx off mutes it.
派手すぎませんか?<br> Is it too much?
/dopa fireworks off で空を閉じて HUD だけにでき、/dopa typing off で入力への反応を止められ、/dopa off ですべてを止められます。<br> /dopa fireworks off closes the sky and keeps the HUD, /dopa typing off stops the typing reactions, and /dopa off pauses everything.
/cost が合計するこのセッションの費用で、API の価格で計算します。サブスクリプションなら請求額ではありません。<br> GOLD: the session cost as /cost totals it, at API prices. On a subscription, it isn't what you're billed.どれか 1 つの機能だけが欲しいなら、次の mod がよくできています。<br> If you only want one of these features, the mods below do it well:
| 機能<br>Feature | mod |
|---|---|
| スロットマシンのスピナー<br>Slot-machine spinner | Claude Slots |
| 使用量<br>Usage | cctop, context-lens, quota-meter and token-ledger |
| CI | cc-pr-tracker, gh-ci-status |
| ゲーム<br>Games | cc-arcade, claude-games, cc-pokedex |
| ポモドーロ<br>Pomodoro | Claudoro |
| XP と実績<br>XP and achievements | claude-rank, claude-code-achievements |
Issue も Pull Request も歓迎します。先にチェックを実行してください。<br> Issues and pull requests are welcome. Run the checks first:
claude plugin validate --strict .
claude plugin validate --strict ./plugin
claude plugin test ./plugin
CI は、対応している最も古い Claude Code と最新の Claude Code の両方でこれらを実行します。効果音は tools/gen_sfx.py で作っています。<br> CI runs them on the oldest supported Claude Code and on the latest. The sounds come from tools/gen_sfx.py.
MIT です。効果音は一から合成したものです。<br> MIT. The sounds are synthesized from scratch.
非公式のプロジェクトで、Anthropic とは関係ありません。「Claude」は Anthropic, PBC の商標です。<br> An unofficial project, not affiliated with Anthropic. "Claude" is a trademark of Anthropic, PBC.
hooks/dopa-mode.ts 2821 lines1// DOPA MODE for Claude Code. DOPA is short for dopamine.
2//
3// Above the prompt: HP (context left), STAMINA (plan limit left), cost and files edited,
4// with warnings before they bite. Everything you do and every real milestone goes up as fireworks
5// in a sky above the prompt: a finished turn and the files it edited, commits, pushes, pull requests,
6// tests passing and CI going green. It also keeps trophies, a daily command card, a focus timer and
7// a session recap.
8//
9// It reads and never rewrites: no prompt, tool result or transcript row is changed.
10// The only process it can start is `gh pr checks`, and only when you type /dopa boss.
11
12import { atom, read, update } from 'claude-code'
13import type { Elements, EngineInterface, Register, RenderElement, Timer } from 'claude-code'
14import type { BossState, FocusState, Limit, Meter, Moment, Profile, SessionStats, Settings, TestState, TurnInfo } from '../types'
15
16type Api = EngineInterface
17type BoxText = Pick<Elements['terminal'], 'Box' | 'Text'>
18
19// ---------------------------------------------------------------- constants
20
21const FRAME_MS = 40
22
23// The hold-lamp ladder from pachinko: white, blue, green, red, gold, and rainbow, which means confirmed.
24// One ladder drives the hit count, the reveal lamp and the runner's trail; rainbow is kept for the rarest.
25const LADDER = ['#e9e6f2', '#3d8bff', '#2ed47a', '#ff4545', '#ffc928'] as const
26const RAINBOW = ['#ff4545', '#ff9a1f', '#ffe14d', '#2ed47a', '#2fc6ff', '#5b6bff', '#c45bff']
27const PINK = '#ff4545'
28const GOLD = '#ffc928'
29const ORANGE = '#ff9a1f'
30
31const COMBO_TIERS = [
32 { at: 50, word: 'RAINBOW', level: 5 },
33 { at: 30, word: 'FEVER', level: 4 },
34 { at: 20, word: 'EXCELLENT', level: 3 },
35 { at: 10, word: 'GREAT', level: 2 },
36 { at: 5, word: 'GOOD', level: 1 },
37 { at: 0, word: '', level: 0 },
38] as const
39
40/** Test runners whose summaries the slot reads. It reads what Claude already ran; it never runs tests. */
41const TEST_COMMAND =
42 /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b|\bnpx\s+(?:jest|vitest|mocha)\b|^\s*(?:jest|vitest|mocha)\b|\bpytest\b|\bpython3?\s+-m\s+(?:pytest|unittest)\b|\bgo\s+test\b|\bcargo\s+(?:test|nextest)\b|\b(?:mvn|mvnw)\b[^|;&]*\b(?:test|verify)\b|\b(?:gradle|gradlew)\b[^|;&]*\b(?:test|check|build)\b|\brspec\b|\bphpunit\b|\bdotnet\s+test\b|\bdeno\s+test\b|\bclaude\s+plugin\s+test\b/
43const CHECKS_COMMAND = /\bgh\s+pr\s+checks\b/
44
45const TROPHIES = [
46 { id: 'first-clear', name: 'First clear', hint: 'finish a turn' },
47 { id: 'commit', name: 'Committed', hint: 'see Claude make a commit' },
48 { id: 'merged', name: 'Merged', hint: 'see a pull request merged' },
49 { id: 'hit-10', name: 'Ten-hit turn', hint: 'reach 10 HIT in one turn' },
50 { id: 'hit-30', name: 'Fever turn', hint: 'reach 30 HIT in one turn' },
51 { id: 'hit-50', name: 'Rainbow turn', hint: 'reach 50 HIT in one turn' },
52 { id: 'streak-10', name: 'Ten in a row', hint: 'finish 10 turns without an interrupt' },
53 { id: 'all-green', name: 'All green', hint: 'see a test run pass' },
54 { id: 'comeback', name: 'Comeback', hint: 'turn a red test run green' },
55 { id: 'boss', name: 'Boss down', hint: 'see every pull request check pass' },
56 { id: 'self-compact', name: 'Self care', hint: 'compact before auto-compact does it for you' },
57 { id: 'dex-10', name: 'Collector', hint: 'capture 10 commands' },
58 { id: 'focus-1', name: 'Deep work', hint: 'finish a focus session' },
59 { id: 'focus-4', name: 'Full cycle', hint: 'finish 4 focus sessions in one day' },
60 { id: 'dan', name: '1 DAN', hint: 'reach LV 11' },
61] as const
62type TrophyId = (typeof TROPHIES)[number]['id']
63
64export const FRESH_PROFILE: Profile = {
65 level: 1,
66 xp: 0,
67 totalXp: 0,
68 clears: 0,
69 streak: 0,
70 bestStreak: 0,
71 bestCombo: 0,
72 moments: { commit: 0, push: 0, pr: 0, merge: 0 },
73}
74export const DEFAULT_SETTINGS: Settings = { isOn: true, sfx: true, spinner: true, meters: true, alerts: true, typing: true, fireworks: true }
75const IDLE_TURN: TurnInfo = { isWorking: false, combo: 0, startedAt: 0, word: 'Cooking', move: '' }
76export const FRESH_STATS: SessionStats = {
77 startedAt: 0,
78 turns: 0,
79 toolCalls: 0,
80 tools: {},
81 files: {},
82 inputTokens: 0,
83 cacheReadTokens: 0,
84 outputTokens: 0,
85 workMs: 0,
86 bestCombo: 0,
87 moments: { commit: 0, push: 0, pr: 0, merge: 0 },
88}
89
90// Held by the host, so they survive a hot reload of this file.
91const profileAtom = atom({ plugin: 'dopa-mode', key: 'profile' } as const, FRESH_PROFILE)
92const settingsAtom = atom({ plugin: 'dopa-mode', key: 'settings' } as const, DEFAULT_SETTINGS)
93const turnAtom = atom({ plugin: 'dopa-mode', key: 'turn' } as const, IDLE_TURN)
94const meterAtom = atom({ plugin: 'dopa-mode', key: 'meter' } as const, null)
95const statsAtom = atom({ plugin: 'dopa-mode', key: 'stats' } as const, FRESH_STATS)
96const testsAtom = atom({ plugin: 'dopa-mode', key: 'tests' } as const, { last: null, greenStreak: 0, wasRed: false })
97const bossAtom = atom({ plugin: 'dopa-mode', key: 'boss' } as const, null)
98const focusAtom = atom({ plugin: 'dopa-mode', key: 'focus' } as const, null)
99
100// ---------------------------------------------------------------- game rules
101
102export function xpToNext(level: number): number {
103 return 80 + level * 40
104}
105
106/** Ranks as in shogi, go and arcade dan exams: 10 KYU up to 1 KYU, 1 DAN up to 9 DAN, then MEIJIN. */
107export function levelTitle(level: number): string {
108 if (level <= 10) return `${11 - Math.max(1, level)} KYU`
109 if (level <= 19) return `${level - 10} DAN`
110 return 'MEIJIN'
111}
112
113/** XP for a finished turn: its hits, the files it edited and how long it ran, a little more on a streak. */
114export function turnXp(combo: number, files: number, durationMs: number, streak: number): number {
115 const base = 10 + 4 * Math.min(combo, 60) + 12 * Math.min(files, 20) + Math.min(60, Math.floor(durationMs / 2000))
116 return Math.round(base * (1 + Math.min(streak, 10) * 0.05))
117}
118
119/** A finished turn on the profile: XP lands, levels roll over, the streak grows. */
120export function finishTurn(before: Profile, xp: number, combo: number): Profile {
121 const p = grantXp(before, xp)
122 return { ...p, clears: p.clears + 1, streak: p.streak + 1, bestStreak: Math.max(p.bestStreak, p.streak + 1), bestCombo: Math.max(p.bestCombo, combo) }
123}
124
125export function normalizeProfile(raw: unknown): Profile {
126 const p = (raw !== null && typeof raw === 'object' ? raw : {}) as Partial<Profile> & { pulls?: unknown }
127 const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) ? v : d)
128 const m = (p.moments ?? {}) as Partial<Record<Moment, number>>
129 return {
130 level: Math.max(1, Math.floor(num(p.level, 1))),
131 xp: Math.max(0, num(p.xp, 0)),
132 totalXp: Math.max(0, num(p.totalXp, 0)),
133 clears: Math.max(0, num(p.clears, num(p.pulls, 0))), // older saves counted finished turns as pulls
134 streak: Math.max(0, num(p.streak, 0)),
135 bestStreak: Math.max(0, num(p.bestStreak, 0)),
136 bestCombo: Math.max(0, num(p.bestCombo, 0)),
137 moments: { commit: Math.max(0, num(m.commit, 0)), push: Math.max(0, num(m.push, 0)), pr: Math.max(0, num(m.pr, 0)), merge: Math.max(0, num(m.merge, 0)) },
138 }
139}
140
141export function normalizeSettings(raw: unknown): Settings {
142 const s = (raw !== null && typeof raw === 'object' ? raw : {}) as Partial<Settings>
143 const flag = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
144 return {
145 isOn: flag(s.isOn, DEFAULT_SETTINGS.isOn),
146 sfx: flag(s.sfx, DEFAULT_SETTINGS.sfx),
147 spinner: flag(s.spinner, DEFAULT_SETTINGS.spinner),
148 meters: flag(s.meters, DEFAULT_SETTINGS.meters),
149 alerts: flag(s.alerts, DEFAULT_SETTINGS.alerts),
150 typing: flag(s.typing, DEFAULT_SETTINGS.typing),
151 fireworks: flag(s.fireworks, DEFAULT_SETTINGS.fireworks),
152 }
153}
154
155/** What a tool call is doing, in a few words: EDIT signup.ts, BASH npm test, GREP validate. */
156export function moveLabel(e: unknown): string {
157 const a = e as Record<string, unknown>
158 const tool = typeof a.tool === 'string' ? a.tool : ''
159 const str = (v: unknown) => (typeof v === 'string' ? v : '')
160 const base = (v: unknown) => str(v).split(/[\\/]/).pop() ?? ''
161 const clip = (text: string) => (text.length > 28 ? `${text.slice(0, 27)}…` : text)
162 const host = (v: unknown) => {
163 try {
164 return new URL(str(v)).hostname
165 } catch {
166 return ''
167 }
168 }
169 const verbs: Record<string, [string, string]> = {
170 Edit: ['EDIT', base(a.file_path)],
171 MultiEdit: ['EDIT', base(a.file_path)],
172 NotebookEdit: ['EDIT', base(a.notebook_path)],
173 Write: ['WRITE', base(a.file_path)],
174 Read: ['READ', base(a.file_path)],
175 Bash: ['BASH', str(a.command).trim().split(/\r?\n/)[0] ?? ''],
176 PowerShell: ['PS', str(a.command).trim().split(/\r?\n/)[0] ?? ''],
177 Grep: ['GREP', str(a.pattern)],
178 Glob: ['GLOB', str(a.pattern)],
179 WebFetch: ['WEB', host(a.url)],
180 WebSearch: ['SEARCH', str(a.query)],
181 Task: ['AGENT', str(a.description)],
182 Agent: ['AGENT', str(a.description)],
183 TodoWrite: ['TODO', ''],
184 AskUserQuestion: ['ASK', ''],
185 }
186 const known = verbs[tool]
187 if (known) return `${known[0]} ${clip(known[1])}`.trim()
188 if (tool.startsWith('mcp__')) return `MCP ${tool.split('__')[1] ?? ''}`.trim()
189 return tool.toUpperCase().slice(0, 16)
190}
191
192export function comboTier(combo: number) {
193 return COMBO_TIERS.find((t) => combo >= t.at) ?? COMBO_TIERS[COMBO_TIERS.length - 1]!
194}
195
196/** A ladder step's color; the rainbow step cycles. */
197function ladderColor(level: number, phase: number): string {
198 return level >= 5 ? RAINBOW[phase % RAINBOW.length]! : LADDER[Math.max(0, level)]!
199}
200
201export type TestCount = { passed: number; failed: number; isKnown: boolean }
202
203/** Reads the summary of common test runners. Null when the output has no summary it knows. */
204export function parseTestOutput(text: string): TestCount | null {
205 const last = (re: RegExp): RegExpMatchArray | null => {
206 let found: RegExpMatchArray | null = null
207 for (const m of text.matchAll(re)) found = m
208 return found
209 }
210 const n = (v: string | undefined) => (v ? Number(v) : 0)
211 const counts = (line: string, passWord = 'passed') => ({
212 passed: n(line.match(new RegExp(`(\\d+) ${passWord}`))?.[1]),
213 failed: n(line.match(/(\d+) failed/)?.[1]),
214 isKnown: true,
215 })
216 let m = last(/Tests:[ \t]+[^\n]*?\d+ total/g) // Jest
217 if (m) return counts(m[0])
218 m = last(/^\s*Tests\s+[^\n]*?\(\d+\)/gm) // Vitest
219 if (m) return counts(m[0])
220 m = last(/Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)/g) // Maven Surefire
221 if (m) {
222 const failed = n(m[2]) + n(m[3])
223 return { passed: n(m[1]) - failed - n(m[4]), failed, isKnown: true }
224 }
225 m = last(/(\d+) tests? completed, (\d+) failed/g) // Gradle
226 if (m) return { passed: n(m[1]) - n(m[2]), failed: n(m[2]), isKnown: true }
227 m = last(/((?:\d+ (?:passed|failed|errors?|skipped|xfailed|xpassed|warnings?|deselected),? ?)+) in [\d.]+s/g) // pytest
228 if (m) {
229 const line = m[1] ?? ''
230 return { passed: n(line.match(/(\d+) passed/)?.[1]), failed: n(line.match(/(\d+) failed/)?.[1]) + n(line.match(/(\d+) errors?/)?.[1]), isKnown: true }
231 }
232 const cargo = [...text.matchAll(/test result: \w+\. (\d+) passed; (\d+) failed/g)] // cargo, one line per crate
233 if (cargo.length > 0) return { passed: cargo.reduce((sum, c) => sum + n(c[1]), 0), failed: cargo.reduce((sum, c) => sum + n(c[2]), 0), isKnown: true }
234 m = last(/(\d+) examples?, (\d+) failures?/g) // RSpec
235 if (m) return { passed: n(m[1]) - n(m[2]), failed: n(m[2]), isKnown: true }
236 m = last(/Failed:\s+(\d+), Passed:\s+(\d+)/g) // dotnet test
237 if (m) return { passed: n(m[2]), failed: n(m[1]), isKnown: true }
238 m = last(/OK \((\d+) tests?/g) // PHPUnit, green
239 if (m) return { passed: n(m[1]), failed: 0, isKnown: true }
240 m = last(/Tests: (\d+), Assertions: \d+(?:, Errors: (\d+))?(?:, Failures: (\d+))?/g) // PHPUnit, red
241 if (m) {
242 const failed = n(m[2]) + n(m[3])
243 return { passed: n(m[1]) - failed, failed, isKnown: true }
244 }
245 const passing = last(/(\d+) passing/g) // Mocha
246 if (passing) return { passed: n(passing[1]), failed: n(last(/(\d+) failing/g)?.[1]), isKnown: true }
247 m = last(/(?:ok|FAILED) \| (\d+) passed[^|\n]*\| (\d+) failed/g) // Deno
248 if (m) return { passed: n(m[1]), failed: n(m[2]), isKnown: true }
249 const bun = last(/^\s*(\d+) pass$/gm) // bun test, claude plugin test
250 if (bun) return { passed: n(bun[1]), failed: n(last(/^\s*(\d+) fail$/gm)?.[1]), isKnown: true }
251 m = last(/Ran (\d+) tests? in/g) // unittest
252 if (m) {
253 const f = last(/FAILED \(([^)]*)\)/g)?.[1] ?? ''
254 const failed = n(f.match(/failures=(\d+)/)?.[1]) + n(f.match(/errors=(\d+)/)?.[1])
255 return { passed: n(m[1]) - failed, failed, isKnown: true }
256 }
257 const goFail = (text.match(/^--- FAIL:/gm) ?? []).length + (text.match(/^FAIL\t/gm) ?? []).length // go test
258 const goPass = (text.match(/^ok\s+\S+/gm) ?? []).length + (text.match(/^--- PASS:/gm) ?? []).length
259 if (goFail + goPass > 0) return { passed: goPass, failed: goFail, isKnown: false }
260 if (/BUILD SUCCESSFUL/.test(text)) return { passed: 0, failed: 0, isKnown: false }
261 if (/BUILD FAILED/.test(text)) return { passed: 0, failed: 1, isKnown: false }
262 return null
263}
264
265export type CheckCount = { total: number; passed: number; failed: number; pending: number }
266
267/** Reads `gh pr checks` output: its JSON (`--json name,bucket`) or its plain tab-separated table. */
268export function parseChecks(text: string): CheckCount | null {
269 const tally = (buckets: string[]): CheckCount | null => {
270 if (buckets.length === 0) return null
271 const c = { total: buckets.length, passed: 0, failed: 0, pending: 0 }
272 for (const b of buckets) {
273 if (b === 'pass' || b === 'skipping') c.passed += 1
274 else if (b === 'fail' || b === 'cancel') c.failed += 1
275 else c.pending += 1
276 }
277 return c
278 }
279 const trimmed = text.trim()
280 if (trimmed.startsWith('[')) {
281 try {
282 const rows = JSON.parse(trimmed) as Array<{ bucket?: unknown }>
283 return tally(rows.map((row) => String(row.bucket ?? '').toLowerCase()))
284 } catch {
285 // Not JSON after all: try the table.
286 }
287 }
288 const buckets = trimmed
289 .split('\n')
290 .map((line) => (line.split('\t')[1] ?? '').trim().toLowerCase())
291 .filter((b) => ['pass', 'fail', 'pending', 'skipping', 'cancel'].includes(b))
292 return tally(buckets)
293}
294
295type Dex = { captured: string[]; day: string; card: string | null; isCardCaptured: boolean }
296
297export function normalizeDex(raw: unknown): Dex {
298 const d = (raw !== null && typeof raw === 'object' ? raw : {}) as Partial<Dex>
299 return {
300 captured: Array.isArray(d.captured) ? d.captured.filter((x): x is string => typeof x === 'string') : [],
301 day: typeof d.day === 'string' ? d.day : '',
302 card: typeof d.card === 'string' ? d.card : null,
303 isCardCaptured: d.isCardCaptured === true,
304 }
305}
306
307
308export function grantXp(p: Profile, xp: number): Profile {
309 const q: Profile = { ...p, xp: p.xp + xp, totalXp: p.totalXp + xp }
310 while (q.xp >= xpToNext(q.level)) {
311 q.xp -= xpToNext(q.level)
312 q.level += 1
313 }
314 return q
315}
316
317function localDay(now = new Date()): string {
318 const pad = (x: number) => String(x).padStart(2, '0')
319 return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
320}
321
322function clampMinutes(arg: string, fallback: number): number {
323 const v = Number(arg)
324 return Number.isFinite(v) && v > 0 ? Math.min(180, Math.max(1, Math.round(v))) : fallback
325}
326
327export function fmt(n: number): string {
328 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
329}
330
331function elapsed(ms: number): string {
332 const s = Math.max(0, Math.floor(ms / 1000))
333 if (s < 60) return `${s}s`
334 if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
335 return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
336}
337
338/** 1234 to 1.2k, 1234567 to 1.2M. */
339export function short(n: number): string {
340 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
341 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
342 return String(Math.round(n))
343}
344
345/** Time until an ISO timestamp: 47m, 2h 05m, 3d 4h. Empty when unknown. */
346export function untilText(iso: string | undefined, now = Date.now()): string {
347 if (!iso) return ''
348 const ms = Date.parse(iso) - now
349 if (!Number.isFinite(ms)) return ''
350 if (ms <= 0) return 'now'
351 const minutes = Math.ceil(ms / 60_000)
352 if (minutes < 60) return `${minutes}m`
353 const hours = Math.floor(minutes / 60)
354 if (hours < 24) return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
355 return `${Math.floor(hours / 24)}d ${hours % 24}h`
356}
357
358export function limitName(kind: string, isShort = false): string {
359 if (kind === 'five_hour') return isShort ? '5h' : '5-hour'
360 if (kind === 'seven_day') return isShort ? '7d' : 'weekly'
361 if (kind.startsWith('seven_day_')) return `${isShort ? '7d' : 'weekly'} ${kind.slice(10)}`
362 if (kind === 'spend_limit') return 'spend'
363 return kind.replace(/_/g, ' ')
364}
365
366type UsageLike = {
367 context: { tokens?: number; window: number; percent?: number }
368 rateLimits: readonly Limit[]
369 cost?: { usd: number }
370}
371
372export function toMeter(u: UsageLike): Meter {
373 const { tokens, window, percent } = u.context
374 const ctxPercent = percent ?? (tokens !== undefined && window > 0 ? Math.round((tokens / window) * 100) : null)
375 return {
376 ctxPercent,
377 ctxTokens: tokens ?? null,
378 ctxWindow: window,
379 limits: u.rateLimits.map((l) =>
380 l.resetsAt
381 ? { kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt }
382 : { kind: l.kind, percentUsed: l.percentUsed },
383 ),
384 usd: u.cost?.usd ?? null,
385 }
386}
387
388const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
389// Claude Code on Windows runs commands through PowerShell, which answers in the same shape as Bash.
390const SHELL_TOOLS = new Set(['Bash', 'PowerShell'])
391
392/** The file an edit tool call changes, relative to the session's directory. */
393export function editedFile(e: unknown, root: string): string | null {
394 const args = e as { tool?: string; file_path?: unknown; notebook_path?: unknown }
395 if (!args.tool || !EDIT_TOOLS.has(args.tool)) return null
396 const path =
397 typeof args.file_path === 'string' ? args.file_path : typeof args.notebook_path === 'string' ? args.notebook_path : null
398 if (!path) return null
399 for (const sep of ['/', '\\']) {
400 const base = root.endsWith(sep) ? root : root + sep
401 if (root && path.startsWith(base)) return path.slice(base.length)
402 }
403 return path
404}
405
406function random01(): number {
407 const a = new Uint32Array(1)
408 crypto.getRandomValues(a)
409 return (a[0] ?? 0) / 4294967296
410}
411
412function pick<T>(list: readonly T[]): T {
413 return list[Math.floor(random01() * list.length)] ?? (list[0] as T)
414}
415
416// ---------------------------------------------------------------- live state
417
418let settings: Settings = DEFAULT_SETTINGS
419let live: TurnInfo = IDLE_TURN
420let isInteractive = true
421const clients = new Set<string>()
422
423/** True when a person can see this session: the REPL, or an app attached to it, such as the desktop app. */
424function hasPerson(): boolean {
425 return isInteractive || clients.size > 0
426}
427let turnFiles = new Set<string>()
428let ticker: Timer | null = null
429let ticks = 0
430let lastHitAt = 0
431// A subagent working on after the turn keeps the sky open this long after each of its tool calls, so the band does
432// not fold and unfold between them.
433const BACKGROUND_SKY_MS = 12_000
434let backgroundUntil = 0
435let focusDone: Timer | null = null
436let storeQueue: Promise<unknown> = Promise.resolve()
437let toastSlot = 0
438
439// What the person is doing. The draft's text is never kept: only its length.
440// hits: characters typed into this draft, each one a HIT. tier: its HIT tier. milestone: the last HIT count that went up as a word.
441// jets: where the typing fountain leapt at the last keystrokes, so sparks already in the air stay where they rose.
442type Jet = { at: number; x: number }
443type Typing = { len: number; typed: number; hits: number; energy: number; lastAt: number; pips: number; tier: number; flashUntil: number; milestone: number; jets: readonly Jet[] }
444const NO_TYPING: Typing = { len: 0, typed: 0, hits: 0, energy: 0, lastAt: 0, pips: 0, tier: 0, flashUntil: 0, milestone: 0, jets: [] }
445let lastKeySoundAt = 0
446// Where the next keystroke's effects go: steps of the golden ratio across the sky, so each key lands far from the last.
447let typeSpin = 0
448let typing: Typing = NO_TYPING
449let ack: { label: string; color: string; until: number } | null = null
450// Permission dialogs up and waiting on the person: the tool and its input as the dialog showed them. The call that
451// comes back with the same tool and input is the one they answered.
452let asking: Array<{ tool: string; input: unknown }> = []
453
454/** The dialog's input and the call's own agree, field by field. */
455function isSameCall(input: unknown, call: Record<string, unknown>): boolean {
456 if (!input || typeof input !== 'object') return true
457 return Object.entries(input as Record<string, unknown>).every(([k, v]) => JSON.stringify(v) === JSON.stringify(call[k]))
458}
459
460/** The dialog a finished call answered, taken off the list: the same tool and input, else the oldest for its tool. */
461function takeAnswered(call: Record<string, unknown>): boolean {
462 const tool = typeof call.tool === 'string' ? call.tool : ''
463 let i = asking.findIndex((a) => a.tool === tool && isSameCall(a.input, call))
464 if (i < 0) i = asking.findIndex((a) => a.tool === tool)
465 if (i < 0) return false
466 asking = asking.filter((_, j) => j !== i)
467 return true
468}
469
470/** Read-modify-writes of saved data run one at a time, so two rewards landing together cannot overwrite each other. */
471function serial<T>(fn: () => Promise<T>): Promise<T> {
472 const run = storeQueue.then(fn, fn)
473 storeQueue = run.catch(() => undefined)
474 return run
475}
476
477/** Reward toasts land one after another, like a payout, each with its sound and fireworks. */
478function queueToast($: Api, text: string, sound: string, occasion: Occasion, opts: ShowOptions = {}) {
479 const now = Date.now()
480 const at = Math.max(now, toastSlot)
481 toastSlot = at + 700
482 const land = () => {
483 $.ui.toast(text, { timeoutMs: 4500 })
484 sfx($, sound, 0.8)
485 startShow($, occasion, opts)
486 }
487 if (at <= now) land()
488 else $.clock.after(at - now, land)
489}
490let focusTick: Timer | null = null
491let cwd = ''
492let lastCtx: number | null = null
493const lastLimits = new Map<string, number>()
494let lastAlertAt = 0
495let isUsageBusy = false
496
497function sfx($: Api, name: string, gain = 0.8) {
498 if (!settings.isOn || !settings.sfx || !hasPerson()) return
499 // Not awaited: it resolves when the clip ends. A Linux or Windows terminal plays nothing.
500 $.audio.play({ asset: `sfx/${name}.wav` }, { gain }).catch(() => {})
501}
502
503function ensureTicker($: Api) {
504 if (ticker) return
505 ticker = $.clock.every(FRAME_MS, () => onTick($))
506}
507
508function onTick($: Api) {
509 ticks += 1
510 const now = Date.now()
511 const isShowing = show !== null && now < show.end
512 if (show && !isShowing) {
513 show = null
514 waiting = []
515 $.ui.invalidate('ui.render') // the sky folds away or settles back
516 }
517 const sky = skyRows(now)
518 if (sky >= 3) blitSky($, now)
519 if (ack && now >= ack.until) {
520 ack = null
521 $.ui.invalidate('ui.render')
522 }
523 if (live.isWorking && ticks % 75 === 0) void refreshUsage($) // keeps HP live mid-turn
524 const isTyping = settings.typing && typing.len > 0 && now - typing.lastAt < 1500
525 if (live.isWorking || isShowing || isTyping || ack !== null || sky >= 3) {
526 if (ticks % 3 === 0) $.ui.invalidate('ui.render')
527 } else {
528 $.ui.invalidate('ui.render') // one last frame so the HUD settles
529 ticker?.cancel()
530 ticker = null
531 }
532}
533
534async function loadFromStore($: Api) {
535 const [rawProfile, rawSettings] = await Promise.all([$.store.get('profile'), $.store.get('settings')])
536 const profile = normalizeProfile(rawProfile)
537 settings = normalizeSettings(rawSettings)
538 await update($, profileAtom, () => profile)
539 await update($, settingsAtom, () => settings)
540}
541
542async function saveSettings($: Api, change: Partial<Settings>) {
543 settings = { ...settings, ...change }
544 await $.store.set('settings', settings)
545 await update($, settingsAtom, () => settings)
546}
547
548const CLEAR_SHOWS = ['clear0', 'clear1', 'clear2', 'clear3', 'clear4', 'clear5'] as const
549
550/** A finished turn: XP for the work, its result in the sky (the files it edited, or DONE), and the streak grows. */
551async function clear($: Api, combo: number, durationMs: number, files: number) {
552 const { before, after, xp } = await serial(async () => {
553 const before = normalizeProfile(await $.store.get('profile'))
554 const xp = turnXp(combo, files, durationMs, before.streak)
555 const after = finishTurn(before, xp, combo)
556 await $.store.set('profile', after)
557 await update($, profileAtom, () => after)
558 return { before, after, xp }
559 })
560 const level = comboTier(combo).level
561 payout($, xp)
562 acknowledge($, `CLEAR +${fmt(xp)} XP`, level >= 5 ? (RAINBOW[0] ?? GOLD) : (LADDER[Math.min(4, level)] ?? '#e9e6f2'), settings.fireworks ? null : 'green', 2600)
563 startShow($, CLEAR_SHOWS[level] ?? 'clear0', { text: files > 0 ? `${files} FILE${files === 1 ? '' : 'S'}` : 'DONE' })
564 if (after.level > before.level) {
565 $.ui.toast(`LEVEL UP LV ${after.level} ${levelTitle(after.level)}`, { timeoutMs: 5000 })
566 $.clock.after(600, () => sfx($, 'levelup'))
567 startShow($, 'level', { text: `LV${after.level}` })
568 }
569 void (async () => {
570 await achieve($, 'first-clear')
571 if (after.streak >= 10) await achieve($, 'streak-10')
572 if (after.level >= 11) await achieve($, 'dan')
573 })().catch(() => {})
574}
575
576/**
577 * Milestones Claude reaches for you, read from a shell command it ran and what that printed: a commit (git's
578 * "[branch abc1234]" line), a push, a pull request opened (its URL) or merged. Reads only; runs nothing.
579 */
580export function readMoments(command: string, output: string, isError: boolean): Array<{ kind: Moment; word: string; detail: string }> {
581 if (isError) return []
582 const found: Array<{ kind: Moment; word: string; detail: string }> = []
583 const commit = /\bgit\s+commit\b/.test(command) ? /\[[^\]\s]+(?: \([^)]*\))? ([0-9a-f]{7,40})\]/.exec(output) : null
584 if (commit) found.push({ kind: 'commit', word: 'COMMIT', detail: commit[1]!.slice(0, 7) })
585 if (/\bgit\s+push\b/.test(command) && !/rejected|error:|fatal:|Everything up-to-date/i.test(output)) found.push({ kind: 'push', word: 'PUSH', detail: '' })
586 const pr = /\bgh\s+pr\s+create\b/.test(command) ? /https?:\/\/\S+\/pull\/(\d+)/.exec(output) : null
587 if (pr) found.push({ kind: 'pr', word: `PR #${pr[1]}`, detail: `#${pr[1]}` })
588 if (/\bgh\s+pr\s+merge\b/.test(command) && !/error|failed|not mergeable/i.test(output)) found.push({ kind: 'merge', word: 'MERGED', detail: '' })
589 return found
590}
591
592// Claude's task list this session: created and done task ids. A task created after all were done starts a new list.
593let tasks = { created: new Set<string>(), done: new Set<string>() }
594let todoDone = 0
595
596/** A task done: done/total in the sky, and the last one of the list as a finale. */
597function onTaskDone($: Api, done: number, total: number) {
598 if (!settings.isOn || !hasPerson() || total === 0) return
599 const isAll = done >= total
600 acknowledge($, `TASK ${done}/${total}`, isAll ? GOLD : (LADDER[2] ?? '#2ed47a'), null, 1300)
601 startShow($, isAll ? 'todoAll' : 'todo', { text: `${done}/${total}` })
602 void award($, isAll ? 40 : 10).catch(() => {})
603}
604
605/** The older TodoWrite tool: the same progress, read from the list Claude wrote. */
606function onTodoWrite($: Api, input: unknown) {
607 const todos = (input as { todos?: unknown }).todos
608 if (!Array.isArray(todos) || todos.length === 0) return
609 const done = todos.filter((t) => (t as { status?: unknown })?.status === 'completed').length
610 if (done > todoDone) onTaskDone($, done, todos.length)
611 todoDone = done
612}
613
614const CHECKER = /\b(tsc|vue-tsc|eslint|ruff|mypy|pyright|biome|cargo (check|clippy|build)|javac|mvn|gradlew?|go (vet|build)|(npm|pnpm|yarn|bun) (run )?(lint|typecheck|type-check|check))\b/
615
616/**
617 * The error count a type checker or linter printed, or null when the command was not one or printed no count.
618 * Reads tsc (its summary, or one line per error when piped) and mypy ("Found 12 errors in"), eslint ("12 errors"), ruff and biome ("Found 12 errors."), pyright,
619 * cargo ("due to 12 previous errors") and javac ("12 errors"); a checker that ran clean and printed none is 0.
620 */
621export function readErrorCount(command: string, output: string, isError: boolean): number | null {
622 if (!CHECKER.test(command)) return null
623 const n = (re: RegExp) => {
624 const m = re.exec(output)
625 return m ? Number(m[1]) : null
626 }
627 // tsc prints its "Found N errors" summary only to a terminal; piped, it prints one line per error.
628 const tscLines = (output.match(/^\S.*?(?:\(\d+,\d+\): error TS\d+|:\d+:\d+ - error TS\d+)/gm) ?? []).length
629 return (
630 n(/Found (\d+) errors? in /) ??
631 (tscLines > 0 ? tscLines : null) ??
632 n(/✖ \d+ problems? \((\d+) errors?/) ??
633 n(/Found (\d+) errors?\./) ??
634 n(/(\d+) errors?, \d+ warnings?, \d+ informations?/) ??
635 n(/could not compile .* due to (\d+) previous errors?/) ??
636 n(/^(\d+) errors?$/m) ??
637 (!isError && !/\berror\b/i.test(output) ? 0 : null)
638 )
639}
640
641// The last error count per command: only the same command run again is compared, so checking the whole project
642// then one file never looks like progress.
643const lastErrors = new Map<string, number>()
644
645async function onErrorCount($: Api, command: string, ran: unknown) {
646 const out = bashOutput(ran)
647 if (!out) return
648 const count = readErrorCount(command, out.text, out.isError)
649 if (count === null) return
650 const key = command.trim().replace(/\s+/g, ' ')
651 const before = lastErrors.get(key)
652 lastErrors.set(key, count)
653 if (before === undefined || count >= before || !settings.isOn || !hasPerson()) return
654 const fixed = before - count
655 $.ui.toast(count === 0 ? `CLEAN ${fixed} error${fixed === 1 ? '' : 's'} fixed` : `ERRORS ${before} to ${count}`, { timeoutMs: 4500 })
656 startShow($, count === 0 ? 'clean' : 'errors', { text: count === 0 ? '0 ERRORS' : `-${fixed} ERRORS` })
657 await award($, Math.min(50, fixed * 5))
658}
659
660const MOMENT_XP: Record<Moment, number> = { commit: 30, push: 40, pr: 60, merge: 80 }
661const MOMENT_TOAST: Record<Moment, string> = { commit: 'COMMIT', push: 'PUSHED', pr: 'PULL REQUEST OPENED', merge: 'MERGED' }
662
663async function onMoments($: Api, command: string, ran: unknown) {
664 const out = bashOutput(ran)
665 if (!out) return
666 for (const m of readMoments(command, out.text, out.isError)) {
667 await serial(async () => {
668 const p = normalizeProfile(await $.store.get('profile'))
669 const next = { ...p, moments: { ...p.moments, [m.kind]: p.moments[m.kind] + 1 } }
670 await $.store.set('profile', next)
671 await update($, profileAtom, () => next)
672 })
673 await update($, statsAtom, (st) => ({ ...st, moments: { ...st.moments, [m.kind]: st.moments[m.kind] + 1 } }))
674 if (!settings.isOn || !hasPerson()) continue
675 $.ui.toast(`${MOMENT_TOAST[m.kind]}${m.detail ? ` ${m.detail}` : ''} +${MOMENT_XP[m.kind]} XP`, { timeoutMs: 5000 })
676 startShow($, m.kind, { text: m.word })
677 await award($, MOMENT_XP[m.kind])
678 if (m.kind === 'commit') await achieve($, 'commit')
679 if (m.kind === 'merge') await achieve($, 'merged')
680 }
681}
682
683async function loseStreak($: Api) {
684 const lost = await serial(async () => {
685 const before = normalizeProfile(await $.store.get('profile'))
686 if (before.streak === 0) return 0
687 const profile = { ...before, streak: 0 }
688 await $.store.set('profile', profile)
689 await update($, profileAtom, () => profile)
690 return before.streak
691 })
692 if (lost >= 2) {
693 $.ui.toast(`STREAK BROKEN ${lost} in a row`, { timeoutMs: 3500 })
694 sfx($, 'streak-lost', 0.6)
695 }
696}
697
698// ---------------------------------------------------------------- meters and alerts
699
700function alertsOn(): boolean {
701 return settings.isOn && settings.alerts && hasPerson()
702}
703
704async function setMeter($: Api, meter: Meter) {
705 await update($, meterAtom, () => meter)
706}
707
708async function refreshUsage($: Api) {
709 if (isUsageBusy) return
710 isUsageBusy = true
711 try {
712 const meter = toMeter(await $.session.usage())
713 await setMeter($, meter)
714 if (meter.ctxPercent !== null) ctxAlerts($, meter.ctxPercent)
715 } catch {
716 // No reading yet: the band keeps the last one.
717 } finally {
718 isUsageBusy = false
719 }
720}
721
722/** Context is HP: warn at 70, 85 and 95 percent used, cheer when it comes back. */
723function ctxAlerts($: Api, used: number) {
724 const prev = lastCtx
725 lastCtx = used
726 if (!alertsOn()) return
727 const left = Math.max(0, 100 - used)
728 if (prev !== null && used <= prev - 25) {
729 $.ui.toast(`HP RECOVER ${left}% of context free`, { timeoutMs: 4000 })
730 sfx($, 'heal', 0.7)
731 startShow($, 'heal')
732 return
733 }
734 const crossed = [95, 85, 70].find((t) => used >= t && (prev === null ? t >= 85 : prev < t))
735 if (crossed === 95) {
736 $.ui.toast(`DANGER context ${used}% full. Auto-compact is near`, { timeoutMs: 6000 })
737 sfx($, 'warn', 0.7)
738 } else if (crossed === 85) {
739 $.ui.toast(`WARNING context ${used}% full. Compact now`, { timeoutMs: 6000 })
740 sfx($, 'warn', 0.7)
741 } else if (crossed === 70) {
742 $.ui.toast(`HP ${left}% context ${used}% full. Wrap up or /compact`, { timeoutMs: 5000 })
743 }
744}
745
746/** Plan limits are STAMINA: warn at 75, 90 and 100 percent used, cheer on a refill. */
747function limitAlerts($: Api, limits: readonly Limit[]) {
748 for (const l of limits) {
749 const prev = lastLimits.get(l.kind)
750 lastLimits.set(l.kind, l.percentUsed)
751 if (!alertsOn()) continue
752 const name = limitName(l.kind)
753 const refill = untilText(l.resetsAt)
754 const when = refill === 'now' ? ', refilling now' : refill ? `, refills in ${refill}` : ''
755 if (prev !== undefined && l.percentUsed <= prev - 30) {
756 $.ui.toast(`STAMINA RECOVER ${name} limit back to ${l.percentUsed}% used`, { timeoutMs: 5000 })
757 sfx($, 'heal', 0.8)
758 startShow($, 'refill')
759 continue
760 }
761 const crossed = [100, 90, 75].find((t) => l.percentUsed >= t && (prev === undefined ? t >= 90 : prev < t))
762 if (crossed === 100) {
763 $.ui.toast(`STAMINA EMPTY ${name} limit used up${when}`, { timeoutMs: 8000 })
764 sfx($, 'warn', 0.8)
765 } else if (crossed === 90) {
766 $.ui.toast(`STAMINA WARNING ${name} limit ${l.percentUsed}% used${when}`, { timeoutMs: 6000 })
767 sfx($, 'warn', 0.7)
768 } else if (crossed === 75) {
769 $.ui.toast(`STAMINA ${Math.max(0, Math.round(100 - l.percentUsed))}% ${name} limit ${l.percentUsed}% used${when}`, { timeoutMs: 5000 })
770 }
771 }
772}
773
774/** Claude is waiting on you. */
775function yourMove($: Api, kind: string, message: string) {
776 if (!alertsOn() || kind === 'auth_success') return
777 const now = Date.now()
778 if (now - lastAlertAt < 4000) return
779 lastAlertAt = now
780 const isNudge = kind === 'idle_prompt'
781 $.ui.toast(`${isNudge ? 'STILL YOUR TURN' : 'YOUR TURN'} ${message}`, { timeoutMs: isNudge ? 4000 : 6000 })
782 sfx($, 'your-move', isNudge ? 0.5 : 0.9)
783}
784
785async function compactNow($: Api) {
786 $.ui.toast('COMPACTING summarizing the conversation', { timeoutMs: 3000 })
787 try {
788 const result = await $.session.compact()
789 if ('skip' in result && result.skip) $.ui.toast(`Compact skipped: ${result.skip}`)
790 else if (lastCtx !== null && lastCtx >= 70) await achieve($, 'self-compact')
791 } catch (err) {
792 // Say what actually happened rather than guessing.
793 const why = err instanceof Error ? err.message : String(err)
794 $.ui.toast(`Compact did not run: ${why}`, { timeoutMs: 6000 })
795 }
796}
797
798// ---------------------------------------------------------------- XP, trophies, tests, CI, the command card, focus
799
800/** XP from anything but a finished turn, with its own LEVEL UP. */
801/** The payout sound: a cascade of pachinko balls, longer for more XP. */
802function payout($: Api, xp: number) {
803 if (xp > 0) sfx($, xp >= 100 ? 'jara-l' : xp >= 30 ? 'jara-m' : 'jara-s', 0.5)
804}
805
806async function award($: Api, xp: number) {
807 payout($, xp)
808 const { before, after } = await serial(async () => {
809 const before = normalizeProfile(await $.store.get('profile'))
810 const after = grantXp(before, xp)
811 await $.store.set('profile', after)
812 await update($, profileAtom, () => after)
813 return { before, after }
814 })
815 if (after.level > before.level && settings.isOn && hasPerson()) {
816 queueToast($, `LEVEL UP LV ${after.level} ${levelTitle(after.level)}`, 'levelup', 'level', { text: `LV${after.level}` })
817 if (after.level >= 11) await achieve($, 'dan')
818 }
819}
820
821async function achieve($: Api, id: TrophyId) {
822 if (!settings.isOn || !hasPerson()) return
823 const trophy = TROPHIES.find((t) => t.id === id)
824 if (!trophy) return
825 const isNew = await serial(async () => {
826 const raw = await $.store.get('trophies')
827 const have = Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
828 if (have.includes(id)) return false
829 await $.store.set('trophies', [...have, id])
830 return true
831 })
832 if (!isNew) return
833 queueToast($, `TROPHY ${trophy.name} +100 XP`, 'trophy', 'trophy')
834 await award($, 100)
835}
836
837async function showTrophies($: Api) {
838 const raw = await $.store.get('trophies')
839 const have = Array.isArray(raw) ? raw : []
840 $.ui.log(`TROPHIES ${TROPHIES.filter((t) => have.includes(t.id)).length}/${TROPHIES.length}`)
841 for (const t of TROPHIES) $.ui.log(have.includes(t.id) ? `✓ ${t.name}` : ` ${t.name}: ${t.hint}`)
842}
843
844/** Text a shell call (Bash or PowerShell) printed, when it ran to the end in the foreground. */
845// Color codes test runners and checkers print even when piped (Vitest does). Stripped before anything is read.
846const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g
847
848function bashOutput(ran: unknown): { text: string; isError: boolean } | null {
849 const r = ran as { result?: unknown; isError?: unknown; deny?: unknown }
850 if (r.deny) return null
851 if (typeof r.result === 'string') return { text: r.result.replace(ANSI, ''), isError: r.isError === true }
852 const out = r.result as Record<string, unknown> | undefined
853 if (!out || typeof out !== 'object' || out.interrupted === true || out.backgroundTaskId) return null
854 const text = `${typeof out.stdout === 'string' ? out.stdout : ''}\n${typeof out.stderr === 'string' ? out.stderr : ''}`
855 return { text: text.replace(ANSI, ''), isError: r.isError === true }
856}
857
858/** Looks at a finished shell call for a test summary or pull request checks. Reads only; runs nothing. */
859async function readBash($: Api, command: string, ran: unknown) {
860 const out = bashOutput(ran)
861 if (!out) return
862 if (TEST_COMMAND.test(command)) {
863 await onTestRun($, parseTestOutput(out.text) ?? { passed: 0, failed: out.isError ? 1 : 0, isKnown: false })
864 } else if (CHECKS_COMMAND.test(command)) {
865 const checks = parseChecks(out.text)
866 if (checks) await onChecks($, checks)
867 }
868}
869
870async function onTestRun($: Api, count: TestCount) {
871 if (count.isKnown && count.passed + count.failed === 0) return
872 const isGreen = count.failed === 0
873 const prev = await read($, testsAtom)
874 const greenStreak = isGreen ? prev.greenStreak + 1 : 0
875 const isComeback = isGreen && prev.wasRed
876 await update($, testsAtom, () => ({ last: { ...count, isGreen, at: Date.now() }, greenStreak, wasRed: !isGreen }))
877 if (!settings.isOn || !hasPerson()) return
878 const tally = count.isKnown ? `${count.passed}/${count.passed + count.failed} passed` : 'passed'
879 if (isComeback) {
880 $.ui.toast(`COMEBACK red to green, ${tally}`, { timeoutMs: 5000 })
881 sfx($, 'jackpot', 0.8)
882 startShow($, 'comeback')
883 await award($, 80)
884 await achieve($, 'comeback')
885 } else if (isGreen) {
886 sfx($, 'green', 0.5)
887 if (greenStreak === 1 || greenStreak % 5 === 0) {
888 $.ui.toast(`ALL GREEN ${tally}${greenStreak > 1 ? ` ${greenStreak} in a row` : ''}`, { timeoutMs: 3500 })
889 }
890 const before = prev.last
891 if (before?.isGreen && before.isKnown && count.isKnown && count.passed > before.passed) {
892 startShow($, 'tests', { text: `+${count.passed - before.passed} TESTS` }) // the suite grew and stays green
893 }
894 await award($, 20)
895 await achieve($, 'all-green')
896 }
897}
898
899async function onChecks($: Api, c: CheckCount) {
900 const prev = await read($, bossAtom)
901 const isDefeated = c.total > 0 && c.failed === 0 && c.pending === 0
902 await update($, bossAtom, () => ({ ...c, at: Date.now(), isDefeated }))
903 if (!settings.isOn || !hasPerson()) return
904 if (isDefeated && !prev?.isDefeated) {
905 $.ui.toast(`BOSS DEFEATED CI ${c.passed}/${c.total} green`, { timeoutMs: 5000 })
906 sfx($, 'boss-down', 0.8)
907 startShow($, 'boss')
908 await award($, 150)
909 await achieve($, 'boss')
910 } else if (c.failed > 0 && (prev?.failed ?? 0) === 0) {
911 $.ui.toast(`BOSS ENRAGED ${c.failed} check${c.failed === 1 ? '' : 's'} failed`, { timeoutMs: 5000 })
912 sfx($, 'warn', 0.6)
913 } else if (!prev && !isDefeated) {
914 $.ui.toast(`BOSS CI ${c.passed}/${c.total} done, ${c.pending} pending`, { timeoutMs: 3500 })
915 }
916}
917
918/** /dopa boss: runs `gh pr checks` once, only because you typed it. */
919async function checkBoss($: Api) {
920 let stdout = ''
921 let stderr = ''
922 try {
923 const out = await $.process.run(['gh', 'pr', 'checks', '--json', 'name,bucket'], { timeoutMs: 20_000 })
924 stdout = out.stdout
925 stderr = out.stderr
926 } catch (err) {
927 $.ui.toast(`BOSS could not run gh: ${err instanceof Error ? err.message : String(err)}`, { timeoutMs: 6000 })
928 return
929 }
930 const checks = parseChecks(stdout)
931 if (!checks) {
932 const why = stderr.trim().split('\n')[0] ?? ''
933 $.ui.toast(`BOSS no checks found${why ? `: ${why}` : ''}`, { timeoutMs: 6000 })
934 return
935 }
936 await onChecks($, checks)
937}
938
939async function listCommands($: Api) {
940 try {
941 return (await $.command.list()).filter((c) => c.name !== 'dopa' && c.description.trim() !== '')
942 } catch {
943 return []
944 }
945}
946
947/** /dopa daily: one command card a day from this install's own command list, so each description is accurate. */
948async function drawDaily($: Api) {
949 const today = localDay()
950 const all = await listCommands($)
951 if (all.length === 0) {
952 $.ui.toast('DAILY CARD no commands to draw from')
953 return
954 }
955 const card = await serial(async () => {
956 const dex = normalizeDex(await $.store.get('dex'))
957 if (dex.day === today && dex.card) return { name: dex.card, isNew: !dex.captured.includes(dex.card), isFresh: false }
958 const fresh = all.filter((c) => !dex.captured.includes(c.name))
959 const pool = fresh.length > 0 ? fresh : all
960 const name = pool[Math.floor(random01() * pool.length)]?.name ?? all[0]!.name
961 await $.store.set('dex', { ...dex, day: today, card: name, isCardCaptured: dex.captured.includes(name) })
962 return { name, isNew: !dex.captured.includes(name), isFresh: true }
963 })
964 if (card.isFresh) sfx($, 'coin', 0.7)
965 const info = all.find((c) => c.name === card.name)
966 const head = `DAILY CARD /${card.name}${card.isNew ? ' NEW' : ''}`
967 $.ui.log(head)
968 if (info) $.ui.log(` ${info.description}`)
969 $.ui.log(card.isNew ? ' Run it today to capture it: +60 XP' : ' Already captured. A new card comes tomorrow')
970 $.ui.toast(head, { timeoutMs: 4000 })
971}
972
973async function showDex($: Api) {
974 const dex = normalizeDex(await $.store.get('dex'))
975 const names = (await listCommands($)).map((c) => c.name)
976 const missing = names.filter((n) => !dex.captured.includes(n))
977 $.ui.log(`DEX ${names.length - missing.length}/${names.length} commands captured`)
978 if (missing.length > 0) {
979 const more = missing.length > 24 ? ` +${missing.length - 24} more` : ''
980 $.ui.log(`Not tried yet: ${missing.slice(0, 24).map((n) => `/${n}`).join(' ')}${more}`)
981 }
982}
983
984/** The first time you run a command, it is captured. The daily card pays extra. */
985/** Runs a command: a new one is captured for XP. Resolves to whether it was new. */
986async function capture($: Api, name: string): Promise<boolean> {
987 const result = await serial(async () => {
988 const dex = normalizeDex(await $.store.get('dex'))
989 if (dex.captured.includes(name)) return null
990 const isDaily = dex.card === name && dex.day === localDay()
991 const next = { ...dex, captured: [...dex.captured, name], isCardCaptured: dex.isCardCaptured || isDaily }
992 await $.store.set('dex', next)
993 return { isDaily, count: next.captured.length }
994 })
995 if (!result) return false
996 const xp = result.isDaily ? 60 : 20
997 $.ui.toast(`CAPTURED /${name}${result.isDaily ? ' daily card' : ''} +${xp} XP`, { timeoutMs: 4000 })
998 sfx($, 'combo', 0.6)
999 await award($, xp)
1000 if (result.count >= 10) await achieve($, 'dex-10')
1001 return true
1002}
1003
1004function armFocus($: Api, f: NonNullable<FocusState>) {
1005 focusDone?.cancel()
1006 focusTick?.cancel()
1007 focusDone = $.clock.after(Math.max(0, f.endsAt - Date.now()), () => {
1008 void finishFocus($).catch(() => {})
1009 })
1010 focusTick = $.clock.every(1000, () => $.ui.invalidate('ui.render'))
1011}
1012
1013function disarmFocus() {
1014 focusDone?.cancel()
1015 focusTick?.cancel()
1016 focusDone = null
1017 focusTick = null
1018}
1019
1020async function startFocus($: Api, kind: 'focus' | 'break', minutes: number) {
1021 const f = { kind, minutes, endsAt: Date.now() + minutes * 60_000 }
1022 await update($, focusAtom, () => f)
1023 armFocus($, f)
1024 $.ui.toast(`${kind === 'focus' ? 'FOCUS' : 'BREAK'} ${minutes} min on the clock`, { timeoutMs: 3000 })
1025}
1026
1027async function stopFocus($: Api) {
1028 disarmFocus()
1029 const f = await read($, focusAtom)
1030 await update($, focusAtom, () => null)
1031 $.ui.toast(f?.kind === 'focus' ? 'FOCUS stopped, no XP this time' : 'Timer stopped', { timeoutMs: 3000 })
1032}
1033
1034async function finishFocus($: Api) {
1035 const f = await read($, focusAtom)
1036 disarmFocus()
1037 if (!f) return
1038 await update($, focusAtom, () => null)
1039 if (!settings.isOn || !hasPerson()) return
1040 if (f.kind === 'break') {
1041 $.ui.toast('BREAK OVER back to it', { timeoutMs: 5000 })
1042 sfx($, 'your-move', 0.8)
1043 return
1044 }
1045 const xp = f.minutes * 5
1046 $.ui.toast(`FOCUS CLEAR ${f.minutes} min +${fmt(xp)} XP`, { timeoutMs: 6000 })
1047 sfx($, 'trophy', 0.8)
1048 startShow($, 'focus')
1049 await award($, xp)
1050 const count = await serial(async () => {
1051 const day = localDay()
1052 const raw = (await $.store.get('focusDay')) as { day?: unknown; count?: unknown } | undefined
1053 const next = raw && raw.day === day && typeof raw.count === 'number' ? raw.count + 1 : 1
1054 await $.store.set('focusDay', { day, count: next })
1055 return next
1056 })
1057 await achieve($, 'focus-1')
1058 if (count >= 4) await achieve($, 'focus-4')
1059 $.clock.after(1500, () => $.ui.toast(count % 4 === 0 ? 'Long break earned: /dopa break 15' : 'Take five: /dopa break', { timeoutMs: 5000 }))
1060}
1061
1062// ---------------------------------------------------------------- drawing helpers
1063
1064/** Text in a ladder color; the rainbow step cycles letter by letter. */
1065function lamp(T: BoxText, text: string, level: number, phase: number, isInverse = false): RenderElement {
1066 if (level < 5) return T.Text({ color: LADDER[Math.max(0, level)]!, bold: true, inverse: isInverse, children: text })
1067 return T.Box({
1068 flexDirection: 'row',
1069 children: Array.from(text).map((ch, i) =>
1070 T.Text({ color: RAINBOW[(i + phase) % RAINBOW.length]!, bold: true, inverse: isInverse, children: ch }),
1071 ),
1072 })
1073}
1074
1075type Piece = { width: number; priority: number; node: RenderElement }
1076
1077function piece(width: number, priority: number, node: RenderElement): Piece {
1078 return { width, priority, node }
1079}
1080
1081/** A piece held at `width` columns however short its text gets, so whatever follows it stays where it is. */
1082function slot(T: BoxText, width: number, priority: number, node: RenderElement): Piece {
1083 return piece(width, priority, T.Box({ minWidth: width, children: [node] }))
1084}
1085
1086/** Lays pieces out in one row, dropping the least important until they fit. */
1087function fitRow(T: BoxText, pieces: Piece[], columns: number, gap = 3): RenderElement {
1088 const kept = pieces.slice()
1089 const width = () => kept.reduce((sum, p) => sum + p.width, 0) + gap * Math.max(0, kept.length - 1)
1090 while (kept.length > 1 && width() > columns) {
1091 let worst = 0
1092 kept.forEach((p, i) => {
1093 if (p.priority > kept[worst]!.priority) worst = i
1094 })
1095 kept.splice(worst, 1)
1096 }
1097 return T.Box({ flexDirection: 'row', columnGap: gap, children: kept.map((p) => p.node) })
1098}
1099
1100function xpBar(p: Profile, width: number): string {
1101 const filled = Math.min(width, Math.round((p.xp / xpToNext(p.level)) * width))
1102 return `▕${'█'.repeat(filled)}${'░'.repeat(width - filled)}▏`
1103}
1104
1105function streakColor(streak: number): string {
1106 return LADDER[Math.min(4, Math.floor(streak / 5))]!
1107}
1108
1109/**
1110 * LV, the XP bar, the XP count and the rank, first in both rows at fixed widths. A file name, the HIT count or a
1111 * chip changing length used to push the XP about, and it should stay where the eye left it.
1112 */
1113function levelPieces(T: BoxText, p: Profile): Piece[] {
1114 const next = xpToNext(p.level)
1115 return [
1116 slot(T, 5, 0, T.Text({ bold: true, children: `LV ${p.level}` })),
1117 piece(14, 1, T.Text({ color: GOLD, children: xpBar(p, 12) })),
1118 // As wide as this level's fullest count, so the count filling up moves nothing either.
1119 slot(T, `${fmt(next)}/${fmt(next)} XP`.length, 3, T.Text({ dimColor: true, children: `${fmt(p.xp)}/${fmt(next)} XP` })),
1120 slot(T, 6, 5, T.Text({ dimColor: true, children: levelTitle(p.level) })),
1121 ]
1122}
1123
1124/** What the person is doing: the typing badge while they write, and the chip for their last action. */
1125type Person = { typed: Piece | null; chip: Piece | null }
1126const NOBODY: Person = { typed: null, chip: null }
1127
1128/** Between turns: the level block, the streak, the typing badge while you write (or the help hint), then the chip. */
1129function idleRow(T: BoxText, p: Profile, columns: number, person: Person = NOBODY): RenderElement {
1130 const pieces = levelPieces(T, p)
1131 if (p.streak >= 2) {
1132 const t = `STREAK ${p.streak}`
1133 pieces.push(piece(t.length, 2, T.Text({ color: streakColor(p.streak), bold: true, children: t })))
1134 }
1135 // The badge takes the hint's place while you write, so neither one moves anything.
1136 pieces.push(person.typed ?? piece(10, 6, T.Text({ dimColor: true, children: '/dopa help' })))
1137 if (person.chip) pieces.push(person.chip)
1138 return fitRow(T, pieces, columns)
1139}
1140
1141// Room for " 999 HIT " and the longest tier word, the files count, and what Claude is touching.
1142const HITS_WIDTH = 19
1143const FILES_WIDTH = 8
1144const MOVE_WIDTH = 30
1145
1146/** While Claude works: the level block, the hit count in its ladder color, the files this turn has edited, what it is touching. */
1147function feverRow(T: BoxText, turn: TurnInfo, p: Profile, columns: number, phase: number, now: number, person: Person = NOBODY): RenderElement {
1148 const tier = comboTier(turn.combo)
1149 const isHit = turn.combo > 0 && now - lastHitAt < 280
1150 const hits = turn.combo === 0 ? 'READY' : `${turn.combo} HIT`
1151 const files = turnFiles.size > 0 ? `${turnFiles.size} file${turnFiles.size === 1 ? '' : 's'}` : ''
1152 const pieces = levelPieces(T, p)
1153 pieces.push(
1154 slot(T, HITS_WIDTH, 0, T.Box({
1155 flexDirection: 'row',
1156 columnGap: 1,
1157 children: [
1158 turn.combo === 0 ? T.Text({ dimColor: true, children: ` ${hits} ` }) : lamp(T, ` ${hits} `, tier.level, phase, isHit),
1159 tier.word ? lamp(T, tier.word, tier.level, phase) : null,
1160 ],
1161 })),
1162 // Held open before the first edit too, so the move after it does not jump when a file lands.
1163 slot(T, FILES_WIDTH, 2, T.Text({ color: '#2ed47a', bold: isHit, children: files })),
1164 )
1165 pieces.push(slot(T, MOVE_WIDTH, 4, T.Text({ dimColor: true, children: turn.move })))
1166 if (person.typed) pieces.push(person.typed)
1167 if (person.chip) pieces.push(person.chip)
1168 return fitRow(T, pieces, columns)
1169}
1170
1171function meterBar(left: number, width: number): string {
1172 const filled = Math.max(0, Math.min(width, Math.round((left / 100) * width)))
1173 return '█'.repeat(filled) + '░'.repeat(width - filled)
1174}
1175
1176function gauge(T: BoxText, label: string, left: number, color: string, tail: string, isUrgent: boolean, priority: number): Piece {
1177 return piece(
1178 label.length + 10 + tail.length,
1179 priority,
1180 T.Box({
1181 flexDirection: 'row',
1182 children: [
1183 T.Text({ color, bold: true, inverse: isUrgent, children: label }),
1184 T.Text({ color, children: ` ${meterBar(left, 8)} ` }),
1185 isUrgent ? T.Text({ color, bold: true, children: tail }) : T.Text({ dimColor: true, children: tail }),
1186 ],
1187 }),
1188 )
1189}
1190
1191/** HP (context left), STAMINA (plan limit left, with its refill), GOLD (cost), LOOT (files edited). */
1192function statusRow(T: BoxText, m: Meter | null, stats: SessionStats, columns: number, extra: RenderElement | null, more: Piece[] = []): RenderElement | null {
1193 const pieces: Piece[] = []
1194 if (m && m.ctxPercent !== null) {
1195 const left = Math.max(0, 100 - m.ctxPercent)
1196 const color = left >= 50 ? '#2ed47a' : left >= 25 ? GOLD : PINK
1197 const tail = m.ctxTokens !== null ? `${short(Math.max(0, m.ctxWindow - m.ctxTokens))} ctx left` : `${left}% ctx left`
1198 pieces.push(gauge(T, 'HP', left, color, tail, left < 15, 0))
1199 }
1200 const ranked = [...(m?.limits ?? [])].sort((a, b) => b.percentUsed - a.percentUsed)types/index.d.ts 94 lines1// The state contract for DOPA MODE (DOPA is short for dopamine). Claude Code reads it to check every
2// $.state value the hooks module reads or writes.
3
4/** Milestones Claude reached for you. */
5export type Moment = 'commit' | 'push' | 'pr' | 'merge'
6
7export type Profile = {
8 level: number
9 /** XP inside the current level. */
10 xp: number
11 totalXp: number
12 /** Turns finished. */
13 clears: number
14 /** Turns finished in a row without an interrupt. */
15 streak: number
16 bestStreak: number
17 bestCombo: number
18 moments: Record<Moment, number>
19}
20
21export type Settings = {
22 isOn: boolean
23 sfx: boolean
24 spinner: boolean
25 /** The HP / STAMINA / GOLD / LOOT row under the HUD. */
26 meters: boolean
27 /** Threshold warnings, YOUR MOVE and QUEST CLEAR. */
28 alerts: boolean
29 typing: boolean
30 fireworks: boolean
31}
32
33/** One plan limit window, as $.session.usage() reports it. */
34export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
35
36/** The latest usage reading: context fill, plan limits and cost. */
37export type Meter = {
38 ctxPercent: number | null
39 ctxTokens: number | null
40 ctxWindow: number
41 limits: Limit[]
42 usd: number | null
43}
44
45/** What this session has done, for the band and /dopa recap. */
46export type SessionStats = {
47 /** Date.now() when the session (or the last /clear) started. */
48 startedAt: number
49 turns: number
50 toolCalls: number
51 tools: Record<string, number>
52 /** Edited files, relative to the session's directory, with edit counts. */
53 files: Record<string, number>
54 inputTokens: number
55 cacheReadTokens: number
56 outputTokens: number
57 workMs: number
58 bestCombo: number
59 moments: Record<Moment, number>
60}
61
62export type TurnInfo = {
63 isWorking: boolean
64 combo: number
65 /** Date.now() when the turn started. */
66 startedAt: number
67 word: string
68 move: string
69}
70
71/** The last test run Claude made, read from its output. */
72export type TestRun = { passed: number; failed: number; isKnown: boolean; isGreen: boolean; at: number }
73export type TestState = { last: TestRun | null; greenStreak: number; wasRed: boolean }
74
75/** Pull request checks, read from gh output. */
76export type BossState = { total: number; passed: number; failed: number; pending: number; at: number; isDefeated: boolean }
77
78export type FocusState = { kind: 'focus' | 'break'; minutes: number; endsAt: number } | null
79
80declare module 'claude-code' {
81 interface PluginState {
82 'dopa-mode': {
83 profile: Profile
84 settings: Settings
85 turn: TurnInfo
86 meter: Meter | null
87 stats: SessionStats
88 tests: TestState
89 boss: BossState | null
90 focus: FocusState
91 }
92 }
93}
94