SLOPSHOPPER

dopa-mode

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

newbandspinnerguardcommandtoast
★ 1v1.4.0MITupdated 2026-10-03charimsma/dopa-mode/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dopa-mode
› fix the failing auth test and add an audit log call ╭─────────────╮ │ dopa-mode │ ● dopa-mode: /dopa fireworks launch a show (on|off: every firework on or off) │ 5 HIT GOOD │ ● dopa-mode: /dopa typing the TYPE meter, typing pips and GO! on or off ╰─────────────╯ ⏺ 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 › /dopa ● dopa-mode: /dopa recap this session: files edited, moves, tokens, limits (/dopa recap copy: to the clipboard) ● dopa-mode: /dopa daily draw today's command card; run that command to capture it ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

DOPA MODE

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.

CI Claude Code 2.1.287+ Model calls: 0 License: MIT

<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.

インストール / Install

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.

花火が上がるとき / What goes up

花火は入力欄の上の枠に囲まれた空に点字のドットで描かれ、音も付きます。<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 type1 文字ごとに 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 EnterGO!。長いプロンプトほど大きく上がります<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 questionYES・NO・OK。どの答えも同じ見た目なので、どれかに誘導しません<br>YES, NO or OK, all drawn alike, so no answer is favored
ターンが終わったとき<br>A turn finishesClaude がしたこと: 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 oneCOMMIT / PUSH / フラッシュを浴びる PR #42 / MERGED<br>COMMIT / PUSH / PR #42 under camera flashes / MERGED
Claude がタスクを 1 つ終えたとき<br>Claude finishes a task on its list3/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 growsPASS / +4 TESTS
CI がすべて通ったとき<br>CI goes all greenWIN
compact でコンテキストが空いたとき、プランの上限が回復したとき<br>Compacting frees up context, or your plan limit resetsHEAL / REFILL
レベルが上がったとき、トロフィーを取ったとき、集中タイマーを終えたとき<br>You level up, earn a trophy or finish a focus sessionLV10 / 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.

HUD

  • HP はコンテキストの残りです。減るにつれて黄色から赤に変わり、85% 使うと WARNING、95% で DANGER が出ます。残りが少なくなると Compact now ボタンも出ます。<br> HP is the context you have left. It turns yellow, then red, as it drains. WARNING pops up when 85% of the context is used, and DANGER at 95%. When HP runs low, a Compact now button appears.
  • STAMINA は 5 時間枠と週の枠のうち、厳しいほうの残りです。回復までの時間も出ます。<br> STAMINA is what's left of your plan limit (the 5-hour or weekly one, whichever is tighter), with the time until it refills.
  • GOLD はこのセッションの費用、LOOT は編集したファイルの数です。TESTS と CI には、直近のテストと PR のチェックの結果が出ます。<br> GOLD is the session cost and LOOT the files edited. TESTS and CI show the last test run and the pull request checks.
  • Claude が許可を待って止まると、YOUR TURN で知らせます。作業中は目を離していて大丈夫です。<br> When Claude stops to wait for your permission, YOUR TURN lets you know, so you can look away while it works.
  • 入力中は、HIT のバッジが GOOD、GREAT、EXCELLENT、FEVER、RAINBOW と上がっていきます。<br> While you type, a HIT badge climbs through GOOD, GREAT, EXCELLENT, FEVER and RAINBOW.
  • ターンを終えるたび、コミットや PR のたびに XP がたまります。段位は 10 KYU から 9 DAN を経て MEIJIN まで。<br> Finished turns, commits and pull requests pay XP. Ranks run from 10 KYU through 9 DAN to MEIJIN.

テスト・CI・そのほか / Tests, CI and the rest

  • Claude がテストを実行すると、その結果の要約を読みます。対応しているのは Jest、Vitest、Mocha、pytest、unittest、Maven、Gradle、cargo、go test、RSpec、PHPUnit、.NET、Deno、Bun です。<br> It reads the summary whenever Claude runs tests. Supported runners: Jest, Vitest, Mocha, pytest, unittest, Maven, Gradle, cargo, go test, RSpec, PHPUnit, .NET, Deno and Bun.
  • 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.
  • 自分のコマンド一覧から引く 今日のコマンドカード、トロフィー、集中タイマー、朝会のメモにそのまま貼れる セッションのまとめ もあります。<br> There is also a daily command card drawn from your own command list, trophies, a focus timer and a session recap you can paste straight into your standup notes.

