/dash でセッションの計器盤(経過・ターン・ツール・使用量)をペインに SVG で表示する。出す枠と順を自分で並べられる

Claude Code の関数フック mod を集めたリポジトリです。 ペイン・入力欄の上の帯・枠・トーストを使い、VS Code 拡張でもターミナルでも見た目が崩れないように作っています。 VS Code では SVG のグラフやアニメ(SMIL / CSS)も描けます。
Anthropic とは関係のない、個人が作った非公式の mod です。
| mod | 呼び方 | 何をするか |
|---|---|---|
| mod-hub | /mod | 画面のある mod(ペインや帯を出す物)をアイコン付きで並べる。名前・説明で絞る、固定して上に置く、カテゴリー別(開発・状況・道具・遊び)・名前順・最近開いた順。見出しに季節の小さな飾り |
| cc-dash | /dash | セッションの計器盤。経過・ターン・ツール・エラーのタイル、ターンの所要時間の棒グラフ、ツールのタイムライン、使用量のリング型ゲージ。「自分の並び」で出す枠と順を自分で決められる |
| work-bar | 入力欄の上に自動で出る | 作業中は経過時間・今のツール・流れる光の帯。待ち中は前のターンのまとめと定型文のボタン |
| aqua-pet | /aquarium | 作業に反応する水槽。魚は 1 匹ずつターンで 稚魚 → 若魚 → 成魚 → 大物 と育ち、節目で新しい魚(全 10 種、後ほど珍しい)が来る。エラーで赤く震える。えさ・名前・掃除・魚の一覧のボタン。日本時間の時刻で水の色と光が(夕は橙で沈む日、夜は月と星)、季節で飾り(花びら・泡・落ち葉・雪)が変わる。turn-gacha で集めたカードでかざり(水草・石・流木・貝・つぼ・城・宝箱・沈没船)が手に入り、底の 5 つの置き場に置ける(城と沈没船の周りを魚が回る)。記録は毎日控えを取り、ペインから戻せる |
| project-board | /projects | フォルダに並んだ引き継ぎメモ(*.md)の鮮度グラフと一覧・詳細 |
| git-pane | /git | 今のフォルダの git の様子。ブランチ・変更行数の差分バー・直近コミット。git には書き込まない |
| token-meter | /tokens | ターンごとのトークンの積み上げ棒とキャッシュの当たり率 |
| focus-timer | /focus-timer | ポモドーロ。減っていくリングと今日の本数 |
| session-recap | /session-recap | セッションのふりかえり。ターンの帯のタイムラインと、押した時だけ作る要約 |
| edit-log | /edits | このセッションで Claude が Edit / Write などの編集ツールで変えたファイルの時系列。押すと git の差分(HEAD と比べる)と「レビューして」 |
| todo-board | /todos | 今のフォルダの TODO / FIXME / HACK / XXX の件数・推移・一覧。git の外はフォルダを歩いて読む。種類のボタンで絞り、押すと「片付けて」 |
| test-runner | /testrun | 設定したテストのコマンドをボタンで走らせる。信号と直近 20 回の結果、失敗したら「直して」。Claude が走らせたテストも拾い、落ちている間は入力欄の上に 1 行出す。落ちたままコミットしようとしたらトーストで知らせる |
| prompt-book | /prompts | よく使う依頼文を保存して、ボタンで入力欄へ。タグ・使った回数・並び替え |
| live-commentary | /jikkyo | Claude の動きをスポーツ実況風にステータス行へ。口調は熱血・冷静・関西 |
| turn-gacha | /gacha | ターンが終わるたびにガチャ。N / R / SR / SSR のカード 60 種と図鑑、集まり具合の実績 6 個。出たカードは dev-rpg で装備にできる。daily-quest のお題をこなすと、おまけで引ける。記録は毎日控えを取り、「控えから戻す」で戻せる |
| dev-rpg | /rpg | 作業すると経験値がたまる RPG。レベル・称号・実績 22 個。ターンを終えるとたまる「魔物の気配」で魔物 8 種・ボス 3 体とボタンで戦う。ボスは時々湧き、湧いたボスは気配を使わずに挑めて経験値 1.5 倍。戦いで減った HP は 15 分休むか新しいセッションで戻る。レベル・実績・勝利・ボスで入る SP を能力(力・体力・知恵・敏捷)に振り、スキル 10 種を覚える。turn-gacha のカードを武器・防具・お守りに装備して能力が上がる。daily-quest のお題をこなすと経験値が入る。ターンで潜るダンジョン(8 ターンで 1 階、10 階ごとに番人、5 階ごとに休み場)で一番深い階を目指し、勝った分は「帰る」で持ち帰る。記録は毎日控えを取れる |
| code-omikuji | /omikuji | 今日のコード運勢。筒を振ると棒の番号が回って止まり、和紙のおみくじが開く。文は 238 通りと、その季節の日だけ出る一言 |
| agent-watch | /agent-watch | 動いているサブエージェントの帯のタイムライン(経過・ツール回数・状態)。ボタンでその会話へ切り替え、メインへ戻る |
| canvas-pane | /canvas | Claude が呼べるツール show を足す。「売上をグラフにしてペインに出して」と頼むと、棒・折れ線・円・表・Markdown・SVG をペインに描く。直近 20 枚をめくれる |
| rule-switch | /rules | 「日本語で答える」「commit しない」などの約束ごとをボタンでオン/オフし、システムプロンプトに入れる。自分の約束も足せる |
| wait-games | /games | 長いターンの間に遊ぶ 2048・マインスイーパー・15 パズル・ライツアウト・数独(4x4 / 9x9)・神経衰弱。ゲームごとに勝ち数・最短手数・最速を残す。ターンが終わったら知らせる。記録は毎日控えを取り、「控えから戻す」で戻せる |
| spec-board | /specs | SpecStatus の確認待ち・途中の仕様書と次のおすすめ。押すと詳細(読むだけ) |
| ci-pane | /ci | 今のブランチの PR(状態・レビュー・チェックの丸)と GitHub Actions の結果。gh を使う。何かを起こす操作は無い |
| port-watch | /ports | 待ち受け中の TCP ポートと、セッション中に増えた物の印。URL を写すボタンと、開発用の実行環境だけ 2 回押しで止めるボタン |
| pre-check | /precheck | コミット前の確かめ。変更の要約・秘密情報らしい文字列(足した行だけ、伏せて表示)・衝突の印・1MB 超のファイル・テスト(ボタンで) |
| face-band | /face で出す / 隠す | 待ち・考え中・ツール実行中・答え終わり・エラーで変わる Claude の顔。日本時間の時間帯と季節で小物が変わる(夜は眠そうにナイトキャップ、夏は麦わら帽子、冬はマフラーなど)。入れただけでは出ない(最初はオフ)。work-bar と並べて出る |
| done-chime | /chime | 長いターン(既定 30 秒以上)が終わったら音を鳴らす。音の種類・しきい値・音量・試し鳴らし |
| turn-haiku | /haiku | ターンが終わるたびに、やった事を五七五の 1 句にして縦書きの短冊に。季節の季語の候補を添えて詠む。過去の句の一覧 |
| session-timeline | /timeline | このセッションのターンとツール呼び出しを横の帯の年表で。ツールの種類で色分け、失敗は赤い印、サブエージェントは細い帯、1 分以上のターンは黄の縁 |
| file-map | /filemap | このセッションで読んだ・書いたファイルを、フォルダごとの四角(ツリーマップ)で。大きいほど多く触った所、橙は書いた物、青は読んだだけ |
| toast-inbox | /inbox | ほかの mod が出したトーストを 100 件まで残す受け箱(設定でステータス行も)。未読の数・mod ごとの件数・mod で絞る・全部既読・空にする。通知の行を押すと、それを出した mod のペインを開く |
| usage-history | /history | 日をまたいだ使い方の記録。日ごとのターン数か出力トークンの棒(7 日 / 30 日)、合計と 1 日平均、曜日と時間帯のヒートマップ。直近 7 日の数をほかの mod(week-card)にも見せる。記録は毎日控えを取れる |
| fail-log | /errors | ツールが失敗した記録を 200 件まで残す。似た失敗をまとめた「よく落ちる物」の棒、最近の一覧、今日・通算の数。サブエージェントの分は紫で分ける |
| daily-quest | /quest | 日替わりのお題 3 つ(ターン・ファイルを直す・テスト・コミットなど 8 種から)。進みの棒・ごほうび・連続日数・直近 7 日。ごほうびは dev-rpg が経験値、turn-gacha がおまけの抽選で受け取る。記録は毎日控えを取り、「控えから戻す」で戻せる |
| trophy-shelf | /trophies | dev-rpg・turn-gacha・aqua-pet・wait-games・daily-quest の記録を 1 枚の棚にまとめ、トロフィーを初めて取った日付つきで集める。ダンジョンの深さのトロフィー 3 つ(番人越え・地下三十階・百階の主)も |
| mod-check | /mod-check | mod がちゃんと動いているかを 1 回で確かめる。入っている・コマンドあり・予備名・見つからないの数と、mod ごとの判定(本来の名前か予備名か、本来の名前を使っている相手)、画面・テーマ・Claude Code の版をペインに出し、同じ結果を 1 行 1 mod の貼れる文で返す。隣の mod の版がセッション中に変わったら、帯とトーストで開き直しを頼む。「点検表」タブで実画面の見え方に よい・だめ・まだ を付け、結果を貼る文にする |
| compact-cue | 入力欄の上に自動で出る・/cue | コンテキストの使用量が多くなった時、テストが通った・コミットした・引き継ぎメモを書いた直後なら「今が /compact のよい時」、編集の途中なら「区切りまで待って」と帯とトーストで知らせる。ペインは使用量のリングと作業の様子。知らせるだけで compact はしない |
| ship-guard | 常時・/guard | Claude が git push・gh release・gh repo create・npm publish を打つ時、直前に AskUserQuestion で公開してよいと答えていなければ、その場で「実行しますか?」と聞き、断られたら止める。ペインは記録とオン/オフ。git push の前は、送るコミットの作者が noreply か、足した行に個人のメール・ユーザー名入りのパスが無いかも確かめ、引っかかったら確認済みでも聞き直す |
| rule-watch | 入力欄の上に自動で出る・/rulewatch | Claude の返事が約束ごとから外れた時に帯で知らせる。英語で答えた・AskUserQuestion を使わずに「〜しますか?」で終わった・コミットの後に引き継ぎメモや開発ログを書いていない・開発ログを書いたのに「📝」の 1 行が無い・締めの言葉なのに開発ログが無い、の 6 つ。ペインは数と記録、「Claude に伝える」で入力欄に一言入れる |
| enc-guard | 常時・/enc | 文字コード・改行の罠を Claude が踏んだ時に、直し方を Claude に伝える(設定で止めるにもできる)。-Encoding の無い Set-Content / Add-Content、日本語入りで BOM の無い .ps1、ASCII 以外の字が入った .bat / .cmd、Windows で encoding= / newline= の無い Python の open |
| release-draft | /release | 前のタグから今までのコミットと、版が上がった plugin.json / package.json を集めて、CHANGELOG の下書き(Added / Changed / Fixed)と次の版の候補を出す。「これで CHANGELOG を書いて」で Claude に頼む。git は読むだけ |
| reply-frame | 常時 | 返答をオレンジ、自分の発言を青の角丸の枠で囲む |
| danger-frame | 常時 | 危ないシェルのコマンドの行を赤い枠で囲む |
| long-turn-toast | 常時 | 長くかかったターン(既定 120 秒超)をトーストで知らせる |
| usage-pane | /usage-pane | 使用量の JSON を 3 本のバーで表示 |
| devlog-pane | /devlog | フォルダにある今日の開発ログ(YYYY-MM-DD.md)を表示 |
| run-guard | 常時・/runguard | Claude が確かめのために窓のあるアプリを起動する・音を鳴らす・claude -p を隔離なしで走らせるのを、実行の前に止めて直し方(-WindowStyle Hidden・--headless・一時の設定フォルダ)を返す。組ごとにオン/オフでき、記録も残る |
| week-card | /week | 1 週間の使い方と、お題・ガチャの図鑑・冒険の書(レベルとダンジョン)・水槽の増え方を 1 枚のカードにまとめ、週報に貼る文(Markdown)を入力欄に入れる |
reply-frame は takosasi-dev/reply-frame でも公開しています。ここに入っているのは、自分の発言の枠を足した新しい版です。
VS Code のペインに描かれる SVG の部分です(架空のデータ。ボタンなどは省いています)。node tools/shots.mts で作り直せます(--light で明るいテーマ)。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.png" width="250" alt="mod-hub"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.png" width="250" alt="cc-dash"></td> <td align="center" valign="top"><b>work-bar</b><br><img src="docs/images/work-bar.png" width="250" alt="work-bar"></td> </tr> <tr> <td align="center" valign="top"><b>face-band</b><br><img src="docs/images/face-band.png" width="250" alt="face-band"></td> <td align="center" valign="top"><b>aqua-pet</b><br><img src="docs/images/aqua-pet.png" width="250" alt="aqua-pet"></td> <td align="center" valign="top"><b>project-board</b><br><img src="docs/images/project-board.png" width="250" alt="project-board"></td> </tr> <tr> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane.png" width="250" alt="git-pane"></td> <td align="center" valign="top"><b>token-meter</b><br><img src="docs/images/token-meter.png" width="250" alt="token-meter"></td> <td align="center" valign="top"><b>focus-timer</b><br><img src="docs/images/focus-timer.png" width="250" alt="focus-timer"></td> </tr> <tr> <td align="center" valign="top"><b>session-recap</b><br><img src="docs/images/session-recap.png" width="250" alt="session-recap"></td> <td align="center" valign="top"><b>edit-log</b><br><img src="docs/images/edit-log.png" width="250" alt="edit-log"></td> <td align="center" valign="top"><b>todo-board</b><br><img src="docs/images/todo-board.png" width="250" alt="todo-board"></td> </tr> <tr> <td align="center" valign="top"><b>test-runner</b><br><img src="docs/images/test-runner.png" width="250" alt="test-runner"></td> <td align="center" valign="top"><b>pre-check</b><br><img src="docs/images/pre-check.png" width="250" alt="pre-check"></td> <td align="center" valign="top"><b>ci-pane</b><br><img src="docs/images/ci-pane.png" width="250" alt="ci-pane"></td> </tr> <tr> <td align="center" valign="top"><b>spec-board</b><br><img src="docs/images/spec-board.png" width="250" alt="spec-board"></td> <td align="center" valign="top"><b>port-watch</b><br><img src="docs/images/port-watch.png" width="250" alt="port-watch"></td> <td align="center" valign="top"><b>prompt-book</b><br><img src="docs/images/prompt-book.png" width="250" alt="prompt-book"></td> </tr> <tr> <td align="center" valign="top"><b>agent-watch</b><br><img src="docs/images/agent-watch.png" width="250" alt="agent-watch"></td> <td align="center" valign="top"><b>canvas-pane</b><br><img src="docs/images/canvas-pane.png" width="250" alt="canvas-pane"></td> <td align="center" valign="top"><b>rule-switch</b><br><img src="docs/images/rule-switch.png" width="250" alt="rule-switch"></td> </tr> <tr> <td align="center" valign="top"><b>live-commentary</b><br><img src="docs/images/live-commentary.png" width="250" alt="live-commentary"></td> <td align="center" valign="top"><b>turn-gacha</b><br><img src="docs/images/turn-gacha.png" width="250" alt="turn-gacha"></td> <td align="center" valign="top"><b>dev-rpg</b><br><img src="docs/images/dev-rpg.png" width="250" alt="dev-rpg"></td> </tr> <tr> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.png" width="250" alt="code-omikuji"></td> <td align="center" valign="top"><b>turn-haiku</b><br><img src="docs/images/turn-haiku.png" width="250" alt="turn-haiku"></td> <td align="center" valign="top"><b>wait-games</b><br><img src="docs/images/wait-games.png" width="250" alt="wait-games"></td> </tr> <tr> <td align="center" valign="top"><b>session-timeline</b><br><img src="docs/images/session-timeline.png" width="250" alt="session-timeline"></td> <td align="center" valign="top"><b>file-map</b><br><img src="docs/images/file-map.png" width="250" alt="file-map"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.png" width="250" alt="usage-history"></td> </tr> <tr> <td align="center" valign="top"><b>toast-inbox</b><br><img src="docs/images/toast-inbox.png" width="250" alt="toast-inbox"></td> <td align="center" valign="top"><b>done-chime</b><br><img src="docs/images/done-chime.png" width="250" alt="done-chime"></td> <td align="center" valign="top"><b>fail-log</b><br><img src="docs/images/fail-log.png" width="250" alt="fail-log"></td> </tr> <tr> <td align="center" valign="top"><b>daily-quest</b><br><img src="docs/images/daily-quest.png" width="250" alt="daily-quest"></td> <td align="center" valign="top"><b>trophy-shelf</b><br><img src="docs/images/trophy-shelf.png" width="250" alt="trophy-shelf"></td> <td align="center" valign="top"><b>mod-check</b><br><img src="docs/images/mod-check.png" width="250" alt="mod-check"></td> </tr> <tr> <td align="center" valign="top"><b>compact-cue</b><br><img src="docs/images/compact-cue.png" width="250" alt="compact-cue"></td> <td align="center" valign="top"><b>ship-guard</b><br><img src="docs/images/ship-guard.png" width="250" alt="ship-guard"></td> <td align="center" valign="top"><b>rule-watch</b><br><img src="docs/images/rule-watch.png" width="250" alt="rule-watch"></td> </tr> <tr> <td align="center" valign="top"><b>enc-guard</b><br><img src="docs/images/enc-guard.png" width="250" alt="enc-guard"></td> <td align="center" valign="top"><b>release-draft</b><br><img src="docs/images/release-draft.png" width="250" alt="release-draft"></td> <td align="center" valign="top"><b>run-guard</b><br><img src="docs/images/run-guard.png" width="250" alt="run-guard"></td> </tr> <tr> <td align="center" valign="top"><b>week-card</b><br><img src="docs/images/week-card.png" width="250" alt="week-card"></td> </tr> </table>
ペインの絵は開いた時に 1 回動きます(数字が増える・棒が伸びる・おみくじが開く)。node tools/gif.mts <mod> で作り直せます。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub.gif" width="250" alt="mod-hub(動く絵)"></td> <td align="center" valign="top"><b>cc-dash</b><br><img src="docs/images/cc-dash.gif" width="250" alt="cc-dash(動く絵)"></td> </tr> <tr> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history.gif" width="250" alt="usage-history(動く絵)"></td> <td align="center" valign="top"><b>code-omikuji</b><br><img src="docs/images/code-omikuji.gif" width="250" alt="code-omikuji(動く絵)"></td> </tr> </table>
Claude Code の /config で theme を明るい物(light で始まる物)にすると、SVG も明るい色で描きます(VS Code の配色は mod から読めないので、Claude Code の theme に合わせます)。
<table> <tr> <td align="center" valign="top"><b>mod-hub</b><br><img src="docs/images/mod-hub-light.png" width="250" alt="mod-hub(明るいテーマ)"></td> <td align="center" valign="top"><b>git-pane</b><br><img src="docs/images/git-pane-light.png" width="250" alt="git-pane(明るいテーマ)"></td> <td align="center" valign="top"><b>usage-history</b><br><img src="docs/images/usage-history-light.png" width="250" alt="usage-history(明るいテーマ)"></td> </tr> </table>
好きな mod だけ入れられます。冒険の書(dev-rpg)とガチャ(turn-gacha)の 2 本だけが欲しい時は、単体版の claude-code-dev-rpg からも入れられます(中身は同じなので、どちらか片方から入れてください)。ターミナルの Claude Code の入力欄で:
/plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
y でマーケットプレイスを足し、スコープを選ぶと入ります。シェルからは:
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
デスクトップアプリでは、入力欄の横の + → Plugins → Add marketplace に takosasi-dev/claude-code-mods を入れ、Add plugin で選びます。
mod-hub に並ぶのは、mod-hub より後に読み込まれる mod だけです。mod-hub を最初に入れてください。カテゴリーとアイコンはこのリポジトリの mod だけに付いていて、ほかの mod は「その他」に入ります。toast-inbox と mod-check も、ほかの mod より後から読み込まれる物を見るので、mod-hub の次に入れるのがおすすめです。
コマンドの名前がほかのプラグインや組み込みと被った時は、/edit-log・/todo-board・/test-runner・/prompt-book・/live-commentary・/turn-gacha・/dev-rpg・/code-omikuji・/gitpane・/token-meter・/mod-hub・/canvas-pane・/rule-switch・/wait-games・/spec-board・/ci-pane・/port-watch・/pre-check・/face-band・/done-chime・/turn-haiku・/session-timeline・/file-map・/toast-inbox・/usage-history・/fail-log・/compact-cue・/ship-guard・/rule-watch・/enc-guard・/release-draft・/daily-quest・/trophy-shelf・/modcheck・/run-guard・/week-card のように mod の名前のコマンドになります。
claude plugin install は 1 回に 1 本だけ受け取ります。まとめて入れる時は、下の行のかたまりをシェル(bash・zsh・PowerShell のどれでも)にそのまま貼ってください。最初の 1 本でマーケットプレイスが足され、2 本目からはそれを使います。スコープは既定で user(全プロジェクト)で、-s project を付けると今のプロジェクトだけに入ります。mod-hub・toast-inbox・mod-check を先に入れてから、ほしいカテゴリーを開いて貼ります。
最初に(mod-hub・toast-inbox・mod-check)
claude plugin install mod-hub --marketplace takosasi-dev/claude-code-mods
claude plugin install toast-inbox --marketplace takosasi-dev/claude-code-mods
claude plugin install mod-check --marketplace takosasi-dev/claude-code-mods
claude plugin install project-board --marketplace takosasi-dev/claude-code-mods
claude plugin install git-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install edit-log --marketplace takosasi-dev/claude-code-mods
claude plugin install todo-board --marketplace takosasi-dev/claude-code-mods
claude plugin install test-runner --marketplace takosasi-dev/claude-code-mods
claude plugin install spec-board --marketplace takosasi-dev/claude-code-mods
claude plugin install ci-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install port-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install pre-check --marketplace takosasi-dev/claude-code-mods
claude plugin install fail-log --marketplace takosasi-dev/claude-code-mods
claude plugin install ship-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install enc-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install release-draft --marketplace takosasi-dev/claude-code-mods
claude plugin install run-guard --marketplace takosasi-dev/claude-code-mods
claude plugin install cc-dash --marketplace takosasi-dev/claude-code-mods
claude plugin install work-bar --marketplace takosasi-dev/claude-code-mods
claude plugin install token-meter --marketplace takosasi-dev/claude-code-mods
claude plugin install session-recap --marketplace takosasi-dev/claude-code-mods
claude plugin install agent-watch --marketplace takosasi-dev/claude-code-mods
claude plugin install session-timeline --marketplace takosasi-dev/claude-code-mods
claude plugin install file-map --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-history --marketplace takosasi-dev/claude-code-mods
claude plugin install compact-cue --marketplace takosasi-dev/claude-code-mods
claude plugin install usage-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install devlog-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install focus-timer --marketplace takosasi-dev/claude-code-mods
claude plugin install prompt-book --marketplace takosasi-dev/claude-code-mods
claude plugin install canvas-pane --marketplace takosasi-dev/claude-code-mods
claude plugin install rule-switch --marketplace takosasi-dev/claude-code-mods
claude plugin install done-chime --marketplace takosasi-dev/claude-code-mods
claude plugin install aqua-pet --marketplace takosasi-dev/claude-code-mods
claude plugin install live-commentary --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-gacha --marketplace takosasi-dev/claude-code-mods
claude plugin install dev-rpg --marketplace takosasi-dev/claude-code-mods
claude plugin install code-omikuji --marketplace takosasi-dev/claude-code-mods
claude plugin install wait-games --marketplace takosasi-dev/claude-code-mods
claude plugin install face-band --marketplace takosasi-dev/claude-code-mods
claude plugin install turn-haiku --marketplace takosasi-dev/claude-code-mods
claude plugin install daily-quest --marketplace takosasi-dev/claude-code-mods
claude plugin install trophy-shelf --marketplace takosasi-dev/claude-code-mods
claude plugin install week-card --marketplace takosasi-dev/claude-code-mods
claude plugin install reply-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install danger-frame --marketplace takosasi-dev/claude-code-mods
claude plugin install long-turn-toast --marketplace takosasi-dev/claude-code-mods
全部(48 本)をまとめて(bash / zsh):
for m in mod-hub toast-inbox mod-check cc-dash work-bar aqua-pet project-board git-pane token-meter focus-timer session-recap edit-log todo-board test-runner prompt-book live-commentary turn-gacha dev-rpg code-omikuji agent-watch canvas-pane rule-switch wait-games spec-board ci-pane port-watch pre-check face-band done-chime turn-haiku session-timeline file-map usage-history compact-cue fail-log ship-guard rule-watch enc-guard release-draft daily-quest trophy-shelf reply-frame danger-frame long-turn-toast usage-pane devlog-pane run-guard week-card; do claude plugin install "$m" --marketplace takosasi-dev/claude-code-mods; done
PowerShell:
foreach ($m in 'mod-hub','toast-inbox','mod-check','cc-dash','work-bar','aqua-pet','project-board','git-pane','token-meter','focus-timer','session-recap','edit-log','todo-board','test-runner','prompt-book','live-commentary','turn-gacha','dev-rpg','code-omikuji','agent-watch','canvas-pane','rule-switch','wait-games','spec-board','ci-pane','port-watch','pre-check','face-band','done-chime','turn-haiku','session-timeline','file-map','usage-history','compact-cue','fail-log','ship-guard','rule-watch','enc-guard','release-draft','daily-quest','trophy-shelf','reply-frame','danger-frame','long-turn-toast','usage-pane','devlog-pane','run-guard','week-card') { claude plugin install $m --marketplace takosasi-dev/claude-code-mods }
この一覧は node tools/plugin_dirs.mts --readme で作り直せます(--en で英語版)。
リポジトリを clone して、そのフォルダから読ませることもできます。
takosasi-dev/claude-code-mods を clone 先のパス(例 D:\work\claude-code-mods)に変えて入れます。mod はそのフォルダから直接読まれるので、git pull の後に /reload-plugins で新しい版になりますCLAUDE_CODE_PLUGIN_DIRS で読ませる: 入れずに、セッションを始める時に毎回フォルダから読みます。値は clone 先で node tools/plugin_dirs.mts を走らせると出ます(別の場所を指すなら node tools/plugin_dirs.mts D:\work\claude-code-mods)。mod-hub・toast-inbox・mod-check を先頭に、全 mod のフォルダを Windows は ;、ほかは : でつないだ 1 行です。これを環境変数か、~/.claude/settings.json の env の CLAUDE_CODE_PLUGIN_DIRS に入れます(道具は settings.json を書き換えません)。要らない mod はその行から消してくださいD:\work\claude-code-mods\mods\mod-hub;D:\work\claude-code-mods\mods\toast-inbox;D:\work\claude-code-mods\mods\cc-dash;...
/haiku の「自動で詠む」で止められますtaskkill /T /F)。node で動く MCP サーバーも開発用に見えるので、止める前に名前とポートを確かめてください。macOS / Linux(lsof)の実機では未確認ですgit config --global --add safe.directory <フォルダ> を足してください$.ui.toast / $.ui.status を呼んだ時だけです。「空にする」はほかのセッションの記録も消しますgit grep で読みます(.gitignore の物は読みません)。git の外では作業フォルダを歩いて読み(2000 ファイル・5000 件・8 秒で止め、1 MiB を超えるファイルと . で始まるフォルダ・node_modules・dist などは読みません)、この時はファイルを変えても自動では読み直さないので「再読込」を押してくださいnpm test・pytest など)で見ます。ごほうびは dev-rpg と turn-gacha がターンの終わりに 1 回だけ受け取りますhooks/register.tsx 415 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CcDashCall, CcDashLayout, CcDashPanel, CcDashTab, CcDashUsage } from '../types'
5import { knownOrder, layoutOf, move, objOf, PANEL_LABEL, reset, setShown, shownOf } from './layout'
6import { clean, clock, dur, gaugesSvg, left, overviewSvg, shortTool, TOOL_COLORS, timelineSvg, toolBarsSvg, turnsSvg } from './svg'
7import type { Gauge } from './svg'
8import { followTheme, mayWrite, readTheme, rememberVersion } from './style'
9
10const PANE = 'cc-dash'
11const TITLE = '計器盤'
12const KEEP_CALLS = 200
13const KEEP_TURNS = 20
14const LAYOUT_KEY = 'layout'
15
16const tabA = atom({ plugin: 'cc-dash', key: 'tab' } as const, 'overview' as CcDashTab)
17const startedA = atom({ plugin: 'cc-dash', key: 'startedAt' } as const, 0)
18const turnCountA = atom({ plugin: 'cc-dash', key: 'turnCount' } as const, 0)
19const turnDurA = atom({ plugin: 'cc-dash', key: 'turnDurations' } as const, [] as number[])
20const runningA = atom({ plugin: 'cc-dash', key: 'runningSince' } as const, null as number | null)
21const callsA = atom({ plugin: 'cc-dash', key: 'calls' } as const, [] as CcDashCall[])
22const countsA = atom({ plugin: 'cc-dash', key: 'toolCounts' } as const, {} as Record<string, number>)
23const totalA = atom({ plugin: 'cc-dash', key: 'toolTotal' } as const, 0)
24const errorsA = atom({ plugin: 'cc-dash', key: 'errorCount' } as const, 0)
25const usageA = atom({ plugin: 'cc-dash', key: 'usage' } as const, null as CcDashUsage | string | null)
26const layoutA = atom({ plugin: 'cc-dash', key: 'layout' } as const, layoutOf(undefined))
27const arrangingA = atom({ plugin: 'cc-dash', key: 'isArranging' } as const, false)
28
29const TABS: [CcDashTab, string][] = [
30 ['overview', '概要'],
31 ['tools', 'ツール'],
32 ['usage', '使用量'],
33 ['mine', '自分の並び'],
34]
35
36/** 各タブに出す枠 (自分の並びは store の順) */
37const TAB_PANELS: Record<Exclude<CcDashTab, 'mine'>, CcDashPanel[]> = {
38 overview: ['overview', 'turns'],
39 tools: ['toolBars', 'timeline'],
40 usage: ['usage'],
41}
42
43const UNSET = '使用量ファイルが未設定です。/config で cc-dash の usageFile に JSON の絶対パスを入れてください'
44
45// 絶対パス(/x、\x、C:\x、C:/x)だけを受け付ける。相対パスは今のフォルダから読まれてしまう
46const isAbsolute = (path: string) => /^(?:[\\/]|[A-Za-z]:[\\/])/.test(path)
47
48type $ = EngineInterface
49
50/** 使用量ファイルを読む。読めなければ理由の文字列 */
51const readUsage = async ($: $, file: string): Promise<CcDashUsage | string> => {
52 let raw: string
53 try {
54 raw = await $.fs.read(file)
55 } catch (err) {
56 return clean(`使用量ファイルを読めません: ${file} (${err instanceof Error ? err.message : String(err)})`)
57 }
58 try {
59 const u = JSON.parse(raw) as CcDashUsage | null
60 if (!u || typeof u.sessionPct !== 'number' || typeof u.weeklyPct !== 'number') throw new Error('bad')
61 return u
62 } catch {
63 return clean(`使用量ファイルを読めません: ${file} (中身の形が違います)`)
64 }
65}
66
67/** 使用量を読んで $.state に置く。描画フックの外(30 秒タイマーと使用量を出した時)で呼ぶ */
68async function refreshUsage($: $, file: string) {
69 if (file === '') return
70 const u = await readUsage($, file)
71 await update($, usageA, () => u)
72}
73
74/** 今のタブに出ている枠 */
75const panelsOf = async ($: $, tab: CcDashTab) => (tab === 'mine' ? shownOf(await read($, layoutA)) : TAB_PANELS[tab])
76
77// 並びの書き込みは 1 本ずつ流す (ボタンを続けて押しても読み直しと書き込みが重ならない)
78let chain: Promise<unknown> = Promise.resolve()
79const serial = (f: () => Promise<void>) => {
80 const p = chain.then(f, f)
81 chain = p.catch(() => {})
82 return p
83}
84
85/**
86 * 並びを変える。書く直前に store を読み直し、その値に変更を当てて、知らない欄と知らない枠は残して書く。
87 * 読めない時 (例外・形が違う) と、新しい版が入ったのに古い版のまま動いている時は、書かずに画面だけ変える
88 * (別のセッションや新しい版の並びを消さない)
89 */
90const changeLayout = ($: $, f: (l: CcDashLayout) => CcDashLayout) =>
91 serial(async () => {
92 let raw: unknown
93 try {
94 raw = await $.store.get(LAYOUT_KEY)
95 } catch {
96 await update($, layoutA, f)
97 return
98 }
99 if (!isLayoutShape(raw) || !(await mayWrite($, await $.clock.now()))) {
100 await update($, layoutA, f)
101 return
102 }
103 const next = f(layoutOf(raw))
104 await update($, layoutA, () => next)
105 await $.store.set(LAYOUT_KEY, { ...objOf(raw), ...next }).catch(() => {})
106 })
107
108/** store の並びとして読める形か (まだ無い・物で、order と hidden は無いか配列) */
109const isLayoutShape = (raw: unknown) => {
110 if (raw === undefined) return true
111 const o = objOf(raw)
112 return o === raw && (o.order === undefined || Array.isArray(o.order)) && (o.hidden === undefined || Array.isArray(o.hidden))
113}
114
115const gaugesOf = (u: CcDashUsage, now: number): Gauge[] => [
116 { label: '5時間枠', pct: u.sessionPct, left: left(u.sessionResetAt, now) },
117 { label: '週', pct: u.weeklyPct, left: left(u.weeklyResetAt, now) },
118 ...(typeof u.modelName === 'string' && typeof u.modelPct === 'number'
119 ? [{ label: `${clean(u.modelName)} 週`, pct: u.modelPct, left: left(u.modelResetAt, now) }]
120 : []),
121]
122
123const fetchedText = (u: CcDashUsage, now: number) =>
124 typeof u.fetchedAt === 'number' ? `取得: ${Math.max(0, Math.floor((now - u.fetchedAt) / 60_000))}分前` : ''
125
126export const register: Register = (on, options) => {
127 const raw = String(options.usageFile ?? '').trim()
128 // 絶対パスでなければ未設定と同じ扱い
129 const file = isAbsolute(raw) ? raw : ''
130 followTheme(on)
131
132 on('session.start', async ($, e, next) => {
133 await readTheme($)
134 await rememberVersion($).catch(() => {})
135 await $.command.register({ name: 'dash', description: 'セッションの計器盤をペインに表示' }).catch(() => {})
136 // リロードでも session.start は来る。最初の 1 回だけ時刻を入れる
137 const now = await $.clock.now()
138 await update($, startedA, v => v || now)
139 try {
140 const saved = await $.store.get(LAYOUT_KEY)
141 await update($, layoutA, () => layoutOf(saved))
142 } catch {
143 // 読めなければ初めの並びのまま
144 }
145 // 30 秒ごとに、数字が変わる時だけ描き直す (使用量は読み直して毎回、概要は経過の分が変わった時、ツールは実行中がある時)
146 let lastMinute = -1
147 $.clock.every(30_000, async () => {
148 const tab = await read($, tabA)
149 const shown = (await read($, arrangingA)) && tab === 'mine' ? [] : await panelsOf($, tab)
150 if (shown.includes('usage')) await refreshUsage($, file)
151 const minute = Math.floor(((await $.clock.now()) - (await read($, startedA))) / 60_000)
152 const isDue =
153 shown.includes('usage') ||
154 (shown.includes('overview') && minute !== lastMinute) ||
155 (shown.includes('timeline') && (await read($, callsA)).some(c => c.end === undefined))
156 lastMinute = minute
157 if (isDue) await $.ui.invalidate('ui.render')
158 })
159
160 return next(e)
161 })
162
163 // /clear で数え直す
164 on('session.end', async ($, e, next) => {
165 if (e.reason === 'clear') {
166 const now = await $.clock.now()
167 await update($, startedA, () => now)
168 await update($, turnCountA, () => 0)
169 await update($, turnDurA, () => [])
170 await update($, runningA, () => null)
171 await update($, callsA, () => [])
172 await update($, countsA, () => ({}))
173 await update($, totalA, () => 0)
174 await update($, errorsA, () => 0)
175 }
176 return next(e)
177 })
178
179 on('command.run', { command: 'dash' }, async $ => {
180 await $.ui.open({ id: PANE, title: TITLE })
181
182 return { text: '計器盤を開きました。' }
183 })
184
185 // メインループのターンだけ数える (サブエージェントのターンは agentId 付きで来る)
186 on('turn.start', async ($, e, next) => {
187 if (e.agentId === undefined) {
188 const now = await $.clock.now()
189 await update($, runningA, () => now)
190 }
191 return next(e)
192 })
193
194 on('turn.complete', async ($, e, next) => {
195 if (e.agentId === undefined) {
196 await update($, turnDurA, list => [...list, e.durationMs].slice(-KEEP_TURNS))
197 await update($, turnCountA, n => n + 1)
198 await update($, runningA, () => null)
199 // 答えが返らないまま終わった呼び出し(中断など)を締める。エラーには数えない
200 const end = await $.clock.now()
201 await update($, callsA, list => (list.some(c => c.end === undefined) ? list.map(c => (c.end === undefined ? { ...c, end } : c)) : list))
202 }
203 return next(e)
204 })
205
206 // 見るだけのフック。落ちても呼び出しは止めない (.catch で next の結果をそのまま返す)
207 on('tool.call', async ($, e, next) => {
208 if (e.agentId !== undefined) return next(e)
209 const tool = clean(String(e.tool))
210 const start = await $.clock.now()
211 const id = e.tool_use_id ?? `${tool}-${start}-${Math.random()}`
212 await update($, callsA, list => [...list, { id, tool, start, isError: false }].slice(-KEEP_CALLS))
213 await update($, countsA, c => ({ ...c, [tool]: (c[tool] ?? 0) + 1 }))
214 await update($, totalA, n => n + 1)
215
216 // 中断(next が投げる)・deny はエラーに数えない (work-bar とそろえる)
217 let isError = false
218 try {
219 const r = await next(e)
220 isError = r.isError === true
221 return r
222 } finally {
223 const end = await $.clock.now()
224 await update($, callsA, list => list.map(c => (c.id === id ? { ...c, end, isError } : c)))
225 if (isError) await update($, errorsA, n => n + 1)
226 }
227 }).catch(($, e, next) => next(e))
228
229 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
230 const t = $.ui.resolve(e)
231 const { Box, Text, Button } = t
232 const tab = await read($, tabA)
233 const now = await $.clock.now()
234
235 const tabs = (
236 <Box key="tabs" flexDirection="row" flexWrap="wrap" gap={1}>
237 {TABS.map(([id, label]) => (
238 <Button
239 key={`tab-${id}`} dimColor={tab !== id}
240 label={label}
241 variant={tab === id ? 'primary' : 'secondary'}
242 onPress={async () => {
243 await update($, tabA, () => id)
244 // 使用量を出すタブを開いた時に読む
245 if ((await panelsOf($, id)).includes('usage')) await refreshUsage($, file)
246 }}
247 />
248 ))}
249 </Box>
250 )
251
252 // terminal の部品表にも Svg の名前はあるので、画面の種類でも分ける
253 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
254
255 // ツールの色は回数の順位で決まる。回数を読む(購読する)のは、ツールの枠を描く時だけ
256 const colorFn = async () => {
257 const counts = await read($, countsA)
258 const ranked = Object.entries(counts).sort((a, b) => b[1] - a[1])
259 const rank = new Map(ranked.map(([tool], i) => [tool, i]))
260 return { ranked, colorOf: (tool: string) => TOOL_COLORS[(rank.get(tool) ?? 0) % TOOL_COLORS.length] ?? TOOL_COLORS[0]! }
261 }
262
263 /** 枠 1 つ。読むのはその枠に要る値だけ */
264 const panel = async (id: CcDashPanel) => {
265 if (id === 'overview') {
266 const startedAt = await read($, startedA)
267 const o = {
268 elapsedMs: startedAt ? now - startedAt : 0,
269 turns: await read($, turnCountA),
270 tools: await read($, totalA),
271 errors: await read($, errorsA),
272 isRunning: (await read($, runningA)) !== null,
273 }
274 return Svg ? (
275 <Svg key="header" source={overviewSvg(o)} alt={`経過 ${clock(o.elapsedMs)} ターン ${o.turns} ツール ${o.tools} エラー ${o.errors}`} />
276 ) : (
277 <Box key="overview" flexDirection="column">
278 <Text key="status" color={o.isRunning ? 'green' : undefined}>{o.isRunning ? '● 実行中' : '○ 待機中'}</Text>
279 <Text key="summary">{`経過 ${clock(o.elapsedMs)} ターン ${o.turns} ツール ${o.tools} エラー ${o.errors}`}</Text>
280 </Box>
281 )
282 }
283 if (id === 'turns') {
284 const durations = await read($, turnDurA)
285 return Svg ? (
286 <Svg key="turns" source={turnsSvg(durations)} alt="直近20ターンの所要時間の棒グラフ" />
287 ) : (
288 <Text key="last" dimColor>
289 {durations.length ? `最新ターン ${dur(durations[durations.length - 1] ?? 0)}` : 'まだ完了したターンがありません'}
290 </Text>
291 )
292 }
293 if (id === 'toolBars') {
294 const { ranked, colorOf } = await colorFn()
295 const top = ranked.slice(0, 8)
296 const total = await read($, totalA)
297 return Svg ? (
298 <Svg key="tool-bars" source={toolBarsSvg(top, total, colorOf)} alt="ツール別の呼び出し回数の横棒グラフ" />
299 ) : (
300 <Box key="tool-counts" flexDirection="column">
301 {top.length === 0 && <Text dimColor>まだ呼び出しがありません</Text>}
302 {top.map(([tool, n]) => (
303 <Text key={`count-${tool}`}>{`${shortTool(tool)} ${n}`}</Text>
304 ))}
305 </Box>
306 )
307 }
308 if (id === 'timeline') {
309 const { colorOf } = await colorFn()
310 const calls = await read($, callsA)
311 if (Svg) return <Svg key="timeline" source={timelineSvg(calls, now, colorOf)} alt="直近30回の呼び出しのタイムライン" />
312 const recent = calls.slice(-30)
313 const running = recent.filter(c => c.end === undefined).length
314 const failed = recent.filter(c => c.isError).length
315 return (
316 <Text key="timeline-text" dimColor>
317 {recent.length ? `直近${recent.length}回の呼び出し 実行中 ${running} 失敗 ${failed}` : '直近の呼び出しはまだありません'}
318 </Text>
319 )
320 }
321 // 使用量: 描画では読まない。読むのは 30 秒タイマーと使用量を出した時
322 const u = file === '' ? UNSET : ((await read($, usageA)) ?? '読み込み中…')
323 if (typeof u === 'string') {
324 return (
325 <Text key="usage-error" dimColor>
326 {u}
327 </Text>
328 )
329 }
330 const gauges = gaugesOf(u, now)
331 return Svg ? (
332 <Svg key="gauges" source={gaugesSvg(gauges, fetchedText(u, now))} alt={gauges.map(g => `${g.label} ${g.pct}%`).join(' ')} />
333 ) : (
334 <Box key="usage" flexDirection="column">
335 {gauges.map(g => (
336 <Text key={`gauge-${g.label}`} color={g.pct > 80 ? 'red' : g.pct > 50 ? 'yellow' : undefined}>
337 {`${g.label} ${Math.round(g.pct)}% ${g.left}`}
338 </Text>
339 ))}
340 </Box>
341 )
342 }
343
344 const panels = async (ids: CcDashPanel[]) => {
345 const out = []
346 for (const id of ids) out.push(await panel(id))
347 return out
348 }
349
350 let body
351 if (tab !== 'mine') {
352 body = (
353 <Box key={tab} flexDirection="column" gap={1}>
354 {await panels(TAB_PANELS[tab])}
355 </Box>
356 )
357 } else if (await read($, arrangingA)) {
358 // 並べ替えの画面: 枠ごとに名前と「上へ」「下へ」「出す」/「隠す」
359 const layout = await read($, layoutA)
360 const order = knownOrder(layout)
361 body = (
362 <Box key="arrange" flexDirection="column" gap={1}>
363 <Text dimColor>出す枠を選び、上から出す順に並べます</Text>
364 {order.map((id, i) => {
365 const isHidden = layout.hidden.includes(id)
366 return (
367 <Box key={`row-${id}`} flexDirection="column">
368 <Text key={`name-${id}`} bold={!isHidden} dimColor={isHidden}>
369 {`${i + 1}. ${PANEL_LABEL[id]}${isHidden ? ' 隠しています' : ''}`}
370 </Text>
371 <Box flexDirection="row" flexWrap="wrap" gap={1}>
372 {i > 0 && <Button key={`up-${id}`} dimColor label="上へ" onPress={() => changeLayout($, l => move(l, id, -1))} />}
373 {i < order.length - 1 && <Button key={`down-${id}`} dimColor label="下へ" onPress={() => changeLayout($, l => move(l, id, 1))} />}
374 {isHidden ? (
375 <Button key={`show-${id}`} dimColor label="出す" onPress={() => changeLayout($, l => setShown(l, id, true))} />
376 ) : (
377 <Button key={`hide-${id}`} dimColor label="隠す" onPress={() => changeLayout($, l => setShown(l, id, false))} />
378 )}
379 </Box>
380 </Box>
381 )
382 })}
383 <Box flexDirection="row" flexWrap="wrap" gap={1}>
384 <Button
385 key="arrange-done"
386 label="並べ終わる"
387 variant="primary"
388 onPress={async () => {
389 await update($, arrangingA, () => false)
390 if (shownOf(await read($, layoutA)).includes('usage')) await refreshUsage($, file)
391 }}
392 />
393 <Button key="arrange-reset" dimColor label="初めの並びに戻す" onPress={() => changeLayout($, reset)} />
394 </Box>
395 </Box>
396 )
397 } else {
398 const shown = shownOf(await read($, layoutA))
399 body = (
400 <Box key="mine" flexDirection="column" gap={1}>
401 {shown.length === 0 ? <Text dimColor>出す枠がありません。「並べ替える」で選んでください</Text> : await panels(shown)}
402 <Button key="arrange" dimColor label="並べ替える" onPress={() => update($, arrangingA, () => true)} />
403 </Box>
404 )
405 }
406
407 return (
408 <Box flexDirection="column" gap={1}>
409 {tabs}
410 {body}
411 </Box>
412 )
413 })
414}
415hooks/layout.ts 55 lines1// 「自分の並び」の枠の順と隠す枠。store の 'layout' に { order, hidden } で残す。
2// 知らない枠の名前(新しい版で足された枠)と知らない欄は消さずに書き戻す
3import type { CcDashLayout, CcDashPanel } from '../types'
4
5export const PANELS: readonly CcDashPanel[] = ['overview', 'turns', 'toolBars', 'timeline', 'usage']
6
7export const PANEL_LABEL: Record<CcDashPanel, string> = {
8 overview: 'セッションのタイル',
9 turns: 'ターンの所要時間',
10 toolBars: 'ツール別の回数',
11 timeline: '直近の呼び出し',
12 usage: '使用量',
13}
14
15export const isPanel = (v: unknown): v is CcDashPanel => PANELS.includes(v as CcDashPanel)
16
17/** 物なら中身、そうでなければ空 (知らない欄を残すための元) */
18export const objOf = (v: unknown): Record<string, unknown> =>
19 typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
20
21const strings = (v: unknown) => (Array.isArray(v) ? [...new Set(v.filter((x): x is string => typeof x === 'string'))] : [])
22
23/** store の生の値を並びにする。足りない枠は後ろに足す。知らない名前はその場に残す */
24export function layoutOf(raw: unknown): CcDashLayout {
25 const o = objOf(raw)
26 const order = strings(o.order)
27 return { order: [...order, ...PANELS.filter(p => !order.includes(p))], hidden: strings(o.hidden) }
28}
29
30/** 画面に出す順 (知っている枠だけ) */
31export const knownOrder = (l: CcDashLayout) => l.order.filter(isPanel)
32export const shownOf = (l: CcDashLayout) => knownOrder(l).filter(p => !l.hidden.includes(p))
33
34/** 隣の知っている枠と入れ替える。知らない名前は飛ばして、その位置に残す */
35export function move(l: CcDashLayout, id: CcDashPanel, dir: -1 | 1): CcDashLayout {
36 const order = [...l.order]
37 const i = order.indexOf(id)
38 let j = i + dir
39 while (j >= 0 && j < order.length && !isPanel(order[j])) j += dir
40 if (i < 0 || j < 0 || j >= order.length) return l
41 ;[order[i], order[j]] = [order[j]!, order[i]!]
42 return { ...l, order }
43}
44
45export const setShown = (l: CcDashLayout, id: CcDashPanel, isShown: boolean): CcDashLayout => ({
46 ...l,
47 hidden: isShown ? l.hidden.filter(h => h !== id) : [...l.hidden.filter(h => h !== id), id],
48})
49
50/** 初めの並びに戻す (知らない名前はそのまま後ろに残す) */
51export const reset = (l: CcDashLayout): CcDashLayout => ({
52 order: [...PANELS, ...l.order.filter(p => !isPanel(p))],
53 hidden: l.hidden.filter(p => !isPanel(p)),
54})
55hooks/svg.ts 232 lines1// SVG を文字列で組む関数。描画面は <img> なので SMIL だけで動かす
2import type { CcDashCall } from '../types'
3import { C, HEADER_H, W, clean, countUp, esc, frame, growBar, header } from './style'
4
5// ツールの色。赤はエラー専用なので外す。テーマに付いていくよう、要素を読むたびに C から引く
6// (配列の形のままにしておく。tools/shots.mts が TOOL_COLORS[i % TOOL_COLORS.length] と読む)
7const TOOL_KEYS = ['blue', 'green', 'purple', 'orange', 'yellow'] as const
8export const TOOL_COLORS: readonly string[] = TOOL_KEYS.reduce(
9 (a, k, i) => Object.defineProperty(a, i, { get: () => C[k], enumerable: true }),
10 [] as string[],
11)
12
13export { clean, esc }
14
15const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 2)}..` : s)
16
17/** mcp__server__tool は最後の部分だけにする */
18export const shortTool = (tool: string) => (tool.startsWith('mcp__') ? tool.split('__').pop() || tool : tool)
19
20/** 経過時間 h:mm */
21export const clock = (ms: number) => {
22 const min = Math.max(0, Math.floor(ms / 60_000))
23 return `${Math.floor(min / 60)}:${String(min % 60).padStart(2, '0')}`
24}
25
26/** 所要時間 12秒 / 3分5秒 / 0.4秒 */
27export const dur = (ms: number) => {
28 if (ms < 10_000) return `${(Math.max(0, ms) / 1000).toFixed(1)}秒`
29 const s = Math.round(ms / 1000)
30 return s < 60 ? `${s}秒` : `${Math.floor(s / 60)}分${s % 60}秒`
31}
32
33/** リセットまでの残り (usage-pane の left() を写した物) */
34export const left = (iso: unknown, now: number) => {
35 // 小数秒が 6 桁でも読めるよう 3 桁に切る
36 const at = typeof iso === 'string' ? Date.parse(iso.replace(/(\.\d{3})\d+/, '$1')) : NaN
37 if (Number.isNaN(at)) return ''
38 if (at <= now) return 'リセット済み'
39 const min = Math.floor((at - now) / 60_000)
40 const d = Math.floor(min / 1440)
41 const h = Math.floor(min / 60) % 24
42 const m = min % 60
43 return d > 0 ? `あと${d}日${h}時間` : h > 0 ? `あと${h}時間${m}分` : `あと${m}分`
44}
45
46const text = (x: number, y: number, s: string, size: number, fill: string, extra = '') =>
47 `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" ${extra}>${esc(s)}</text>`
48
49/** 見出しの下から中身を始める y */
50const TOP = HEADER_H + 12
51
52// ---- 概要 ----
53// 概要はツールを呼ぶたびに描き直す(数字が変わる)ので、動きは足さない (mod-guide 2 節)
54
55export type Overview = { elapsedMs: number; turns: number; tools: number; errors: number; isRunning: boolean }
56
57export const overviewSvg = (o: Overview) => {
58 const pulse = o.isRunning
59 ? `<circle cx="${W - 74}" cy="23" r="5" fill="${C.green}">` +
60 `<animate attributeName="r" values="4;9;4" dur="1.4s" repeatCount="indefinite"/>` +
61 `<animate attributeName="opacity" values="1;0.25;1" dur="1.4s" repeatCount="indefinite"/></circle>` +
62 `<circle cx="${W - 74}" cy="23" r="3.5" fill="${C.green}"/>` +
63 text(W - 16, 28, '実行中', 13, C.green, 'text-anchor="end" font-weight="bold"')
64 : text(W - 16, 28, '待機中', 13, C.dim, 'text-anchor="end"')
65 const tw = (W - 32 - 12) / 2
66 const th = 74
67 const tile = (i: number, label: string, value: string, edge: string, valueFill: string = C.text) => {
68 const x = 16 + (i % 2) * (tw + 12)
69 const y = TOP + Math.floor(i / 2) * (th + 12)
70 return (
71 `<rect x="${x}" y="${y}" width="${tw}" height="${th}" rx="8" fill="${C.face}" stroke="${edge}" stroke-width="1.5"/>` +
72 `<rect x="${x + 10}" y="${y}" width="${tw - 20}" height="3" fill="${edge}"/>` +
73 text(x + 12, y + 24, label, 12, C.dim) +
74 text(x + 12, y + 60, value, 28, valueFill, 'font-weight="bold"')
75 )
76 }
77 return frame(
78 TOP + th * 2 + 12 + 16,
79 header('セッション', '', o.isRunning ? C.green : C.blue) +
80 pulse +
81 tile(0, '経過 (時:分)', clock(o.elapsedMs), C.blue) +
82 tile(1, 'ターン', String(o.turns), C.green) +
83 tile(2, 'ツール呼び出し', String(o.tools), C.yellow) +
84 tile(3, 'エラー', String(o.errors), C.red, o.errors > 0 ? C.red : C.text),
85 )
86}
87
88export const turnColor = (ms: number) => (ms > 300_000 ? C.red : ms > 120_000 ? C.yellow : C.blue)
89
90/** ターンが終わった時だけ中身が変わるので、棒は左から順に伸ばす */
91export const turnsSvg = (durations: number[]) => {
92 const h = 222
93 const recent = durations.slice(-20)
94 const last = recent[recent.length - 1]
95 if (last === undefined) {
96 return frame(h, header('直近20ターンの所要時間') + text(W / 2, 132, 'まだ完了したターンがありません', 13, C.dim, 'text-anchor="middle"'))
97 }
98 const x0 = 52
99 const top = TOP + 4
100 const base = 180
101 const plotH = base - top
102 const max = Math.max(...recent, 1000)
103 const slot = (W - 16 - x0) / 20
104 const bw = Math.max(4, slot - 5)
105 // 120 秒・300 秒の目安線 (縦軸に入る時だけ)
106 const guide = (ms: number, color: string) => {
107 if (ms > max) return ''
108 const y = base - (ms / max) * plotH
109 return `<line x1="${x0}" y1="${y}" x2="${W - 16}" y2="${y}" stroke="${color}" stroke-dasharray="4 4" opacity="0.6"/>`
110 }
111 const bars = recent
112 .map((ms, i) => {
113 const bh = Math.max(2, (ms / max) * plotH)
114 const x = x0 + i * slot + (slot - bw) / 2
115 const isLast = i === recent.length - 1
116 const color = turnColor(ms)
117 const op = isLast || color !== C.blue ? 1 : 0.45
118 const begin = `begin="${(i * 0.03).toFixed(2)}s"`
119 return (
120 `<rect x="${x}" y="${base - bh}" width="${bw}" height="${bh}" rx="2" fill="${color}" opacity="${op}">` +
121 `<animate attributeName="height" from="0" to="${bh}" ${begin} dur="0.6s" fill="freeze"/>` +
122 `<animate attributeName="y" from="${base}" to="${base - bh}" ${begin} dur="0.6s" fill="freeze"/></rect>` +
123 (isLast ? `<rect x="${x - 2}" y="${base + 4}" width="${bw + 4}" height="3" rx="1.5" fill="${color}"/>` : '')
124 )
125 })
126 .join('')
127 const avg = recent.reduce((a, b) => a + b, 0) / recent.length
128 return frame(
129 h,
130 header('直近20ターンの所要時間', `最新 ${dur(last)}`, turnColor(last)) +
131 text(x0 - 6, top + 4, dur(max), 12, C.dim, 'text-anchor="end"') +
132 text(x0 - 6, base, '0', 12, C.dim, 'text-anchor="end"') +
133 `<line x1="${x0}" y1="${top}" x2="${W - 16}" y2="${top}" stroke="${C.line}"/>` +
134 `<line x1="${x0}" y1="${base}" x2="${W - 16}" y2="${base}" stroke="${C.line}"/>` +
135 guide(120_000, C.yellow) +
136 guide(300_000, C.red) +
137 bars +
138 text(16, 208, `平均 ${dur(avg)} / 2分超は黄、5分超は赤`, 12, C.dim),
139 )
140}
141
142// ---- ツール ----
143
144export const toolBarsSvg = (top: [string, number][], total: number, colorOf: (tool: string) => string) => {
145 const row = 28
146 const h = TOP + Math.max(1, top.length) * row + 8
147 if (top.length === 0) {
148 return frame(h + 20, header('ツール別の呼び出し回数') + text(W / 2, 92, 'まだ呼び出しがありません', 13, C.dim, 'text-anchor="middle"'))
149 }
150 const max = Math.max(...top.map(([, n]) => n))
151 const bx = 124
152 const bmax = W - bx - 56
153 const rows = top
154 .map(([tool, n], i) => {
155 const y = TOP + i * row
156 const w = Math.max(3, (n / max) * bmax)
157 return (
158 text(16, y + 15, cut(shortTool(tool), 12), 12, C.text) +
159 growBar(bx, y + 2, w, 16, colorOf(tool), i * 0.05) +
160 text(bx + w + 6, y + 15, String(n), 12, C.dim)
161 )
162 })
163 .join('')
164 return frame(h, header('ツール別の呼び出し回数', `計 ${total}回`) + rows)
165}
166
167/** 実行中は 30 秒ごとに描き直すので、流れる縞のほかに動きは足さない */
168export const timelineSvg = (calls: CcDashCall[], now: number, colorOf: (tool: string) => string) => {
169 const row = 20
170 const list = calls.slice(-30)
171 const h = TOP + Math.max(1, list.length) * row + 20
172 if (list.length === 0) {
173 return frame(h + 20, header('直近30回の呼び出し') + text(W / 2, 92, 'まだ呼び出しがありません', 13, C.dim, 'text-anchor="middle"'))
174 }
175 const len = (c: CcDashCall) => Math.max(0, (c.end ?? now) - c.start)
176 const max = Math.max(...list.map(len), 1)
177 const bmax = 210
178 const running = list.filter(c => c.end === undefined).length
179 const errors = list.filter(c => c.isError).length
180 const bands = list
181 .map((c, i) => {
182 const y = TOP + i * row
183 const w = Math.max(3, (len(c) / max) * bmax)
184 const isRunning = c.end === undefined
185 const color = c.isError ? C.red : isRunning ? C.blue : colorOf(c.tool)
186 // 実行中は太い破線の dashoffset を流して縞にする (pattern より描ける所が多い)
187 const band = isRunning
188 ? `<rect x="16" y="${y}" width="${w}" height="14" rx="3" fill="${C.blue}" opacity="0.3"/>` +
189 `<line x1="16" y1="${y + 7}" x2="${16 + w}" y2="${y + 7}" stroke="${C.blue}" stroke-width="14" stroke-dasharray="6 6">` +
190 `<animate attributeName="stroke-dashoffset" from="12" to="0" dur="0.6s" repeatCount="indefinite"/></line>`
191 : `<rect x="16" y="${y}" width="${w}" height="14" rx="3" fill="${color}" opacity="${c.isError ? 1 : 0.85}"/>`
192 const label = `${cut(shortTool(c.tool), 12)} ${isRunning ? '実行中' : dur(len(c))}`
193 return band + text(16 + w + 6, y + 12, label, 12, c.isError ? C.red : C.text)
194 })
195 .join('')
196 const right = [running ? `実行中 ${running}` : '', errors ? `失敗 ${errors}` : ''].filter(Boolean).join(' / ')
197 return frame(h, header('直近30回の呼び出し', right, errors ? C.red : C.blue) + bands + text(16, h - 10, '上が古く下が新しい。幅は所要時間', 12, C.dim))
198}
199
200// ---- 使用量 ----
201
202export type Gauge = { label: string; pct: number; left: string }
203
204export const gaugeColor = (pct: number) => (pct > 80 ? C.red : pct > 50 ? C.yellow : C.green)
205
206/** 30 秒ごとに読み直すが、source が変わるのは残りの分が変わる時だけ。輪と数字は 1 回だけ伸びる */
207export const gaugesSvg = (gauges: Gauge[], footer: string) => {
208 const h = 214
209 const col = W / gauges.length
210 const r = 38
211 const circ = 2 * Math.PI * r
212 const rings = gauges
213 .map((g, i) => {
214 const cx = col * i + col / 2
215 const cy = 132
216 const p = Math.min(100, Math.max(0, g.pct))
217 const d = (circ * p) / 100
218 const color = gaugeColor(p)
219 return (
220 text(cx, 74, cut(g.label, 9), 12, C.text, 'text-anchor="middle" font-weight="bold"') +
221 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${C.line}" stroke-width="10"/>` +
222 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="10" stroke-linecap="${p > 0 ? 'round' : 'butt'}" ` +
223 `stroke-dasharray="${d.toFixed(1)} ${circ.toFixed(1)}" transform="rotate(-90 ${cx} ${cy})">` +
224 `<animate attributeName="stroke-dasharray" from="0 ${circ.toFixed(1)}" to="${d.toFixed(1)} ${circ.toFixed(1)}" dur="0.9s" fill="freeze"/></circle>` +
225 countUp(Math.round(p), `x="${cx}" y="${cy + 7}" font-size="20" fill="${color}" text-anchor="middle" font-weight="bold"`, n => `${n}%`) +
226 text(cx, 194, g.left, 12, C.dim, 'text-anchor="middle"')
227 )
228 })
229 .join('')
230 return frame(h, header('使用量', footer, C.green) + rings)
231}
232hooks/style.ts 315 lines1// 全 mod で共通の見た目(色・見出し・動き)と小物。
2// 正本は shared/style.ts。各 mod の hooks/style.ts はその写しなので、直すのは正本だけにして
3// `node tools/copy_shared.mts` で写す(`--check` で写しのずれを見つける)。
4import type { EngineInterface, On } from 'claude-code'
5
6export type Palette = {
7 bg: string
8 face: string
9 line: string
10 text: string
11 dim: string
12 weak: string
13 blue: string
14 green: string
15 yellow: string
16 red: string
17 purple: string
18 orange: string
19}
20
21/** GitHub Dark 系(docs/mod-guide.md の 7 節) */
22export const DARK: Palette = {
23 bg: '#0d1117',
24 face: '#161b22',
25 line: '#30363d',
26 text: '#c9d1d9',
27 dim: '#8b949e',
28 weak: '#8b949e',
29 blue: '#58a6ff',
30 green: '#3fb950',
31 yellow: '#d29922',
32 red: '#f85149',
33 purple: '#bc8cff',
34 orange: '#f78166',
35}
36
37/** GitHub Light 系 */
38export const LIGHT: Palette = {
39 bg: '#ffffff',
40 face: '#f6f8fa',
41 line: '#d0d7de',
42 text: '#1f2328',
43 dim: '#656d76',
44 weak: '#656d76',
45 blue: '#0969da',
46 green: '#1a7f37',
47 yellow: '#9a6700',
48 red: '#cf222e',
49 purple: '#8250df',
50 orange: '#bc4c00',
51}
52
53// ponytail: 今の色はモジュールの変数 1 個(この mod の中だけ)。SVG を作る関数は C.bg などを呼ぶたびに読むので、
54// 引数を増やさずにテーマが変わる。描く途中で切り替わることは無い(JS は 1 本で走る)
55/** 今の色。setTheme で中身が入れ替わる(同じ物を指し続けるので、import した C のままでよい) */
56export const C: Palette = { ...DARK }
57let isLightNow = false
58
59export const isLight = () => isLightNow
60
61/** /config の theme の値が明るい系か('light'・'light-daltonized'・'light-ansi' など) */
62export const isLightTheme = (value: unknown) => typeof value === 'string' && value.startsWith('light')
63
64export function setTheme(light: boolean) {
65 isLightNow = light
66 Object.assign(C, light ? LIGHT : DARK)
67}
68
69/**
70 * 今の /config の theme を読んで色を合わせる。mod 自身の session.start の中で呼ぶ
71 * (同じイベントに matcher 無しのフックを 2 つ置くと読み込みを拒まれるので、ここでは on しない)。
72 * VS Code の配色は mod から読めないので、Claude Code の theme が light 系の時だけ明るくなる
73 */
74export async function readTheme($: EngineInterface) {
75 const rows = await $.config.list().catch(() => [])
76 setTheme(isLightTheme(rows.find(r => r.key === 'theme')?.value))
77}
78
79/** /config で theme が変わったら色を替えて描き直す。register の中で 1 回呼ぶ */
80export function followTheme(on: On) {
81 on('config.set', { key: 'theme' }, async ($, e, next) => {
82 const r = await next(e)
83 if (r.deny === undefined) {
84 setTheme(isLightTheme(r.value))
85 $.ui.invalidate('ui.render')
86 }
87 return r
88 })
89}
90
91// ---- 小物 ----
92
93/** 外から来る文字列の制御文字を落とす。エンジンは制御文字を含む Text の子や alt を拒み、ペインが閉じる */
94export const clean = (s: string) => s.replace(/[\t\n\r]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '').trim()
95
96/** SVG の文字に入れる前のエスケープ(制御文字も落とす) */
97export const esc = (s: string) =>
98 clean(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
99
100// ---- 時間帯と季節 (日本時間 JST = UTC+9 固定。動いている環境のタイムゾーンには頼らない) ----
101
102export type DayPart = '朝' | '昼' | '夕' | '夜'
103export type Season = '春' | '夏' | '秋' | '冬'
104
105const jstDate = (now: number) => new Date(now + 9 * 3_600_000)
106
107/** 時間帯。朝 5-10 時 / 昼 10-16 時 / 夕 16-19 時 / 夜 19-5 時 */
108export const dayPart = (now: number): DayPart => {
109 const h = jstDate(now).getUTCHours()
110 return h >= 5 && h < 10 ? '朝' : h >= 10 && h < 16 ? '昼' : h >= 16 && h < 19 ? '夕' : '夜'
111}
112
113/** 季節。春 3-5 月 / 夏 6-8 月 / 秋 9-11 月 / 冬 12-2 月 */
114export const season = (now: number): Season => {
115 const m = jstDate(now).getUTCMonth() + 1
116 return m >= 3 && m <= 5 ? '春' : m >= 6 && m <= 8 ? '夏' : m >= 9 && m <= 11 ? '秋' : '冬'
117}
118
119/** n 文字を超えたら … で切る */
120export const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
121
122// ---- 記録を守る(docs/mod-guide.md の 9 節) ----
123
124/** JST の YYYY-MM-DD */
125export const jstDay = (now: number) => jstDate(now).toISOString().slice(0, 10)
126
127export type Backup = { day: string; value: unknown }
128
129// ponytail: $.fs に消す手が無いので、曜日ごとの 7 つの枠を上書きして 7 日分だけ残す。もっと残すなら枠を日付にして process で消す
130const backupPath = (dir: string, plugin: string, key: string, wd: number) => `${dir.replace(/[\\/]+$/, '')}/${plugin}/${key}-${wd}.json`
131
132/**
133 * $.store の key の値を、その日はじめて呼ばれた時だけ dir に控える(曜日ごとの枠で 7 日分)。
134 * dir が空なら何もしない(公開の既定は空。入れた人が backupDir を設定する)。書いたら true
135 */
136export async function backupDaily($: EngineInterface, dir: string, key: string, value: unknown, now: number): Promise<boolean> {
137 if (!dir.trim() || value === undefined) return false
138 const day = jstDay(now)
139 const path = backupPath(dir, $.plugin.name, key, jstDate(now).getUTCDay())
140 // 壊れた枠(途中まで・null)は無いのと同じに扱って上書きする
141 const old = await $.fs.read(path).then(t => parseBackup(t)?.day ?? '', () => '')
142 if (old === day) return false
143 await $.fs.write(path, JSON.stringify({ day, value } satisfies Backup))
144 return true
145}
146
147/** 控えの 1 枠を読む。壊れていれば undefined */
148export function parseBackup(text: string): Backup | undefined {
149 try {
150 const b = JSON.parse(text) as Partial<Backup> | null
151 return b && typeof b.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(b.day) && b.value !== undefined ? (b as Backup) : undefined
152 } catch {
153 return undefined
154 }
155}
156
157/** 控えの一覧。新しい日から順。読めない枠・壊れた枠は飛ばす */
158export async function listBackups($: EngineInterface, dir: string, key: string): Promise<Backup[]> {
159 if (!dir.trim()) return []
160 const all = await Promise.all(
161 [0, 1, 2, 3, 4, 5, 6].map(wd =>
162 $.fs.read(backupPath(dir, $.plugin.name, key, wd)).then(parseBackup, () => undefined),
163 ),
164 )
165 return all.filter((b): b is Backup => b !== undefined).sort((a, b) => b.day.localeCompare(a.day))
166}
167
168let loadedPrint: string | undefined
169let staleAt = 0
170let isStaleNow = false
171let toldStale = false
172
173const normPath = (p: string) => p.replace(/[\\/]+/g, '/').replace(/\/$/, '').toLowerCase()
174
175/** FNV-1a 32bit。中身が変わったかを見るだけ */
176function fnv(s: string) {
177 let h = 0x811c9dc5
178 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
179 return (h >>> 0).toString(16)
180}
181
182/**
183 * 今ディスクにある、この mod の指紋。plugin.json と hooks の .ts / .tsx の中身から作る
184 * (版の数字を上げずに中身だけ変えた時も変わる)。plugin.json が読めなければ undefined
185 */
186async function diskPrint($: EngineInterface): Promise<string | undefined> {
187 const root = $.plugin.root
188 let manifest: string | undefined
189 for (const p of [`${root}/.claude-plugin/plugin.json`, `${root}/plugin.json`]) {
190 manifest = await $.fs.read(p).then(t => t, () => undefined)
191 if (manifest !== undefined) break
192 }
193 if (manifest === undefined) return undefined
194 const names = await $.fs.list(`${root}/hooks`).then(
195 es => es.filter(e => e.kind === 'file' && /\.tsx?$/.test(e.name)).map(e => e.name).sort(),
196 () => [] as string[],
197 )
198 const parts = await Promise.all(names.map(n => $.fs.read(`${root}/hooks/${n}`).then(t => `${n}\n${t}`, () => n)))
199 return fnv([manifest, ...parts].join('\0'))
200}
201
202/**
203 * マーケットプレイスから入れた形(<plugins>/cache/<market>/<name>/<版>)なら、installed_plugins.json に
204 * 今入っている場所が自分と違うか。違えば新しい版が入っている。分からなければ false
205 */
206async function replacedInCache($: EngineInterface): Promise<boolean> {
207 const root = normPath($.plugin.root)
208 const at = root.lastIndexOf('/plugins/cache/')
209 if (at < 0) return false
210 const raw = await $.fs.read(`${$.plugin.root.slice(0, at + '/plugins'.length)}/installed_plugins.json`).then(t => t, () => '')
211 try {
212 const plugins = (JSON.parse(raw) as { plugins?: Record<string, { installPath?: unknown }[]> }).plugins ?? {}
213 const mine = Object.entries(plugins)
214 .filter(([k]) => k.startsWith(`${$.plugin.name}@`))
215 .flatMap(([, v]) => (Array.isArray(v) ? v : []))
216 .map(e => (typeof e.installPath === 'string' ? normPath(e.installPath) : ''))
217 return mine.length > 0 && !mine.includes(root)
218 } catch {
219 return false
220 }
221}
222
223/** 読み込んだ時の指紋を覚える。mod の session.start で 1 回呼ぶ */
224export async function rememberVersion($: EngineInterface) {
225 loadedPrint ??= await diskPrint($)
226}
227
228/**
229 * 新しい版が入ったのに、このセッションはまだ古い版で動いているか。
230 * フォルダから読ませている時は中身の指紋が読み込んだ時と違えば、マーケットプレイスから入れた時は
231 * 入っている場所が自分でなくなれば true。読み直すのは 60 秒に 1 回まで。分からない時は false
232 */
233export async function isStale($: EngineInterface, now: number): Promise<boolean> {
234 if (isStaleNow || now - staleAt < 60_000) return isStaleNow
235 staleAt = now
236 if (await replacedInCache($)) return (isStaleNow = true)
237 if (loadedPrint === undefined) return false
238 const p = await diskPrint($)
239 isStaleNow = p !== undefined && p !== loadedPrint
240 return isStaleNow
241}
242
243/**
244 * $.store に書いてよいか。古い版のままなら書かず、1 回だけトーストで開き直しを頼む
245 * (古い版は新しい版の欄を知らないので、書くと消してしまう)
246 */
247export async function mayWrite($: EngineInterface, now: number): Promise<boolean> {
248 if (!(await isStale($, now))) return true
249 if (!toldStale) {
250 toldStale = true
251 $.ui.toast(`${$.plugin.name}: 新しい版が入りました。VS Code を開き直すまで記録は書きません`)
252 }
253 return false
254}
255
256// ---- SVG の部品 ----
257
258export const W = 380
259export const HEADER_H = 44
260
261/** SVG 全体の枠。地を塗って body を入れる。幅は W にそろえる */
262export const frame = (h: number, body: string, w = W) =>
263 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" font-family="sans-serif">` +
264 `<rect width="${w}" height="${h}" rx="10" fill="${C.bg}"/>${body}</svg>`
265
266/** 面(カード)。角丸 8、線 1 */
267export const panel = (x: number, y: number, w: number, h: number, fill = C.face) =>
268 `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="${fill}" stroke="${C.line}"/>`
269
270/**
271 * 共通の見出し帯(高さ HEADER_H)。左に色の縦線、左に題、右に添え書き。
272 * 下の細い線が左から伸びる
273 */
274export function header(title: string, sub = '', accent = C.blue, y = 0, w = W) {
275 const pad = 14
276 return [
277 `<rect x="0" y="${y}" width="${w}" height="${HEADER_H}" rx="10" fill="${C.face}"/>`,
278 `<rect x="0" y="${y + 8}" width="4" height="${HEADER_H - 16}" rx="2" fill="${accent}"/>`,
279 `<text x="${pad}" y="${y + 28}" font-size="16" font-weight="bold" fill="${C.text}">${esc(title)}</text>`,
280 sub === '' ? '' : `<text x="${w - pad}" y="${y + 28}" font-size="12" fill="${C.dim}" text-anchor="end">${esc(sub)}</text>`,
281 growBar(pad, y + HEADER_H - 3, w - pad * 2, 2, accent, 0, 0.35),
282 ].join('')
283}
284
285/** 横に伸びる棒。幅 0 から w まで伸びて止まる(shots.mts は終わりの値で撮る) */
286export const growBar = (x: number, y: number, w: number, h: number, fill: string, delay = 0, opacity = 1) =>
287 `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" rx="${Math.min(3, h / 2)}" fill="${fill}"${opacity === 1 ? '' : ` opacity="${opacity}"`}>` +
288 `<animate attributeName="width" from="0" to="${Math.max(0, w)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
289
290/** 下から伸びる縦の棒(下端 bottom・高さ h) */
291export const growColumn = (x: number, bottom: number, w: number, h: number, fill: string, delay = 0) =>
292 `<rect x="${x}" y="${bottom - h}" width="${w}" height="${Math.max(0, h)}" rx="${Math.min(3, w / 2)}" fill="${fill}">` +
293 `<animate attributeName="y" from="${bottom}" to="${bottom - h}" begin="${delay}s" dur="0.6s" fill="freeze"/>` +
294 `<animate attributeName="height" from="0" to="${Math.max(0, h)}" begin="${delay}s" dur="0.6s" fill="freeze"/></rect>`
295
296/** ふわっと出る。中身は SVG の断片 */
297export const fadeIn = (body: string, delay = 0) =>
298 `<g opacity="1"><animate attributeName="opacity" from="0" to="1" begin="${delay}s" dur="0.4s" fill="freeze"/>${body}</g>`
299
300/**
301 * 0 から value まで数字が増えて止まる。コマ送りの文字を重ね、最後のコマだけが残る。
302 * attrs は <text> に付ける属性(x・y・font-size・fill など)。format で桁区切りや単位を付ける
303 */
304export function countUp(value: number, attrs: string, format: (n: number) => string = n => String(n), delay = 0) {
305 const STEPS = 8
306 const d = 0.08
307 const last = `<text ${attrs} opacity="0">${esc(format(value))}<animate attributeName="opacity" from="0" to="1" begin="${(delay + (STEPS - 1) * d).toFixed(2)}s" dur="0.01s" fill="freeze"/></text>`
308 if (!Number.isFinite(value) || value === 0) return `<text ${attrs}>${esc(format(value))}</text>`
309 const frames = Array.from({ length: STEPS - 1 }, (_, k) => {
310 const n = Number.isInteger(value) ? Math.round((value * k) / (STEPS - 1)) : (value * k) / (STEPS - 1)
311 return `<text ${attrs} opacity="0">${esc(format(n))}<animate attributeName="opacity" values="1;1" begin="${(delay + k * d).toFixed(2)}s" dur="${d}s"/></text>`
312 })
313 return frames.join('') + last
314}
315types/index.d.ts 50 lines1export type CcDashTab = 'overview' | 'tools' | 'usage' | 'mine'
2
3/** 「自分の並び」に置ける枠 */
4export type CcDashPanel = 'overview' | 'turns' | 'toolBars' | 'timeline' | 'usage'
5
6/** 「自分の並び」の順と隠す枠。知らない名前(新しい版の枠)も残すので string */
7export type CcDashLayout = { order: string[]; hidden: string[] }
8
9/** ツール呼び出し 1 回の記録。end が無いものは実行中 */
10export type CcDashCall = { id: string; tool: string; start: number; end?: number; isError: boolean }
11
12/** 使用量ファイルの中身(読めた分) */
13export type CcDashUsage = {
14 sessionPct: number
15 weeklyPct: number
16 sessionResetAt?: unknown
17 weeklyResetAt?: unknown
18 modelName?: unknown
19 modelPct?: unknown
20 modelResetAt?: unknown
21 fetchedAt?: unknown
22}
23
24declare module 'claude-code' {
25 interface PluginState {
26 'cc-dash': {
27 tab: CcDashTab
28 /** 計測を始めた時刻 (ms)。0 は未設定 */
29 startedAt: number
30 turnCount: number
31 /** 直近 20 ターンの所要時間 (ms)、古い順 */
32 turnDurations: number[]
33 /** メインループのターンが走っていれば開始時刻、無ければ null */
34 runningSince: number | null
35 /** 直近 200 回の呼び出し、古い順 */
36 calls: CcDashCall[]
37 /** ツール名ごとの通算回数 */
38 toolCounts: Record<string, number>
39 toolTotal: number
40 errorCount: number
41 /** 最後に読んだ使用量。文字列は読めなかった理由、null はまだ読んでいない */
42 usage: CcDashUsage | string | null
43 /** 「自分の並び」(store の写し) */
44 layout: CcDashLayout
45 /** 「自分の並び」で並べ替えの画面を開いているか */
46 isArranging: boolean
47 }
48 }
49}
50