Mod で画面に描く
Mod(モッド)でペイン・プロンプトの上の帯・ボタン・入力欄を描く方法です。描画の仕組み、状態の保ち方、再描画、要素の見本をまとめます。
Mod(モッド、mod)は、Claude Code の中に自分の画面を描けます。本体がすでに描いている部分も変えられます。mod が描ける場所を「描画の場所(render site)」と呼びます。ペイン・プロンプトの上の帯・スピナーなどです。Claude Code は、描画の場所を描く直前に ui.render イベントを起こし、そのイベントのフックが、そこへ描くものを返します。
ターミナルのセッションで mod が描ける場所は次のとおりです。
- 右側のサイドバーとしてのペイン
- トランスクリプトの右上のトースト
- トランスクリプトのログ行
- プロンプトの上の帯
- プロンプトの下のステータスライン
- 描き直せるもの:メッセージ・ツール呼び出しの行・スピナー(プロンプトは本体のものです)
狭いターミナルでは、ペインはトランスクリプトの横ではなくプロンプトの上に出ます。
先に最初の mod を作ってください(Mod を作る・試す)。このページは、2つのタブと数え上げを持つペインを作る例から始まり、変えたい部分ごとの節が続きます。1つの props や上限を引くにはMod のリファレンスを見ます。
タブ付きのペインを作る#
/hello-tabs コマンドを足し、そのコマンドがペインを開く mod を作ります。ペインは、幅の広い全画面のターミナルではトランスクリプトの横のサイドバー、それ以外ではプロンプトの上の枠のついた領域です。このペインには2つのタブがあり、2つ目のタブには、数に1を足すボタンがあります。数は、Claude Code を再起動しても残ります。
タブは、横に並べた2つのボタンです。mod がどちらが開いているかを覚えておき、その下にそのタブの中身を描きます。
1. プラグインを作る#
mod は、マニフェスト・コードを指す hooks.json・コードのファイルを持つプラグインです。hello-tabs ディレクトリの中に .claude-plugin と hooks を作り、最初の2つのファイルを保存します。
hello-tabs/.claude-plugin/plugin.json:
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" }
}
hello-tabs/hooks/hooks.json に入口を書きます。
{
"modules": ["./register.js"]
}
2. コードを書く#
コードの中のフックは、次の順に働きます。
/hello-tabsコマンドを足し、前のセッションが保存した数を読み込む- そのコマンドが実行されたらペインを開く
- ペインの中身(タブの行と、開いているタブの本体)を描く
ペインの状態は、モジュールの変数 tab と count が持ちます。hello-tabs/hooks/register.js として保存します。
// ペインの id。ペインを開くときと、描くときに見分けるのに使う
const PANE = 'hello-tabs'
// ペインが見せるもの:開いているタブと、数の値
let tab = 'one'
let count = 0
export function register(on) {
// 最初のプロンプトの前と、再読み込みのあとに動く
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// 前のセッションが保存した数があれば読み込む
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// /hello-tabs と打ったときに動く
on('command.run', { command: 'hello-tabs' }, async ($) => {
// ペインを開き、キーボードを渡し、Esc で閉じられるようにする
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// トランスクリプトには何も出さない
return {}
})
// Claude Code がペインを描くたびに動く
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// ほかの mod のペインには手を出さない
if (e.requestId !== PANE) return next(e)
// このアプリが描ける要素を得る
const { Box, Text, Button } = $.ui.resolve(e)
// このフックをもう一度動かすよう頼む
const redraw = () => $.ui.invalidate('ui.render')
// 1つのタブ:押すとそのタブに切り替わるボタン
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// 開いていないタブは薄くする
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// タブの下に出るもの。どちらが開いているかで変わる
const body =
tab === 'one'
? [Text({ children: ['This is the first tab.'] })]
: [
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
hotkey: 'a',
onPress: async () => {
count += 1
redraw()
// 再起動のあとも残るよう、数を保存する
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// ペイン全体:タブの行、空行、本体
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}
コードだけでは見えにくい点が3つあります。session.start は、セッションをまたいで残るキーと値のストア $.store から数も読みます。command.run はペインがあることを伝えるだけで、中身はそのあと Claude Code が ui.render を起こして尋ねます。ui.render は、動くたびに tab と count から要素の木(Box の中に Box・テキスト・ボタンを入れたもの)を作り直します。
操作できる描画は、どれもこの回り方(描画のサイクル)です。ボタンを押すと onPress が変数を変えて redraw を呼び、Claude Code が ui.render をもう一度動かして、新しい値で木を作り直します。
3. ペインを開く#
シェルで claude --plugin-dir ./hello-tabs として起動し、プロンプトで /hello-tabs を実行します。上に 1: One と 2: Two が並んだペインが開きます。2 を押し、「Add one」のホットキー a を何度か押すと、数が増えます。
4. 数が保存されたかを確かめる#
Esc でペインを閉じてセッションを終え、同じ claude --plugin-dir ./hello-tabs でもう一度起動して /hello-tabs を実行します。数は終わったところのままです。数を消すには、mod に $.store.delete('count') を呼ばせます。
描く場所を選ぶ#
ui.render のフックは、絞らなければすべての描画の場所で動きます。on の第2引数にフィルター(マッチャー)を渡して絞ります。{ component: 'Pane' } ならペインだけです。フックの中では次の値を見ます。
| 値 | 中身 |
|---|---|
e.component |
描画の場所の名前 |
e.surface |
描いているアプリ |
e.props |
その場所のデータ |
e.requestId |
ペインのとき、開いたときの id |
ペインと帯は、mod が埋めるまで空です。
| 場所 | 中身と描き方 |
|---|---|
| ペイン | 幅の広い全画面のターミナルではトランスクリプトの横のサイドバー、それ以外ではプロンプトの上の枠のついた領域。複数開くと、それぞれにタイトルのタブが付く。mod が選んだ id で $.ui.open を呼ぶと現れる(例:$.ui.open({ id: 'hello-tabs' }))。描くには { component: 'Pane' } で絞り、e.requestId が自分の id かを確かめる |
| プロンプトの上の帯 | プロンプト入力の真上の帯。常にあり、すべての mod が共有する。描くには { component: 'AbovePrompt' } で絞る |
帯に何も出さないときは next(e) を返します。木を返すと、自分のあとに動く mod が帯に描くものを置き換えるので、それも残すなら await next(e) の結果を自分の Box の子に入れます。
本体がすでに描くものを変える#
Claude Code は、メッセージ・ツール呼び出しの行・スピナーなど、画面の大半を自分で描きます。それらも描画の場所なので、mod が見た目を変えたり差し替えたりできます。ui.render のフックを、次の表の名前で絞ります。
| 場所 | 何か |
|---|---|
UserMessage・AssistantMessage |
トランスクリプトのメッセージ |
ToolUse・ToolResult・ToolGroup |
ツール呼び出しの行・その結果・まとめて畳んだ呼び出しの群 |
CommandOutput |
コマンドが出力した行 |
AskUserQuestion |
Claude が質問するときに開くダイアログ |
Spinner・ToolProgress・TurnDuration |
ターンのステータス行。Claude が作業中に動く行、動いているツールの進み具合の行、ターンを締めくくる行 |
InfoNotice・SessionMode・PromptHint |
ロゴの下のステータス行、フッターのモードの表示、プロンプトの下のヒントの行 |
本体が描く場所では、フックは細部を変える・描画を差し替える・そのままにする、のどれかができます。次の例はスピナーに当てたものです。別のフックが数える calls 変数を読みます。
細部を変える:本体の描画を残して一部だけ変えるには、props を変えたイベントのコピーを next に渡します。スピナーの言葉のあとの文字を変える例です。
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// 本体のスピナーを残し、言葉のあとの文字を変える
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
スピナーは動きと言葉を保ち、そのあとに自分の文字が続きます(例:Thinking · tool calls: 2…)。
描画を差し替える:その場所の代わりに自分のものを描くには、木を返し、next を呼びません。スピナーの代わりに1行のテキストを描く例です。
on('ui.render', { component: 'Spinner' }, async ($, e) => {
const { Text } = $.ui.resolve(e)
// next を呼ばないので、この行がスピナーの代わりに描かれる
return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
})
Claude が作業するあいだ、自分の行が出て、本体のスピナーは出ません。
そのままにする:本体の描画のままにするには next(e) を返します。イベントによって返すかどうかを変えるフックもよくあります。数えるものができるまでスピナーをそのままにする例です。
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// まだ見せるものがないので、イベントをそのまま渡す
if (calls === 0) return next(e)
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
最初のツール呼び出しの前は、mod がないときと同じ Thinking… です。
本体が描く場所では、next(e) は本体の描画への参照 { type: 'engine', ref } を返します(あとに動く mod が自分の木を返したときを除く)。参照はそのまま返しても、Box に入れて自分の要素と並べてもかまいません。描画の中身を変えたいときは、参照ではなく、「細部を変える」のように props を変えたコピーを next に渡します。
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
const { Box, Text } = $.ui.resolve(e)
const theirs = await next(e)
return Box({ flexDirection: 'column', children: [theirs, Text({ children: ['under the spinner'] })] })
})
スピナーはいつもどおり動き、その下に under the spinner が出ます。
補足
権限の確認画面は描画の場所ではないので、mod には変えられません。質問のダイアログ AskUserQuestion は変えられますが、木に参照をちょうど1回入れ、自分の要素をその上に置く形にします。そうでないと、Claude Code が自分のダイアログを描きます。
起きる描画の場所は、ターミナルとデスクトップアプリで少し違います。Pane・AbovePrompt・Spinner・トランスクリプトの場所は両方で、ほかのステータス行の一部はターミナルだけです。場所ごとの対応はMod のリファレンスの描画の場所の表にあります。
ペインを適切なタイミングで開く#
ペインは、mod が開いたときだけ出ます。いつ・どう開くかで、キーボードを取るか、どれだけ場所を取るか、狭いターミナルで出るかが変わります。
開くには自分で決めた id で $.ui.open を、閉じるには同じ id で $.ui.close を呼びます。ui.render のフックも、この id で自分のペインを見分けます。
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
await $.ui.close({ id: 'hello-tabs' })
$.ui.open が id のほかに取る任意のフィールドです。
| フィールド | 働き |
|---|---|
title |
複数のペインが開いているときの、ペインのタブの表示 |
focus |
キーボードフォーカスを求める |
closeOnEscape |
Esc でペインを閉じるようにする |
holdToasts |
ペインが閉じるまで、$.ui.toast の小さな通知(トースト)を保留する |
rows |
ペインがプロンプトの上にあるときに求める高さ。既定は空きの3分の1 |
columns |
ペインがトランスクリプトの横にあるときに求める幅 |
focus・closeOnEscape・holdToasts は true しか受け付けません。false を渡すと ui.open: focus is true or left out のようなエラーになるので、付けないときは書かずにおき、条件で付けるときはフィールドごと足します。次は items が空でないときだけフォーカスを求める例です。
const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)
Claude の作業中にもコマンドでペインを開けるようにするには、コマンドの登録に immediate: true を足します。ないと、ターンの途中に打ったコマンドはターンの終わりまで待ちます。
広いターミナルまで待つペイン#
小さな画面を乗っ取らないよう、頼まれずに開いたペインは、狭いターミナルでは出ません。出る幅は、開いたきっかけで決まります。
| 開いたきっかけ | 出る幅 |
|---|---|
| ユーザーの操作(コマンドの実行・ボタンの押下など) | どの幅でも |
mod 自身(タイマーや turn.start のフックなど) |
144桁以上。ユーザーが一度そのペインを自分で開いたあとは110桁で足りる |
出たかは $.ui.open の結果で分かります。出れば { isPlaced: true }、待っていれば isPlaced が false で、reason に理由の文字列が入ります。待っているペインは、ユーザーが開くかターミナルを広げると出ます。ペインを開かずに知らせるだけなら、トーストを出す $.ui.toast('Your message') を使います。
要素から木を作る#
ui.render のフックが返すのは要素の木、つまり、箱・テキスト・部品を入れ子にした、描くものの記述です。描画を記述すると、Claude Code がターミナルやデスクトップアプリに描きます。
要素を得るには、フックの中で $.ui.resolve(e) を呼びます(例:const { Box, Text, Button } = $.ui.resolve(e))。要素はそれぞれ関数で、props を渡し、中に入れる要素と文字列を children に入れます。
よく使う要素と、ターミナルでの見え方です。
Text:スタイル付きの文字列を描く。
Text({ children: ['This is the first tab.'] })
Box:中身を行か列に並べる。ボタンと文字を、2桁あけて横に並べる例です。
Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({ key: 'more', label: 'Add one', onPress: addOne }),
Text({ children: ['Count: 0'] }),
],
})
ターミナルでは [ Add one ] Count: 0 のように出ます。
Button:ユーザーが押せる部品で、onPress を呼ぶ。plain: true なら括弧がなく、ホットキーが出ます。
Button({ key: 'more', label: 'Add one', onPress: addOne })
Button({ key: 'tab-one', label: 'One', hotkey: '1', plain: true, onPress: showTabOne })
ターミナルでは [ Add one ] と 1: One のように出ます。
Input:テキスト欄。Enter を押すと、onSubmit に文字を渡して呼びます。
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
value: '',
submitLabel: 'add',
onSubmit: addNote,
})
ターミナルでは Note: Type a note and press Enter のように出ます。
要素の全一覧です。
| 要素 | 描くもの | どこで |
|---|---|---|
Box |
flex のコンテナ。flexDirection・columnGap・padding・borderStyle・width などのレイアウトの props を取る |
どこでも |
Text |
スタイル付きのテキスト。color・bold・dimColor・italic・wrap を取る。color はテーマのキーか 'red' のような色。wrap は 'wrap'・'truncate'・'truncate-start'・'truncate-middle'・'truncate-end' |
どこでも |
Button |
onPress を呼ぶ部品 |
どこでも |
Link・Code・Markdown |
href と任意の label を持つリンク、コードブロック、Claude の返信と同じ形式の文字。Markdown は中身を children でなく text の props で受け取り、onLinkPress を渡すときは key が要る |
どこでも |
Input・Select |
テキスト欄とドロップダウン | ターミナル・デスクトップ |
Svg |
SVG のドキュメント | デスクトップ |
Client |
アニメーションとポインター入力のための、自分の2つ目のファイルが描く領域。そのファイルは mod API を持たず、データを投稿して自分のフックへ届け、ui.message イベントとして届く。読み込み・描画・実行に失敗すると、フックに ui.fault イベントが届く |
ターミナル・デスクトップ |
Raster・Image |
色付きのセルの格子と、画像 | ターミナル |
モジュールが .tsx か .jsx のファイルなら、木を JSX で書けます。先に $.ui.resolve(e) から要素を取り出しておきます。
アプリが持たない要素・要素が取らない props・子を置けない場所の子を木が使うと、Claude Code はその場所の自分の版を描きます。--plugin-dir で始めたセッションでは、そのことを知らせる行がトランスクリプトに出ます(例:ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own)。デバッグログには、同じ理由で ui.render (Pane): a hook returned a tree that does not validate と記録されます。セッションにはほかに何も出ないので、描画が出ないときはこの行かログを確かめます。
色付きのセルの格子を描く#
ターミナルでヒートマップ・スパークライン・ゲーム盤を描くときは、セルごとに Box を作らず、1つの Raster を描きます。Raster は、key、columns と rows の大きさ、全セルを詰めた base64 の文字列 cells を取ります。1つのセルは3つの数、つまり文字のコードポイント・色・背景色です。色は 16 進の24ビット RGB(赤なら 0xc62828)で、その範囲の1つ上の 0x01000000 は、ターミナルの既定を表します。
デスクトップアプリには Raster がないので、e.surface を調べて、そちらではテキストを描きます。次のペインの本体は、3列2行のヒートマップを描きます。
// 「ターミナルの既定の色を使う」という値
const DEFAULT_COLOR = 0x01000000
// [文字, 色] の組の行を、Raster が取る1つの文字列に詰める
// 1つのセルは3つの数:文字のコードポイント・色・背景
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// id が 'heat' で開いたペインだけに描く
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// 3セルの行が2つ。それぞれブロック文字とその色
const rows = [
[['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
[['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
]
if (e.surface !== 'terminal') {
return Text({ children: ['The heat map needs the terminal.'] })
}
return Box({
flexDirection: 'column',
children: [Raster({ key: 'grid', columns: 3, rows: 2, cells: cellsOf(rows) })],
})
})
ターミナルでは、ペインに緑・琥珀・赤の行と、緑・緑・琥珀の行の格子が出ます。変えるのは rows の配列で、cellsOf がそれを詰めた文字列にします。フックは id が heat のペインにだけ描くので、コマンドから $.ui.open({ id: 'heat' }) でペインを開きます。
文字は1セルの幅でなければなりません。すでに画面にある Raster をアニメーションさせるには、ペインの id を requestId にして、Raster の key・同じ大きさ・新しいセルで $.ui.blit を呼びます(この例なら $.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }))。ui.render のフックを動かし直さずに、その1つの要素だけを描き直します。
押す・打つに応える#
ユーザーが mod の部品を使うと、Claude Code はその部品のコールバックを、mod のモジュールの中で呼びます。
| 部品 | コールバック |
|---|---|
Button |
onPress(e)。e.surface は押したアプリ |
Input |
onSubmit(value) と onInput(value) |
Select |
onSelect(value)。選択肢は options に、値が重ならない1つ以上の選択肢の一覧を書く(例:[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]) |
テストは部品を key で押す・打つので、部品には1つずつ key を付けます。
部品が使われると、ui.press・ui.input・ui.select のイベントも(e.element に key を入れて)起き、ほかの mod が扱えます。そのフックはあなたのコールバックより先に動くので、あなたの Input に打たれた内容を見る・変える・コールバックの代わりに答えることができます。逆に、ほかの mod のボタンを押す mod API はありません。
キーボードフォーカスとホットキー#
mod はキーボードを直接読みません。Claude Code がキーをどの部品のものか決め、その部品のコールバックを動かします。帯の数字のホットキーを除き、これが起きるのはペインか帯がキーボードフォーカスを持つあいだだけで、ほかのときキーはプロンプトへ行きます。
ペインがフォーカスを得るのは次のときです。
- mod が、コマンドや押下から
focus: trueで開いた - ユーザーが Ctrl+X のあとに Tab を押した
- ユーザーがクリックした
ただし focus: true が通るのは、プロンプトが空で、ほかにフォーカスを持つものがないときだけです。ユーザーが打っている最中に開いたペインは、打鍵を奪いません。
フォーカスがあるあいだのキーの働きです。
| キー | 働き |
|---|---|
| Tab | 次の部品へ移る |
| ↑・↓ | 描画が収まるあいだは部品の間を移る。ペインや帯が見せられる行より多いときはスクロールする |
| Enter | フォーカスのある Button を押す・Input を送信する・Select で選ぶ |
| ボタンのホットキー | そのボタンを押す。Input にフォーカスがあるあいだは、打てるキーはすべて欄へ行く |
| Page Up・Page Down・Home・End | ペインや帯が見せられる行より多いとき、スクロールする |
| Ctrl+X のあと矢印キー | ペインの大きさを変える。左か上で広げ、右か下で戻す |
| Ctrl+X のあと X | 欄にフォーカスがあるときでも、ペインを閉じる |
| Esc | キーボードフォーカスをプロンプトへ返す。closeOnEscape: true ならペインも閉じる |
Tab と矢印キーは別の用途に割り当てられないので、ゲームなら w・a・s・d で操作させます。
ホットキーと最初のフォーカスを決める#
hotkey:Buttonを1つのキーで押せるようにする。1桁の数字か小文字1字(例:hotkey: 'a')autoFocus:ペインを開いたときにフォーカスを置く部品にautoFocus: trueを付ける。trueしか受け付けないので、ほかの部品には書かない
ホットキーの見え方は、ボタンの形とアプリで変わります。
| ボタン | ターミナル | デスクトップアプリ |
|---|---|---|
| 括弧付き(既定) | [ Add one ](ホットキーは出ない) |
ラベルと、その横の小さなキー |
plain: true |
1: One |
ラベルと、その横の小さなキー |
ターミナルでは括弧付きのボタンにホットキーが出ないので、ラベルにキーの名前を書くか plain: true にします。Button のほかの規則(action・帯の数字のホットキー・1つのホットキーを2つのボタンで使う場合)はMod のリファレンスの要素の節にあります。
打った文字を受け取り、項目ごとに行を描く#
多くのペインは、テキスト欄と、その下の一覧です。この節の例はメモのペインで、メモを打って Enter で足し、各メモの x ボタンで消します。2つのメモを足したあと、ターミナルには次のように描かれます。
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
使う技法は2つです。
- 打った文字を受け取る:
Inputは、ユーザーが Enter を押すと欄の文字でonSubmit(value)を、変化のたびにonInput(value)を呼ぶ - 一覧を描く:データを1行ずつに写し、各行のボタンに別々の
keyを付ける
ペインの中身を描くフックです。
// ペインが描く一覧
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// id が 'notes' で開いたペインだけに描く
if (e.requestId !== 'notes') return next(e)
const { Box, Text, Button, Input } = $.ui.resolve(e)
const redraw = () => $.ui.invalidate('ui.render')
return Box({
flexDirection: 'column',
children: [
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
// 毎回、空の欄として描く。送信のあとに欄が空になる
value: '',
submitLabel: 'add',
autoFocus: true,
// 欄で Enter を押したときに動く
onSubmit: async (value) => {
// 空の行は無視する
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// メモごとに1行:削除ボタンと、メモの文字
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// 行ごとのボタンを見分けられるよう、それぞれに key を付ける
key: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})
試し方です。
- メモを足す:1行打って Enter を押す。行が新しく並び、欄は空になる
- メモを消す:メモの
xボタンにフォーカスが来るまで Tab を押し、Enter を押す。xはボタンのラベルでホットキーではないので、その文字を打っても押されない
どの変更も hello-tabs と同じ描画のサイクルです。コールバックが notes を変え、redraw を呼び、一覧を $.store に保存します。
送信のあとに欄が空になるのは value の props のためです。value は欄が描かれるときに持つ文字で、フックが欄を描き直すまでは、ユーザーの入力がそれを置き換えます。この例は、いつも '' で欄を描きます。
この例はメモを保存しますが、読み込みません。次のセッションで戻すには、hello-tabs が count を読むのと同じように、session.start のフックで読みます。
欄の1行 Note: Type a note and press Enter ⏎ add は、次の props でできています。
| props | 例での値 | 内容 |
|---|---|---|
label |
Note |
欄の前の文字。ターミナルは後ろに : を描く |
placeholder |
Type a note and press Enter |
欄が空のあいだ出る薄い文字 |
submitLabel |
add |
⏎ の後ろの、Enter が何をするかを示す語 |
Input の送信は、コールバックが $.prompt.submit を呼ばない限り、ターンを始めません。
描き直す#
画面に出ているのは、ui.render のフックが最後に返したものの写しです。新しいものを見せるにはフックをもう一度動かす必要があり、Claude Code が自分で動かすときと、mod が頼むときがあります。
頼まなくても Claude Code が描き直すとき#
その場所の props が変わったときと、ターミナルの幅が変わったときです。場所の中の Client が失敗し、mod が ui.fault を扱っているときも、ui.fault のフックが返ったあとにもう一度動かすので、ui.render のフックは Client を外して描けます。タイマーでは動かさず、モジュールの変数が変わったことにも気づきません。
データが変わったときに描き直す#
自分のデータを変えたら $.ui.invalidate('ui.render') を呼びます。押した回数を数えるボタンなら、コールバックで数を変えたあとに呼びます。
Button({
key: 'more',
label: 'Add one',
onPress: () => {
count += 1
// データが変わったので、描き直しを頼む
$.ui.invalidate('ui.render')
},
})
hello-tabs の redraw 関数は、この呼び出しを包んだものです。$.state に置いた値はこの呼び出しが要りません。書くと、その値を読む場所が描き直されます。
タイマーで描き直す#
時計・カウントダウン・セッションの外の値のように、決まった間隔で新しくしたいものは、session.start のフックで $.clock.every のタイマーを始めます(hello-tabs のようにフックがすでにあれば、その中に1行足します)。
on('session.start', async ($, e, next) => {
// 1000 ミリ秒ごとに描き直しを頼む
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
これで ui.render のフックが1秒に1回動きます。モジュールが再読み込みされるとタイマーは止まり、新しいモジュールが自分のタイマーを始めます。
場所を描き直せる頻度#
描き直しは Claude Code が間引くので、$.ui.invalidate はデータが変わるたびに呼んでかまいません。上限(場所ごとの値はMod のリファレンスの上限の表)より速い呼び出しは1回にまとめられ、そのときのデータで描かれます。最新の値は出ますが、途中の値は出ません。アニメーションも上限より速くは動けません。
状態を保つ#
値をどこに置くかで、どれだけ残るかが決まります。
| 置き場所 | 残る期間 | 使いどころ |
|---|---|---|
| モジュールの変数 | モジュールが再読み込みされるまで(開発中はファイルを保存するたびに起こる) | 失ってよい値(hello-tabs の tab など) |
$.state |
セッションが終わる、またはユーザーが /clear・/resume・/branch を実行するまで |
再読み込みのあとも残したい、描画が頼る値 |
$.store |
mod が消すか、どのセッションも cleanupPeriodDays のあいだストアを読み書きしなくなるまで。キーと値のストアで、プラグイン専用の JSON ファイルとして ~/.claude/plugins/store/ に保存される |
設定・履歴・次回もあってほしいものすべて |
$.store.get(key) は値か undefined で解決し、$.store.set(key, value) は任意の JSON の値を取ります。
$.state に値を置く#
$.state はリアクティブな状態です。ui.render のフックが値を読むとその値を購読し、値が書かれるたびに Claude Code がその場所を描き直すので、$.ui.invalidate は要りません。セッションのあいだ残り、変数と違ってモジュールの再読み込みでも消えません。
使うまでの手順は3つです(例は hello-tabs の count を $.state へ移すもの)。
1. 値を宣言する#
型の宣言ファイルの、プラグイン名のキーの下に、値と型を並べます。hello-tabs/types/index.d.ts に保存します。
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
2. マニフェストを宣言へ向ける#
マニフェストに types フィールドでそのパスを書くと、claude plugin validate がコードを宣言と照らして検査します。
{
"name": "hello-tabs",
"version": "0.1.0",
"description": "Opens a pane with two tabs and a counter",
"author": { "name": "Your Name" },
"types": "./types/index.d.ts"
}
3. 値を定義・読み・書きする#
補助関数は3つです。atom が値に名前と既定値を付け、read が読み、update が書きます(中で $.state.get と $.state.set を呼びます)。
import { atom, read, update } from 'claude-code'
// モジュールの先頭:値に名前を付け、既定値を与える
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// ui.render のフック:描くために値を読む
const n = await read($, count)
// Button の中:古い値から新しい値を書く
onPress: () => update($, count, (value) => value + 1)
ui.render のフックが count を読んでいるので、ボタンが書くたびにそのフックが動き直します。守る規則は次の3つです。
pluginとkeyは文字列リテラルで書く:claude plugin validateがソースから読む- すべての値を型の宣言ファイルに宣言する:しないと、検証が
hello-tabs.count is not declaredで失敗する - コールバックか別のイベントのフックから書く:
ui.renderのフックは状態を読めても書けないので、onPress・onSubmit・別のイベントのフックから書く
hello-tabs を $.state に変える#
count を使う行をすべて書き換えます。
- モジュールの先頭:
importの行を足し、let count = 0をatomの行に置き換える ui.renderのフック:tabButtonの前にreadの行を足し、Textで'Count: ' + nを描く- 「Add one」のボタン:
onPressを、数を書くだけでなく保存もする、下の「複数のセッションから保存する」のものに置き換える session.startのフック:savedを読む2行を、下の「/clearのあとに保存した値を読み直す」のloadCountの呼び出しに置き換える
tab は変数のままなので、タブのボタンのための redraw は残します。
/clear のあとに保存した値を読み直す#
/clear・/resume・/branch は $.state の値をすべて既定値に戻しますが、session.start はもう起きません。session.start で $.store の値を $.state へ写す mod は、そのままだと描画が既定値を見せ、$.state の値を保存するコールバックが保存済みの値を既定値で上書きしてしまいます。
そこで classic.SessionStart で写し直します。このイベントは3つのコマンドのあとに e.source を clear・resume・fork にして起きます。起動時と圧縮のあとにも起きますが、圧縮は $.state を戻さないので、source で3つに絞ります。次のコードは、$.state 版の hello-tabs(count が atom で、update を import 済み)に足すものです。loadCount は register の上に置き、すでにある session.start のフックに呼び出しを足します。
// $.store の保存した数を $.state に写す。保存がなければ 0
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// 最初のプロンプトの前と、再読み込みのあとに動く
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// /clear・/resume・/branch のあとにまた動く(/branch は fork と報告される)
on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
await loadCount($)
return next(e)
})
これで /clear のあとも、ペインは 0 ではなく保存した数を見せ、次の「Add one」は保存した数に足されます。
loadCount は保存した値で $.state を上書きし、session.start はモジュールの再読み込みのたびに起きます。ストアが古い値のままにならないよう、「Add one」のボタンのように変更のたびに保存します。セッションを開かずに確かめるテストはMod を作る・試すの「/clear のあとの描画をテストする」にあります。
複数のセッションから保存する#
同じ端末で mod を動かすセッションは、1つの $.store を共有します。get のあとの set は不可分ではないので、2つのセッションがそれぞれ読んで変えて書き戻すと、あとの書き込みが先の書き込みを消します。次の2つで起きにくくできます。
- 項目ごとに別のキーを使う:
setは自分のキーだけを変えるので、別のキーに書くセッション同士は上書きし合わない - 書く直前に読み直す:複数のセッションが変える値は、
session.startで読んだコピーではなく、コールバックの中でgetした値から新しい値を作る。それでも、getとsetのあいだに入った別のセッションの書き込みは失われる
次のボタンは、いまストアにある値に1を足してから描画を更新します。
onPress: async () => {
// いまストアにある値を読む。別のセッションが変えているかもしれない
const saved = Number((await $.store.get('count')) ?? 0)
// 新しい数を保存してから見せる
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
このセッションの開始後に別のセッションがボタンを3回押していれば、この押下で見せて保存する数には、その3回も入ります。
要素の見本#
要素ごとの短いコードと、ターミナルでの見え方です。すべての props は、Mod を作る・試すの型の取り方で手に入る型宣言にあります。
見本を自分のターミナルで試す#
見本は mod 全体ではなく、要素1つ(と、その中に入れた要素)の断片です。試すときは、ペインを開いて見本を描く /gallery コマンドだけの小さな mod を作り、そこへ貼り込みます。
gallery ディレクトリの中に .claude-plugin と hooks を作り、gallery/.claude-plugin/plugin.json を保存します。
{
"name": "gallery",
"version": "0.1.0",
"description": "Opens a pane that draws one sample",
"author": { "name": "Your Name" }
}
gallery/hooks/hooks.json に入口を書きます。
{
"modules": ["./register.js"]
}
コードは gallery/hooks/register.js に保存します。/gallery コマンドがペインを開き、そこに Plain text を描きます。
// コールバックを取る見本で、自分のコールバックの代わりに使う
const noop = () => {}
// Select の見本は、選んだものをここに持つ
let picked = 'md'
// Raster の見本は、この関数でセルを詰める
const DEFAULT_COLOR = 0x01000000
function cellsOf(rows) {
const numbers = rows.flat().flatMap(([char, color]) => [char.codePointAt(0), color, DEFAULT_COLOR])
return new Uint8Array(Uint32Array.from(numbers).buffer).toBase64()
}
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'gallery', description: 'Open the sample pane' })
return next(e)
})
on('command.run', { command: 'gallery' }, async ($) => {
await $.ui.open({ id: 'gallery', focus: true, closeOnEscape: true })
return {}
})
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== 'gallery') return next(e)
const { Box, Text, Button, Input, Select, Link, Markdown, Code, Raster, Svg } = $.ui.resolve(e)
// return のあとの要素を、見本に置き換える
return Text({ children: ['Plain text'] })
})
}
gallery を含むディレクトリで claude --plugin-dir ./gallery と起動し、/gallery を実行すると Plain text のペインが開きます。あとは return のあとの Text({ children: ['Plain text'] }) を見本に差し替えて保存し、/gallery をもう一度実行します(保存のたびにモジュールが再読み込みされます)。
見本の一覧#
どの要素を出したいかで、次のように分かれます。
| やりたいこと | 要素 |
|---|---|
| 文字を出す | Text・Markdown・Link |
| コードと変更を出す | Code |
| 要素を並べる | Box |
| 入力を受ける | Button・Input・Select |
| 絵を描く | Raster・Svg・Image・Client |
Text#
スタイル付きの文字列です。次はスタイルごとに1行ずつ描きます。
Box({
flexDirection: 'column',
children: [
Text({ children: ['Plain text'] }),
Text({ bold: true, children: ['bold'] }),
Text({ italic: true, children: ['italic'] }),
Text({ underline: true, children: ['underline'] }),
Text({ strikethrough: true, children: ['strikethrough'] }),
Text({ dimColor: true, children: ['dimColor'] }),
Text({ inverse: true, children: ['inverse'] }),
Text({ color: 'red', children: ["color: 'red'"] }),
Text({ backgroundColor: 'blue', children: ["backgroundColor: 'blue'"] }),
],
})
dimColor は灰色になり、backgroundColor は文字の幅だけを塗ります。
Markdown#
Claude の返信と同じ整え方をします。中身は children ではなく text に渡します。
Markdown({
text: '## Release notes\n\nThis build has **two** fixes and one `flag`:\n\n- Faster start\n- Fewer prompts\n\n> Quoted text',
})
見出しは # が消えて太字に、インラインのコードはバッククォートが消えて色付きに、引用は左に縦棒の付いた斜体になります。
Link#
ラベルと、そのあとに URL を描きます。
Link({ href: 'https://code.claude.com/docs', label: 'Claude Code docs' })
ターミナルでは URL は文字として出るだけで、クリックで開けるかはユーザーのターミナル次第です。
Code#
言語は language で指定するか、path を渡して推測させます。startLine を付けると、その番号から行番号が振られます。
Code({
language: 'javascript',
startLine: 1,
source: "const name = 'mods'\nconsole.log('hello ' + name)",
})
色はユーザーのテーマに従います。
diff としての Code#
format: 'diff' にすると、source を1つ以上の unified diff のハンクとして描きます。
Code({
format: 'diff',
source: '@@ -1,3 +1,3 @@\n # Mods\n-A mod is a plugin.\n+A mod is a plugin that runs code.\n Read on.',
})
@@ の行は行番号に置き換わり、削除行は赤、追加行は緑で塗られます。削除行と追加行が似ているところは、変わった語だけ濃く塗られます。
Box#
中身を行か列に並べ、枠線も描けます。次は単語の行の下に、枠付きの箱を置きます。
Box({
flexDirection: 'column',
gap: 1,
children: [
Box({
flexDirection: 'row',
columnGap: 4,
children: [Text({ children: ['a row'] }), Text({ children: ['of three'] }), Text({ children: ['items'] })],
}),
Box({
borderStyle: 'round',
paddingX: 1,
children: [Text({ children: ["borderStyle: 'round'"] })],
}),
],
})
枠線はペインの幅いっぱいに伸びます。
入力の部品#
Button・Input・Select は、Tab で移ってフォーカスのあるものを使う部品です。ペインを focus: true で開くとペインにフォーカスが来ますが、文字が Input に入るのはその欄にフォーカスが来てからです。開いてすぐ打たせたい欄には autoFocus: true を付けます。
Button#
押すと onPress が動きます。既定の形・ホットキー付きの plain・薄い表示の3つです。
Box({
flexDirection: 'column',
children: [
Button({ key: 'save', label: 'Save', onPress: noop }),
Button({ key: 'next', label: 'Next', hotkey: 'n', plain: true, onPress: noop }),
Button({ key: 'skip', label: 'Skip', dimColor: true, onPress: noop }),
],
})
括弧付きの Save、括弧なしで n が色付きの n: Next、灰色で括弧付きの Skip が並びます。フォーカスのあるボタンは反転表示になります。
Input#
1行のテキスト欄で、Enter で onSubmit が動きます。
Input({
key: 'title',
label: 'Title',
placeholder: 'Type a title and press Enter',
value: '',
submitLabel: 'save',
onSubmit: noop,
})
フォーカスがないときはラベルとプレースホルダーだけです。フォーカスが来るとラベルが太字になってカーソルが出て、⏎ のあとに submitLabel が出ます。打つとプレースホルダーが消えます。
Select#
選択肢から1つを選ばせ、選んだものの value で onSelect を呼びます。
Select({
key: 'format',
label: 'Format',
value: picked,
options: [
{ value: 'md', label: 'Markdown' },
{ value: 'html', label: 'HTML' },
{ value: 'txt', label: 'Plain text' },
],
onSelect: (value) => {
picked = value
},
})
閉じているときはラベル・いまの選択肢・小さな下向き矢印の1行で、開くと選択肢が並んで1つに印が付き、選ぶとまた閉じます。
Raster#
色付きの文字セルの格子で、ヒートマップ・スパークライン・ゲーム盤向けです。ターミナルが描きます。セルを詰めるのは、試すための mod にある cellsOf です(仕組みは上の「色付きのセルの格子を描く」)。
Raster({
key: 'grid',
columns: 3,
rows: 2,
cells: cellsOf([
[['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
[['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
]),
})
色は小さめのパレットに丸められるので、0x2e7d32 は #337733 で描かれます。
Svg#
デスクトップアプリで SVG のドキュメントを描きます。
Svg({
alt: 'Three bars of rising height',
width: 120,
height: 60,
source:
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60"><rect x="10" y="40" width="20" height="20" fill="#2e7d32"/><rect x="50" y="25" width="20" height="35" fill="#f9a825"/><rect x="90" y="5" width="20" height="55" fill="#c62828"/></svg>',
})
ターミナルでは、Svg だけを返すペインは空になります。ターミナルで何か出すなら、e.surface を見て別の木を返します。
Image と Client#
この2つは見本がありません。Image はターミナルに PNG か生のピクセルを描き、Client はアニメーションとポインター入力のために自分の2つ目のファイルが描く領域です。props はMod のリファレンスの要素の節にあります。
mod が描ける、そのほかの場所#
見本はすべてペインに描きます。mod は、ほかの場所にも描け、Claude Code に何かを見せてもらうこともできます。
- ペインと帯:このページの「描く場所を選ぶ」
- スピナーなど、Claude Code 自身の行:このページの「本体がすでに描くものを変える」
- トースト・ステータスライン・ログ行:ターンを始めずに何かを見せる mod API(Mod のリファレンス)
- 質問のダイアログ:ユーザーが決めるまでツール呼び出しを止める方法(Mod のリファレンス)
次に読むページ#
- Mod のリファレンス:描画の場所ごとの props と、要素ごとの props
- Mod を作る・試す:描画をテストで確かめ、ボタンを押す
- Mod(モッド)を使う:mod の入れ方・信頼・組織での管理
公式ドキュメント(英語)
2026年10月5日時点の内容をもとに、日本語でまとめています。