見ているだけ / It only watches

  • プロンプト、ツールの結果、会話の記録は一切変えません。Claude に見えるものは、mod がないときと同じです。<br> No prompt, tool result or transcript entry is ever changed. Claude sees exactly what it would see without the mod.
  • コミット、PR、エラー数、テスト結果は Claude のコマンドが出力したものから、タスクとサブエージェントは Claude Code が送るイベントから読みます。そのために何かを実行することはありません。<br> Commits, pull requests, error counts and test results are read from what Claude's commands printed, and tasks and subagents from events Claude Code sends. Nothing is run to get them.
  • 許可の確認は見ているだけで、答えるのはいつもあなたです。<br> Permission prompts are only watched: the answer is always yours.
  • 入力欄は文字数を数えるためだけに見ています。文字そのものは保存も記録も変更もしません。<br> The prompt box is watched only to count characters. The text is never stored, logged or changed.
  • 起動できるプロセスは 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 Code のプラグイン用の保存領域に置かれます。<br> Your level, trophies and settings stay on your machine, in Claude Code's local plugin storage.

claude plugin validate ./plugin を実行すると、フックしているイベントと呼び出せる処理がすべて表示されます。<br> claude plugin validate ./plugin prints the whole footprint: every event it hooks and every call it can make.

コマンド / Commands

コマンド<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 confirmLV 1 からやり直します(トロフィーも消えます)<br>Start over from LV 1 (this also erases your trophies)

よくある質問 / FAQ

トークンを使いますか?<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.

数字の意味 / What the numbers mean

  • HP: モデルのコンテキストの大きさから、直前のリクエストで使った入力を引いたものです(ステータスラインと同じ値です)。0 になる前に自動の compact が始まることがあります。<br> HP: the model's context window minus the input the last request used (the same figure the status line shows). Auto-compact can start before it reaches 0.
  • STAMINA: 5 時間枠と週の枠のうち、厳しいほうの残りです。直近の API の応答が伝えた値で、サブスクリプションのプランでだけ出ます。<br> STAMINA: plan usage left in the tighter of the 5-hour and weekly windows, as the last API response reported it. Subscription plans only.
  • GOLD: /cost が合計するこのセッションの費用で、API の価格で計算します。サブスクリプションなら請求額ではありません。<br> GOLD: the session cost as /cost totals it, at API prices. On a subscription, it isn't what you're billed.
  • LOOT: Edit、Write、NotebookEdit で編集したファイルです。シェルを使った編集は数えません。<br> LOOT: files edited with Edit, Write or NotebookEdit. Edits made through a shell are not counted.
  • HIT: 今のターンのツール呼び出しの数で、サブエージェントの分も含みます。入力中は、入力欄に打った文字の数です。<br> HIT: tool calls in the current turn, subagents included; while you type, the characters typed into the prompt box.
  • XP と段位は、ゲームの中だけのものです。<br> XP and ranks exist only in the game.

関連する mod / Related mods

どれか 1 つの機能だけが欲しいなら、次の mod がよくできています。<br> If you only want one of these features, the mods below do it well:

機能<br>Featuremod
スロットマシンのスピナー<br>Slot-machine spinnerClaude Slots
使用量<br>Usagecctop, context-lens, quota-meter and token-ledger
CIcc-pr-tracker, gh-ci-status
ゲーム<br>Gamescc-arcade, claude-games, cc-pokedex
ポモドーロ<br>PomodoroClaudoro
XP と実績<br>XP and achievementsclaude-rank, claude-code-achievements

開発に参加する / Contributing

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.

ライセンス / License

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.

Source 2 files
hooks/dopa-mode.ts 2821 lines
1// 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 lines
1// 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