Claude が呼べるツール show で、グラフ・表・図をペインに SVG で描く。/canvas で直近 20 枚をめくる

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 127 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CanvasBoard } from '../types'
5import { DESCRIPTION as TOOL_DESCRIPTION, INPUT_SCHEMA, KEEP, KIND_NAME, TOOL_NAME, drawChart, hhmm, parseShow } from './canvas'
6import { followTheme, readTheme } from './style'
7
8const PANE = 'canvas-pane'
9const TITLE = 'キャンバス'
10const DESCRIPTION = 'Claude が描いたグラフ・表・図のペインを開く'
11const TOOL = 'mcp__canvas-pane__show'
12const EMPTY_HINT = 'Claude に「〜をグラフにしてペインに出して」と頼むと、ここに描かれます'
13
14const EMPTY: CanvasBoard = { items: [], pos: 0, seq: 1 }
15const BOARD = atom({ plugin: 'canvas-pane', key: 'board' } as const, EMPTY)
16
17type $ = EngineInterface
18
19let ownsMain = false
20
21const openPane = async ($: $) => {
22 await $.ui.open({ id: PANE, title: TITLE })
23 return { text: 'キャンバスのペインを開きました。' }
24}
25
26/** 前へ・次へ。端では動かない */
27const move = ($: $, by: number) =>
28 update($, BOARD, b => ({ ...b, pos: Math.max(0, Math.min(b.items.length - 1, b.pos + by)) }))
29
30/** 見ている 1 枚を消す */
31const remove = ($: $) =>
32 update($, BOARD, b => {
33 if (b.items.length === 0) return b
34 const items = b.items.filter((_, i) => i !== b.pos)
35 return { ...b, items, pos: Math.min(b.pos, Math.max(0, items.length - 1)) }
36 })
37
38export const register: Register = on => {
39 followTheme(on)
40
41 on('session.start', async ($, e, next) => {
42 const started = await next(e)
43 await readTheme($)
44 await $.tool
45 .register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
46 .catch((err: unknown) => $.ui.log(`canvas-pane: ツールを登録できませんでした (${err instanceof Error ? err.message : String(err)})`))
47 // /canvas が拒まれたら (組み込みや他の mod と被ったら) /canvas-pane にする
48 const got = await $.command.register({ name: 'canvas', description: DESCRIPTION }).catch(() => undefined)
49 ownsMain = got?.command === 'canvas'
50 if (!ownsMain) await $.command.register({ name: 'canvas-pane', description: DESCRIPTION }).catch(() => {})
51 return started
52 })
53
54 // /canvas を持てなかった時は、元の /canvas に回す
55 on('command.run', { command: 'canvas' }, ($, e, next) => (ownsMain ? openPane($) : next(e))).catch(($, e, next) => next(e))
56 // /canvas を持てた時は予備名を開かない (同じ名前の別のコマンドを横取りしない)
57 on('command.run', { command: 'canvas-pane' }, ($, e, next) => (ownsMain ? next(e) : openPane($)))
58
59 // Claude が呼ぶツール。入力はモデルから来る外の値なので、全部確かめてから描く
60 on('tool.call', { tool: TOOL }, async ($, e) => {
61 const parsed = parseShow(e)
62 if ('error' in parsed) return { deny: `描けませんでした: ${parsed.error}` }
63 const at = await $.clock.now()
64 let count = 0
65 await update($, BOARD, b => {
66 const items = [...b.items, { ...parsed.item, id: b.seq, at }].slice(-KEEP)
67 count = items.length
68 return { items, pos: items.length - 1, seq: b.seq + 1 }
69 })
70 const opened = await $.ui.open({ id: PANE, title: TITLE }).catch((err: unknown) => ({
71 isPlaced: false as const,
72 reason: err instanceof Error ? err.message : String(err),
73 }))
74 const where = opened.isPlaced ? '' : `(ペインはまだ表に出ていません: ${opened.reason}。ユーザーは /canvas で開けます)`
75 return { result: `「${parsed.item.title}」(${KIND_NAME[parsed.item.kind]}) をペインに描きました${where}。直近 ${KEEP} 枚まで残り、今は ${count} 枚です。` }
76 }).catch(() => ({ deny: 'canvas-pane: 描く途中で失敗しました。もう一度呼ぶか、データを小さくしてください' }))
77
78 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
79 const t = $.ui.resolve(e)
80 const { Box, Text, Button, Markdown } = t
81 // terminal の表にも Svg の名前があるので、画面の種類でも分ける
82 const Svg = e.surface !== 'terminal' && 'Svg' in t ? t.Svg : undefined
83 const board = await read($, BOARD)
84 const item = board.items[board.pos]
85
86 if (!item)
87 return (
88 <Box flexDirection="column" gap={1}>
89 <Text dimColor>{EMPTY_HINT}</Text>
90 </Box>
91 )
92
93 const n = board.items.length
94 const isFirst = board.pos === 0
95 const isLast = board.pos === n - 1
96 let body
97 if (item.kind === 'markdown') body = <Markdown key={`md-${item.id}`} text={item.markdown ?? ''} />
98 // source が変われば伸びるアニメは頭から出る。この mod が組む図は今のテーマの色で組み直す
99 else if (Svg && item.svg !== undefined) body = <Svg source={item.chart ? drawChart(item.chart) : item.svg} alt={item.alt} />
100 else
101 body = (
102 <Box key={`lines-${item.id}`} flexDirection="column">
103 {item.lines.map(l => (
104 <Text color={l.color} dimColor={l.isDim} bold={l.isBold}>
105 {l.text}
106 </Text>
107 ))}
108 </Box>
109 )
110
111 return (
112 <Box flexDirection="column" gap={1}>
113 <Box key="head" flexDirection="column">
114 <Text bold>{item.title}</Text>
115 <Text dimColor>{`${board.pos + 1} / ${n} 枚目 ─ ${KIND_NAME[item.kind]} ─ ${hhmm(item.at)}`}</Text>
116 </Box>
117 {body}
118 <Box key="actions" flexDirection="row" gap={1}>
119 <Button key="prev" label="前へ" dimColor={isFirst} onPress={() => void move($, -1)} />
120 <Button key="next" label="次へ" dimColor={isLast} onPress={() => void move($, 1)} />
121 <Button key="remove" dimColor label="消す" onPress={() => void remove($)} />
122 </Box>
123 </Box>
124 )
125 })
126}
127hooks/canvas.ts 600 lines1// 入力の確かめと、図の SVG・terminal の行を組む純関数。描画面は <img> なので動きは SMIL だけ
2import type { CanvasChart, CanvasItem, CanvasKind, CanvasLine } from '../types'
3import { C, W, countUp, frame, isLight } from './style'
4
5export const TOOL_NAME = 'show'
6/** 残す枚数 */
7export const KEEP = 20
8
9/** 上限。モデルから来る値なので、超えたら断る */
10export const LIMIT = {
11 title: 80,
12 label: 40,
13 points: 50,
14 series: 6,
15 pie: 10,
16 columns: 8,
17 rows: 50,
18 cell: 60,
19 text: 20_000,
20 svg: 100_000,
21} as const
22/** Svg の source の上限 (エンジンが拒む長さ) */
23export const SVG_SOURCE_MAX = 131_072
24
25export const KINDS = ['bar', 'line', 'pie', 'table', 'markdown', 'svg'] as const
26export const KIND_NAME: Record<CanvasKind, string> = {
27 bar: '棒グラフ',
28 line: '折れ線グラフ',
29 pie: '円グラフ',
30 table: '表',
31 markdown: '文章',
32 svg: '図',
33}
34
35// 系列の色。赤も使う (棒や線は失敗の印ではないので)。テーマに付いていくよう、呼ぶたびに C から読む
36const seriesColor = (j: number) => [C.blue, C.green, C.purple, C.orange, C.yellow, C.red][j % 6]!
37// 円グラフは 10 色まで。7 色目からは系列の色の淡い/濃い版 (地の明るさで選ぶ。絵の中身として決めた色)
38const pieColor = (i: number) =>
39 i < 6 ? seriesColor(i) : (isLight() ? ['#0550ae', '#116329', '#6639ba', '#a40e26'] : ['#79c0ff', '#56d364', '#d2a8ff', '#ffa198'])[(i - 6) % 4]!
40
41// ---- 文字 ----
42
43/** 制御文字 (C0・DEL・C1) を落とす (タブと改行は残す)。エンジンは制御文字を含む Text の子や alt の木を拒む */
44export const clean = (s: string) => s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, '')
45/** 日本時間 (JST) の時:分 */
46export const hhmm = (ms: number) => {
47 const d = new Date(ms + 9 * 3_600_000)
48 return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
49}
50
51/** 1 行にする: タブ・改行も空白に */
52export const oneLine = (s: string) => clean(s.replace(/[\t\r\n]+/g, ' ')).trim()
53/** Markdown は tab と改行のほかの制御文字を拒むので、\r も改行に直す */
54export const mdText = (s: string) => clean(s.replace(/\r\n?/g, '\n'))
55
56export const esc = (s: string) =>
57 clean(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
58
59/** 文字の幅の見積もり (px)。全角は 1 字ぶん、半角は 0.6 字ぶん */
60export const textWidth = (s: string, size: number) => {
61 let w = 0
62 for (const ch of s) w += (ch.codePointAt(0) ?? 0) > 0x2e7f ? 1 : 0.6
63 return w * size
64}
65
66/** 幅 px に収まるよう末尾を … にする */
67export const fit = (s: string, px: number, size: number) => {
68 if (textWidth(s, size) <= px) return s
69 let out = ''
70 for (const ch of s) {
71 if (textWidth(out + ch + '…', size) > px) break
72 out += ch
73 }
74 return out + '…'
75}
76
77/** 数の見せ方: 3 桁区切り、小さい数は有効 3 桁 */
78export const fmt = (n: number) => {
79 const r = Math.abs(n) >= 100 ? Math.round(n * 10) / 10 : Number(n.toPrecision(3))
80 const [i = '', d] = String(r).split('.')
81 return i.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (d ? `.${d}` : '')
82}
83
84/** 目盛り: 0〜max を 4 つほどに分ける切りのいい刻み */
85export const ticks = (lo: number, hi: number, count = 4) => {
86 if (lo === hi) {
87 if (lo === 0) hi = 1
88 else if (lo > 0) lo = 0
89 else hi = 0
90 }
91 const raw = (hi - lo) / count
92 const p = 10 ** Math.floor(Math.log10(raw))
93 const m = raw / p
94 const step = (m <= 1 ? 1 : m <= 2 ? 2 : m <= 2.5 ? 2.5 : m <= 5 ? 5 : 10) * p
95 const start = Math.floor(lo / step + 1e-9) * step
96 const end = Math.ceil(hi / step - 1e-9) * step
97 const out: number[] = []
98 for (let v = start; v <= end + step / 2; v += step) out.push(Number(v.toPrecision(12)))
99 return out
100}
101
102// ---- 入力 ----
103
104export type Series = { name: string; values: number[] }
105export type Parsed =
106 | { item: Omit<CanvasItem, 'id' | 'at'> }
107 | { error: string }
108
109const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
110/** 数は ±1e15 まで (それより大きいと目盛りや 3 桁区切りが崩れる) */
111const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && Math.abs(v) <= 1e15
112
113/** 文字の項目を確かめる。制御文字を落としてから数える */
114const str = (v: unknown, what: string, max: number, isText = false): string | { error: string } => {
115 if (typeof v !== 'string') return { error: `${what} は文字列にしてください(今は ${typeName(v)})` }
116 const s = isText ? mdText(v) : oneLine(v)
117 if (s.length > max) return { error: `${what} は ${max} 字までです(今は ${s.length} 字)` }
118 return s
119}
120
121const typeName = (v: unknown) => (v === null ? 'null' : Array.isArray(v) ? '配列' : typeof v === 'object' ? 'オブジェクト' : typeof v)
122
123/** values を系列の並びにする。number[] は 1 系列、[{ name, values }] は複数系列 */
124function readSeries(input: Record<string, unknown>, n: number): Series[] | { error: string } {
125 const v = input.values
126 if (!Array.isArray(v) || v.length === 0) return { error: 'values が要ります(数の配列、または [{ "name": "系列名", "values": [数...] }] の配列)' }
127 const list: Series[] = []
128 // 中に { name, values } が 1 つも無ければ 1 系列 (数でない物は下で断る)
129 if (!v.some(isObj)) {
130 list.push({ name: '', values: v as number[] })
131 } else {
132 if (v.length > LIMIT.series) return { error: `系列は ${LIMIT.series} 本までです(今は ${v.length} 本)` }
133 for (const [i, s] of v.entries()) {
134 if (!isObj(s) || !Array.isArray(s.values))
135 return { error: `values[${i}] は { "name": "系列名", "values": [数...] } の形にしてください(数の配列と混ぜられません)` }
136 const name = str(s.name ?? '', `values[${i}].name`, LIMIT.label)
137 if (typeof name !== 'string') return name
138 list.push({ name, values: s.values as number[] })
139 }
140 }
141 for (const [i, s] of list.entries()) {
142 const where = list.length === 1 && s.name === '' ? 'values' : `values[${i}].values`
143 const bad = s.values.findIndex(x => !isNum(x))
144 if (bad >= 0) return { error: `${where}[${bad}] が数ではありません(±1e15 までの数だけ使えます。今は ${typeName(s.values[bad])})` }
145 if (s.values.length !== n) return { error: `labels ${n} 件に対し ${where} が ${s.values.length} 件です。同じ数にしてください` }
146 }
147 return list
148}
149
150function readLabels(input: Record<string, unknown>, max: number): string[] | { error: string } {
151 const v = input.labels
152 if (!Array.isArray(v) || v.length === 0) return { error: 'labels が要ります(項目名の文字列の配列)' }
153 if (v.length > max) return { error: `labels は ${max} 件までです(今は ${v.length} 件)` }
154 const out: string[] = []
155 for (const [i, x] of v.entries()) {
156 const s = str(typeof x === 'number' ? String(x) : x, `labels[${i}]`, LIMIT.label)
157 if (typeof s !== 'string') return s
158 out.push(s)
159 }
160 return out
161}
162
163/**
164 * 検査のための写し。文字参照 (h h) と CSS のエスケープ (\68 \i) をほどく
165 * (https: や @\69mport のように、ほどくと外を指す書き方をすり抜けさせない)。描くのは元の物
166 */
167export function unescapeForCheck(s: string) {
168 const ch = (code: number) => (Number.isInteger(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '')
169 return s
170 .replace(/&#x([0-9a-f]+);?/gi, (_, h: string) => ch(parseInt(h, 16)))
171 .replace(/&#(\d+);?/g, (_, d: string) => ch(Number(d)))
172 .replace(/\\([0-9a-f]{1,6})\s?/gi, (_, h: string) => ch(parseInt(h, 16)))
173 .replace(/\\([^0-9a-f\r\n])/gi, '$1')
174}
175
176/** Claude が渡した SVG で断る物。img として描かれるので本来は動かないが、念のため。制御文字を落とし、エスケープをほどいてから見る */
177export function svgProblem(raw: string): string | undefined {
178 if (raw.length > LIMIT.svg) return `svg は ${LIMIT.svg} 字までです(今は ${raw.length} 字)`
179 const s = unescapeForCheck(clean(raw))
180 // URL の途中のタブ・改行は読み飛ばされるので、外を指すかは詰めた物でも見る
181 const flat = s.replace(/[\t\r\n]/g, '')
182 if (!/^\s*<svg[\s>]/i.test(s)) return 'svg は <svg で始めてください(<?xml や前置きの文は付けない)'
183 if (/<(?:[\w-]+:)?script/i.test(s)) return 'svg に <script> は入れられません'
184 if (/[\s"'/]on[a-z]+\s*=/i.test(s)) return 'svg に on で始まる属性 (onload= など) は入れられません'
185 if (/(?:href|src)\s*=\s*["']?\s*(?:https?:|\/\/|javascript:|file:)/i.test(flat)) return 'svg から外の物 (href="http..."・file: など) は参照できません。画像は data: で埋め込んでください'
186 if (/url\(\s*["']?\s*(?:https?:|\/\/|file:)|image-set\(|@import/i.test(flat)) return 'svg の CSS から外の物 (url(http...)・file:・image-set・@import) は参照できません'
187 return undefined
188}
189
190/** ツールの入力を確かめて 1 枚に組む。断る時は直し方の分かる理由を返す */
191export function parseShow(input: Record<string, unknown>): Parsed {
192 const title = str(input.title, 'title', LIMIT.title)
193 if (typeof title !== 'string') return title
194 if (title === '') return { error: 'title が空です。図の題を入れてください' }
195 const kind = input.kind
196 if (typeof kind !== 'string' || !(KINDS as readonly string[]).includes(kind))
197 return { error: `kind は ${KINDS.join(' / ')} のどれかにしてください(今は ${typeof kind === 'string' ? JSON.stringify(oneLine(kind).slice(0, 20)) : typeName(kind)})` }
198 const k = kind as CanvasKind
199
200 if (k === 'bar' || k === 'line' || k === 'pie') {
201 const labels = readLabels(input, k === 'pie' ? LIMIT.pie : LIMIT.points)
202 if (!Array.isArray(labels)) return labels
203 const series = readSeries(input, labels.length)
204 if (!Array.isArray(series)) return series
205 if (k === 'pie') {
206 if (series.length !== 1) return { error: `pie は系列 1 つだけです(今は ${series.length} 本)。values は数の配列にしてください` }
207 const vals = series[0]!.values
208 if (vals.some(v => v < 0)) return { error: 'pie の values に負の数は使えません' }
209 if (vals.reduce((a, b) => a + b, 0) <= 0) return { error: 'pie の values の合計が 0 です' }
210 }
211 const chart: CanvasChart = k === 'pie' ? { kind: k, labels, values: series[0]!.values } : { kind: k, labels, series }
212 return done({ title, kind: k, svg: drawChart(chart), chart, alt: chartAlt(title, k, labels, series), lines: chartLines(k, labels, series) })
213 }
214
215 if (k === 'table') {
216 const cols = input.columns
217 if (!Array.isArray(cols) || cols.length === 0) return { error: 'columns が要ります(列名の文字列の配列)' }
218 if (cols.length > LIMIT.columns) return { error: `columns は ${LIMIT.columns} 列までです(今は ${cols.length} 列)` }
219 const columns: string[] = []
220 for (const [i, c] of cols.entries()) {
221 const s = str(c, `columns[${i}]`, LIMIT.label)
222 if (typeof s !== 'string') return s
223 columns.push(s)
224 }
225 const rs = input.rows
226 if (!Array.isArray(rs) || rs.length === 0) return { error: 'rows が要ります(行の配列。1 行はセルの配列)' }
227 if (rs.length > LIMIT.rows) return { error: `rows は ${LIMIT.rows} 行までです(今は ${rs.length} 行)` }
228 const rows: Cell[][] = []
229 for (const [i, r] of rs.entries()) {
230 if (!Array.isArray(r)) return { error: `rows[${i}] はセルの配列にしてください(今は ${typeName(r)})` }
231 if (r.length > columns.length) return { error: `rows[${i}] のセルが ${r.length} 個で、columns の ${columns.length} 列より多いです` }
232 const row: Cell[] = []
233 for (const [j, c] of r.entries()) {
234 if (c === null || c === undefined) row.push('')
235 else if (isNum(c)) row.push(c)
236 else if (typeof c === 'string') {
237 const s = str(c, `rows[${i}][${j}]`, LIMIT.cell)
238 if (typeof s !== 'string') return s
239 row.push(s)
240 } else return { error: `rows[${i}][${j}] は文字列か数 (±1e15 まで) にしてください(今は ${typeName(c)})` }
241 }
242 while (row.length < columns.length) row.push('')
243 rows.push(row)
244 }
245 const chart: CanvasChart = { kind: k, columns, rows }
246 return done({ title, kind: k, svg: drawChart(chart), chart, alt: tableAlt(title, columns, rows), lines: tableLines(columns, rows) })
247 }
248
249 if (k === 'markdown') {
250 const text = str(input.text, 'text', LIMIT.text, true)
251 if (typeof text !== 'string') return text
252 if (text.trim() === '') return { error: 'text が空です' }
253 return done({ title, kind: k, markdown: text, alt: title, lines: [] })
254 }
255
256 if (typeof input.svg !== 'string') return { error: `svg は文字列にしてください(今は ${typeName(input.svg)})` }
257 const problem = svgProblem(input.svg)
258 if (problem) return { error: problem }
259 return done({
260 title,
261 kind: k,
262 svg: clean(input.svg),
263 alt: `${title}(Claude が描いた図)`,
264 lines: [{ text: 'この図は SVG です。VS Code のペインで見られます', isDim: true }],
265 })
266}
267
268/** 組んだ SVG が Svg の上限を超えないか最後に見る */
269const done = (item: Omit<CanvasItem, 'id' | 'at'>): Parsed =>
270 item.svg !== undefined && item.svg.length > SVG_SOURCE_MAX
271 ? { error: `データが多くて図が大きくなりすぎました(${item.svg.length} 字。${SVG_SOURCE_MAX} 字まで)。件数を減らしてください` }
272 : { item }
273
274// ---- SVG の部品 ----
275
276type Cell = string | number
277
278/** 元のデータから、今のテーマの色で組む (描くたびに呼ぶ。テーマを切り替えると色が付いていく) */
279export const drawChart = (c: CanvasChart) =>
280 c.kind === 'table' ? tableSvg(c.columns, c.rows) : c.kind === 'pie' ? pieSvg(c.labels, c.values) : c.kind === 'bar' ? barSvg(c.labels, c.series) : lineSvg(c.labels, c.series)
281
282const r1 = (n: number) => Math.round(n * 10) / 10
283
284const text = (x: number, y: number, s: string, size: number, fill: string, extra = '') =>
285 `<text x="${r1(x)}" y="${r1(y)}" font-size="${size}" fill="${fill}"${extra ? ` ${extra}` : ''}>${esc(s)}</text>`
286
287/** keyTimes の中の 1 点 (0〜0.99) */
288const kt = (x: number) => Math.min(0.99, Math.round(x * 100) / 100)
289
290/** 少し遅れて現れる (0 から始まり、delay 秒後から dur 秒で 1 に) */
291const fadeIn = (delay: number, dur = 0.4) =>
292 delay <= 0
293 ? `<animate attributeName="opacity" values="0;1" dur="${dur}s" fill="freeze"/>`
294 : `<animate attributeName="opacity" values="0;0;1" keyTimes="0;${kt(delay / (delay + dur))};1" dur="${r1(delay + dur)}s" fill="freeze"/>`
295
296/** 0 から to へ伸びる。遅れは keyTimes で付ける (begin で遅らせると、始まるまで伸びきった姿が見える) */
297const grow = (attr: string, from: number, to: number, delay: number, dur = 0.7) =>
298 delay <= 0
299 ? `<animate attributeName="${attr}" values="${r1(from)};${r1(to)}" dur="${dur}s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines=".2 .8 .2 1"/>`
300 : `<animate attributeName="${attr}" values="${r1(from)};${r1(from)};${r1(to)}" keyTimes="0;${kt(delay / (delay + dur))};1" dur="${r1(delay + dur)}s" fill="freeze" calcMode="spline" keySplines="0 0 1 1;.2 .8 .2 1"/>`
301
302/** 凡例。複数系列の時だけ。高さを返す */
303function legend(series: Series[], y: number): { svg: string; h: number } {
304 if (series.length < 2) return { svg: '', h: 0 }
305 let x = 16
306 let row = 0
307 let out = ''
308 for (const [i, s] of series.entries()) {
309 const name = fit(s.name || `系列${i + 1}`, 140, 12)
310 const w = 18 + textWidth(name, 12) + 14
311 if (x + w > W - 12 && x > 16) {
312 x = 16
313 row++
314 }
315 const yy = y + row * 20
316 out += `<rect x="${x}" y="${yy - 9}" width="11" height="11" rx="3" fill="${seriesColor(i)}"/>` + text(x + 16, yy, name, 12, C.text)
317 x += w
318 }
319 return { svg: out, h: (row + 1) * 20 }
320}
321
322// ---- 棒グラフ (横向き。狭いペインでも項目名が読める) ----
323
324export function barSvg(labels: string[], series: Series[]): string {
325 const all = series.flatMap(s => s.values)
326 const t = ticks(Math.min(0, ...all), Math.max(0, ...all))
327 const lo = t[0]!
328 const hi = t[t.length - 1]!
329 const showValues = labels.length * series.length <= 80 && series.length <= 3
330 const labelW = Math.min(120, Math.max(...labels.map(l => textWidth(l, 12))) + 8)
331 const valueW = showValues ? Math.min(64, Math.max(...all.map(v => textWidth(fmt(v), 12))) + 8) : 8
332 const x0 = 16 + labelW
333 const x1 = W - 16 - valueW
334 const sx = (v: number) => x0 + ((v - lo) / (hi - lo)) * (x1 - x0)
335 const zero = sx(0)
336
337 const lg = legend(series, 26)
338 const top = 18 + lg.h + 20
339 const bar = series.length === 1 ? 16 : series.length <= 3 ? 12 : 8
340 const groupH = bar * series.length + 10
341 const bottom = top + labels.length * groupH
342 const h = bottom + 18
343
344 let out = lg.svg
345 for (const v of t) {
346 const x = sx(v)
347 out += `<line x1="${r1(x)}" y1="${top - 6}" x2="${r1(x)}" y2="${bottom}" stroke="${C.line}"${v === 0 ? '' : ' stroke-dasharray="2 3"'}/>`
348 out += text(x, top - 10, fmt(v), 12, C.dim, 'text-anchor="middle"')
349 }
350 let n = 0
351 for (const [i, label] of labels.entries()) {
352 const gy = top + i * groupH + 5
353 out += text(x0 - 6, gy + (bar * series.length) / 2 + 4, fit(label, labelW - 6, 12), 12, C.text, 'text-anchor="end"')
354 for (const [j, s] of series.entries()) {
355 const v = s.values[i]!
356 const y = gy + j * bar
357 const x = Math.min(sx(v), zero)
358 const w = Math.max(Math.abs(sx(v) - zero), v === 0 ? 0 : 1)
359 const delay = Math.min(0.6, n++ * 0.025)
360 const color = seriesColor(j)
361 // 負の数は 0 から左へ伸びる (x も動かす)
362 out +=
363 `<rect x="${r1(x)}" y="${r1(y)}" width="${r1(w)}" height="${r1(bar - 1)}" rx="2" fill="${color}">` +
364 grow('width', 0, w, delay) +
365 (v < 0 ? grow('x', zero, x, delay) : '') +
366 `</rect>`
367 if (showValues)
368 out += `<g opacity="1">${fadeIn(delay + 0.5)}${text(v < 0 ? x - 4 : x + w + 4, y + bar / 2 + 4, fmt(v), 12, C.text, v < 0 ? 'text-anchor="end"' : '')}</g>`
369 }
370 }
371 return frame(h, out)
372}
373
374// ---- 折れ線グラフ ----
375
376export function lineSvg(labels: string[], series: Series[]): string {
377 const all = series.flatMap(s => s.values)
378 const min = Math.min(...all)
379 const max = Math.max(...all)
380 // 正の数だけで、0 から遠くなければ 0 から描く
381 const t = ticks(min >= 0 && min <= max - min ? 0 : min, max)
382 const lo = t[0]!
383 const hi = t[t.length - 1]!
384 const lg = legend(series, 26)
385 const top = 18 + lg.h + 12
386 const plotH = 190
387 const bottom = top + plotH
388 const yLabelW = Math.max(...t.map(v => textWidth(fmt(v), 12))) + 8
389 const x0 = 16 + yLabelW
390 const x1 = W - 22
391 const n = labels.length
392 const sx = (i: number) => (n === 1 ? (x0 + x1) / 2 : x0 + (i * (x1 - x0)) / (n - 1))
393 const sy = (v: number) => bottom - ((v - lo) / (hi - lo)) * plotH
394
395 let out = lg.svg
396 out += `<defs><linearGradient id="area" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${C.blue}" stop-opacity=".35"/><stop offset="1" stop-color="${C.blue}" stop-opacity="0"/></linearGradient></defs>`
397 for (const v of t) {
398 const y = sy(v)
399 out += `<line x1="${r1(x0)}" y1="${r1(y)}" x2="${x1}" y2="${r1(y)}" stroke="${C.line}"${v === lo ? '' : ' stroke-dasharray="2 3"'}/>`
400 out += text(x0 - 6, y + 4, fmt(v), 12, C.dim, 'text-anchor="end"')
401 }
402 // 横軸の項目名は重ならない分だけ間引く
403 const widest = Math.min(90, Math.max(...labels.map(l => textWidth(l, 12))) + 10)
404 const room = n === 1 ? x1 - x0 : (x1 - x0) / (n - 1)
405 const every = Math.max(1, Math.ceil(widest / room))
406 for (const [i, l] of labels.entries()) {
407 if (i % every !== 0 && i !== n - 1) continue
408 if (i !== n - 1 && n - 1 - i < every && n > 1) continue
409 const anchor = n === 1 ? 'middle' : i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle'
410 out += text(sx(i), bottom + 18, fit(l, Math.max(widest, room * every) - 6, 12), 12, C.dim, `text-anchor="${anchor}"`)
411 }
412 const pts = (s: Series) => s.values.map((v, i) => `${r1(sx(i))},${r1(sy(v))}`)
413 if (series.length === 1 && n > 1) {
414 const p = pts(series[0]!)
415 out += `<path d="M${r1(sx(0))},${bottom}L${p.join('L')}L${r1(sx(n - 1))},${bottom}Z" fill="url(#area)" opacity="1">${fadeIn(0.6, 0.6)}</path>`
416 }
417 const showAll = series.length === 1 && n <= 12
418 for (const [j, s] of series.entries()) {
419 const color = seriesColor(j)
420 if (n > 1)
421 out +=
422 `<path d="M${pts(s).join('L')}" fill="none" stroke="${color}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" pathLength="1" stroke-dasharray="1" stroke-dashoffset="0">` +
423 `<animate attributeName="stroke-dashoffset" values="1;0" dur="1s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines=".3 .7 .3 1"/></path>`
424 if (n <= 24)
425 for (const [i, v] of s.values.entries())
426 out += `<circle cx="${r1(sx(i))}" cy="${r1(sy(v))}" r="3.5" fill="${C.face}" stroke="${color}" stroke-width="2" opacity="1">${fadeIn(0.2 + (i / n) * 0.8, 0.3)}</circle>`
427 // 数: 1 系列で 12 点までは全部、それより多い時は最後の点だけ
428 const idx = showAll ? s.values.map((_, i) => i) : [n - 1]
429 for (const i of idx) {
430 const v = s.values[i]!
431 const anchor = n === 1 ? 'middle' : i === n - 1 ? 'end' : i === 0 ? 'start' : 'middle'
432 out += `<g opacity="1">${fadeIn(1)}${text(sx(i), sy(v) - 9, fmt(v), 12, showAll ? C.text : color, `text-anchor="${anchor}" font-weight="bold"`)}</g>`
433 }
434 }
435 return frame(bottom + 32, out)
436}
437
438// ---- 円グラフ (ドーナツ) ----
439
440export function pieSvg(labels: string[], values: number[]): string {
441 const total = values.reduce((a, b) => a + b, 0)
442 const cx = W / 2
443 const cy = 120
444 const R = 78
445 const band = 34
446 let out = ''
447 // 輪っか: 円の線を pathLength=100 で区切る。始まりは 12 時、時計回り
448 let start = 0
449 for (const [i, v] of values.entries()) {
450 const pct = (v / total) * 100
451 if (pct <= 0) continue
452 const color = pieColor(i)
453 const len = Math.max(0.01, pct > 1.5 ? pct - 0.6 : pct)
454 const delay = (start / 100) * 0.8
455 out +=
456 `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="${color}" stroke-width="${band}" pathLength="100" stroke-dasharray="${r1(len)} 100" stroke-dashoffset="${r1(-start)}" transform="rotate(-90 ${cx} ${cy})">` +
457 `<animate attributeName="stroke-dasharray" values="0 100;0 100;${r1(len)} 100" keyTimes="0;${kt(delay / (delay + 0.5))};1" dur="${r1(delay + 0.5)}s" fill="freeze"/></circle>`
458 // 6% 以上は輪の上に割合
459 if (pct >= 6) {
460 const a = ((start + pct / 2) / 100) * Math.PI * 2 - Math.PI / 2
461 out += `<g opacity="1">${fadeIn(1)}${text(cx + Math.cos(a) * R, cy + Math.sin(a) * R + 4, `${Math.round(pct)}%`, 12, C.bg, 'text-anchor="middle" font-weight="bold"')}</g>`
462 }
463 start += pct
464 }
465 out += countUp(total, `x="${cx}" y="${cy + 2}" font-size="18" fill="${C.text}" text-anchor="middle" font-weight="bold"`, fmt, 0.2)
466 out += text(cx, cy + 20, '合計', 12, C.dim, 'text-anchor="middle"')
467
468 // 凡例: 色・項目名・数・割合
469 let y = cy + R + band / 2 + 28
470 for (const [i, l] of labels.entries()) {
471 const v = values[i]!
472 out +=
473 `<g opacity="1">${fadeIn(0.3 + i * 0.06)}` +
474 `<rect x="20" y="${y - 10}" width="12" height="12" rx="3" fill="${pieColor(i)}"/>` +
475 text(40, y, fit(l, 200, 12), 12, C.text) +
476 text(W - 72, y, fmt(v), 12, C.text, 'text-anchor="end"') +
477 text(W - 20, y, `${((v / total) * 100).toFixed(1)}%`, 12, C.dim, 'text-anchor="end"') +
478 `</g>`
479 y += 22
480 }
481 return frame(y + 4, out)
482}
483
484// ---- 表 ----
485
486const cellText = (c: Cell) => (typeof c === 'number' ? fmt(c) : c)
487
488export function tableSvg(columns: string[], rows: Cell[][]): string {
489 const pad = 8
490 const room = W - 32
491 // 数だけの列は右寄せ
492 const numeric = columns.map((_, j) => rows.some(r => typeof r[j] === 'number') && rows.every(r => typeof r[j] === 'number' || r[j] === ''))
493 const natural = columns.map((c, j) =>
494 Math.min(160, Math.max(36, textWidth(c, 12) * 1.05, ...rows.map(r => textWidth(cellText(r[j] ?? ''), 12))) + pad * 2),
495 )
496 const sum = natural.reduce((a, b) => a + b, 0)
497 // 枠の幅いっぱいに伸ばす (狭ければ縮める)
498 const widths = natural.map(w => (w * room) / sum)
499 const xs: number[] = []
500 widths.reduce((x, w) => (xs.push(x), x + w), 16)
501 const headH = 28
502 const rowH = 24
503 const top = 14
504 let out = `<rect x="16" y="${top}" width="${room}" height="${headH}" rx="6" fill="${C.face}" stroke="${C.line}"/>`
505 for (const [j, c] of columns.entries()) {
506 const x = numeric[j] ? xs[j]! + widths[j]! - pad : xs[j]! + pad
507 out += text(x, top + 18, fit(c, widths[j]! - pad * 2, 12), 12, C.blue, `font-weight="bold"${numeric[j] ? ' text-anchor="end"' : ''}`)
508 }
509 for (const [i, r] of rows.entries()) {
510 const y = top + headH + i * rowH
511 let row = i % 2 === 1 ? `<rect x="16" y="${y}" width="${room}" height="${rowH}" fill="${C.face}"/>` : ''
512 for (const [j, c] of r.entries()) {
513 const x = numeric[j] ? xs[j]! + widths[j]! - pad : xs[j]! + pad
514 row += text(x, y + 16, fit(cellText(c), widths[j]! - pad * 2, 12), 12, C.text, numeric[j] ? 'text-anchor="end"' : '')
515 }
516 out += `<g opacity="1">${fadeIn(Math.min(0.8, i * 0.04), 0.3)}${row}</g>`
517 }
518 const bottom = top + headH + rows.length * rowH
519 out += `<line x1="16" y1="${bottom}" x2="${W - 16}" y2="${bottom}" stroke="${C.line}"/>`
520 for (const x of xs.slice(1)) out += `<line x1="${r1(x)}" y1="${top + 4}" x2="${r1(x)}" y2="${bottom}" stroke="${C.line}" stroke-opacity=".6"/>`
521 return frame(bottom + 14, out)
522}
523
524// ---- alt と terminal の行 ----
525
526const cutAlt = (s: string) => (s.length > 600 ? `${s.slice(0, 598)}…` : s)
527
528function chartAlt(title: string, kind: CanvasKind, labels: string[], series: Series[]) {
529 const parts = labels.slice(0, 12).map((l, i) => `${l} ${series.map(s => fmt(s.values[i]!)).join('/')}`)
530 return cutAlt(oneLine(`${title}(${KIND_NAME[kind]}): ${parts.join('、')}${labels.length > 12 ? ` ほか ${labels.length - 12} 件` : ''}`))
531}
532
533function tableAlt(title: string, columns: string[], rows: Cell[][]) {
534 return cutAlt(oneLine(`${title}(表 ${rows.length} 行): ${columns.join(' / ')}`))
535}
536
537/** terminal: 棒は ■ の長さ、ほかは数を並べる */
538function chartLines(kind: CanvasKind, labels: string[], series: Series[]): CanvasLine[] {
539 const out: CanvasLine[] = []
540 if (series.length > 1) out.push({ text: `系列: ${series.map((s, i) => s.name || `系列${i + 1}`).join(' / ')}`, isDim: true })
541 if (kind === 'pie') {
542 const vals = series[0]!.values
543 const total = vals.reduce((a, b) => a + b, 0)
544 for (const [i, l] of labels.entries())
545 out.push({ text: `● ${l} ${fmt(vals[i]!)} (${((vals[i]! / total) * 100).toFixed(1)}%)`, color: pieColor(i) })
546 out.push({ text: `合計 ${fmt(total)}`, isBold: true })
547 return out
548 }
549 const max = Math.max(...series.flatMap(s => s.values.map(Math.abs))) || 1
550 for (const [i, l] of labels.entries()) {
551 if (kind === 'bar' && series.length === 1) {
552 const v = series[0]!.values[i]!
553 out.push({ text: `${l} ${'■'.repeat(Math.max(v === 0 ? 0 : 1, Math.round((Math.abs(v) / max) * 10)))} ${fmt(v)}`, color: v < 0 ? C.red : C.blue })
554 } else out.push({ text: `${l}: ${series.map(s => fmt(s.values[i]!)).join(' / ')}` })
555 }
556 return out
557}
558
559function tableLines(columns: string[], rows: Cell[][]): CanvasLine[] {
560 return [
561 { text: columns.join(' | '), isBold: true, color: C.blue },
562 ...rows.map(r => ({ text: r.map(cellText).join(' | ') })),
563 ]
564}
565
566// ---- ツールの説明と入力の形 ----
567
568export const DESCRIPTION =
569 'ユーザーに図・グラフ・表を見せたい時に使う。ペインに描かれる(直近 20 枚をめくれる)。' +
570 'kind ごとに使う項目: bar(横棒)・line(折れ線)・pie(円) は labels と values、table は columns と rows、markdown は text、svg は svg。' +
571 `上限: title ${LIMIT.title} 字、labels ${LIMIT.points} 件 (pie は ${LIMIT.pie} 件)・1 つ ${LIMIT.label} 字、系列 ${LIMIT.series} 本、` +
572 `columns ${LIMIT.columns} 列、rows ${LIMIT.rows} 行、セル ${LIMIT.cell} 字、text ${LIMIT.text} 字、svg ${LIMIT.svg} 字。` +
573 'svg は <svg で始め、<script>・on で始まる属性・外の物を指す href (http など) は入れない(画像は data: で埋め込む)。' +
574 '入力が合わない時は理由が返るので、直して呼び直す。' +
575 '例: {"title":"月ごとの売上(万円)","kind":"bar","labels":["1月","2月","3月"],"values":[{"name":"2025年","values":[120,135,160]},{"name":"2026年","values":[140,150,190]}]}'
576
577export const INPUT_SCHEMA = {
578 type: 'object',
579 properties: {
580 title: { type: 'string', description: `図の題 (${LIMIT.title} 字まで)` },
581 kind: { type: 'string', enum: [...KINDS], description: 'bar=横棒グラフ、line=折れ線グラフ、pie=円グラフ、table=表、markdown=文章、svg=自分で書いた SVG' },
582 labels: { type: 'array', items: { type: 'string' }, description: 'bar・line・pie の項目名' },
583 values: {
584 description: 'bar・line・pie の数。1 系列なら数の配列、複数系列なら [{ "name": "系列名", "values": [数...] }] (pie は 1 系列だけ)。各系列の数は labels と同じ件数',
585 anyOf: [
586 { type: 'array', items: { type: 'number' } },
587 {
588 type: 'array',
589 items: { type: 'object', properties: { name: { type: 'string' }, values: { type: 'array', items: { type: 'number' } } }, required: ['name', 'values'] },
590 },
591 ],
592 },
593 columns: { type: 'array', items: { type: 'string' }, description: 'table の列名' },
594 rows: { type: 'array', items: { type: 'array', items: { type: ['string', 'number', 'null'] } }, description: 'table の行。1 行はセル (文字列か数) の配列' },
595 text: { type: 'string', description: 'markdown の本文' },
596 svg: { type: 'string', description: 'svg の本文 (<svg で始まる。viewBox を付け、width・height は付けないと枠いっぱいに描かれる)' },
597 },
598 required: ['title', 'kind'],
599} as const
600hooks/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 47 lines1export type CanvasKind = 'bar' | 'line' | 'pie' | 'table' | 'markdown' | 'svg'
2
3/** terminal で描く 1 行 */
4export type CanvasLine = { text: string; color?: string; isDim?: boolean; isBold?: boolean }
5
6/** この mod が組む図の元のデータ。テーマが変わったら描き直すために持つ */
7export type CanvasChart =
8 | { kind: 'bar' | 'line'; labels: string[]; series: { name: string; values: number[] }[] }
9 | { kind: 'pie'; labels: string[]; values: number[] }
10 | { kind: 'table'; columns: string[]; rows: (string | number)[][] }
11
12/** 1 枚。描く物は呼ばれた時に組み終えておく (描画フックは読むだけ) */
13export type CanvasItem = {
14 id: number
15 /** 描いた時刻 (ms) */
16 at: number
17 title: string
18 kind: CanvasKind
19 /** bar / line / pie / table はこの mod が組んだ SVG、svg は Claude が渡した SVG */
20 svg?: string
21 /** bar / line / pie / table の元のデータ。描く時はこれから今のテーマの色で組み直す */
22 chart?: CanvasChart
23 /** markdown の本文 */
24 markdown?: string
25 /** Svg の alt (制御文字なし) */
26 alt: string
27 /** terminal で描く行 */
28 lines: CanvasLine[]
29}
30
31export type CanvasBoard = {
32 /** 古い順、最大 20 */
33 items: CanvasItem[]
34 /** 見ている 1 枚の位置 (items の添字) */
35 pos: number
36 /** 次の id */
37 seq: number
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'canvas-pane': {
43 board: CanvasBoard
44 }
45 }
46}
47