mod でインターフェースに描画する
Claude Code の mod からペイン、プロンプト上部の帯、ボタン、テキストフィールドを描画し、押下や入力を処理して、再描画やセッションをまたいで状態を保持します。
mod は Claude Code 内に独自のインターフェースを描画したり、Claude Code がすでに描画しているインターフェースの一部を変更したりできます。mod が描画できる各場所は描画箇所と呼ばれ、ペイン、プロンプト上部の帯、スピナーなどがあります。Claude Code は描画箇所を描画しようとするたびに ui.render イベントを発火し、そのイベントに対するフックが、そこに描画する内容を返します。
次の図は、ターミナルセッション内で mod が描画できる場所を示しています。
幅の狭いターミナルでは、ペインはトランスクリプトの横ではなくプロンプトの上に配置されます。
ここから始める前に、最初の mod を作成してください。まず、2 つのタブとカウンターを持つペインを作成する実例から始め、その後、変更したい各要素のセクションを読んでください。
個々の props や制限を調べるには、リファレンスを参照してください。
タブ付きのペインを作成する
このセクションでは、/hello-tabs コマンドを追加する mod を作成します。このコマンドはペインを開きます。ペインは、幅の広いフルスクリーンのターミナルではトランスクリプトの横に表示されるサイドバーで、それ以外の場合はプロンプトの上に表示される枠付きの領域です。このペインには 2 つのタブが表示され、2 つ目のタブにはカウンターに 1 を加えるボタンがあります。カウントは Claude Code を再起動した後も保持されます。
完成した mod は次のようになります。この録画では、ペインを開き、2 つ目のタブに切り替え、ボタンを何回か押してから、1 つ目のタブに戻ります。
タブは横に並んだ 2 つのボタンです。mod はどちらがアクティブかを記録し、そのタブのコンテンツを行の下に描画します。
プラグインを作成する
mod は、マニフェスト、コードを指し示す hooks.json、そしてコードファイルを持つプラグインです。それぞれについては mod を作成するで説明しています。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"]
}
コードを書く
次のリストは、各フックが何をするかをコード内に現れる順に示しています。
/hello-tabsコマンドを追加し、以前のセッションで保存されたカウントを読み込む- そのコマンドを実行したときにペインを開く
- ペインのコンテンツ(タブの行と、開いているタブの本文)を描画する
モジュールレベルの 2 つの変数 tab と count がペインの状態を保持します。
これを hello-tabs/hooks/register.js として保存します。
// The pane's id, used to open the pane and to recognize it when drawing
const PANE = 'hello-tabs'
// What the pane shows: which tab is open, and the counter's value
let tab = 'one'
let count = 0
export function register(on) {
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'hello-tabs', description: 'Open the hello-tabs pane' })
// Load the count an earlier session saved, if there is one
const saved = await $.store.get('count')
if (typeof saved === 'number') count = saved
return next(e)
})
// Runs when you type /hello-tabs
on('command.run', { command: 'hello-tabs' }, async ($) => {
// Open the pane, give it the keyboard, and let Esc close it
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// Print nothing in the transcript
return {}
})
// Runs each time Claude Code draws a pane
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Leave other mods' panes alone
if (e.requestId !== PANE) return next(e)
// Get the elements this app can draw
const { Box, Text, Button } = $.ui.resolve(e)
// Ask Claude Code to run this hook again
const redraw = () => $.ui.invalidate('ui.render')
// One tab: a button that switches to its tab when pressed
const tabButton = (name, label, hotkey) =>
Button({
key: 'tab-' + name,
label,
hotkey,
plain: true,
// Dim the tab that isn't open
dimColor: tab !== name,
onPress: () => {
tab = name
redraw()
},
})
// What goes under the tabs, depending on which one is open
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()
// Save the count so it's there after a restart
await $.store.set('count', count)
},
}),
Text({ children: ['Count: ' + count] }),
],
}),
]
// The whole pane: the row of tabs, a blank line, then the body
return Box({
flexDirection: 'column',
children: [
Box({
flexDirection: 'row',
columnGap: 3,
children: [tabButton('one', 'One', '1'), tabButton('two', 'Two', '2')],
}),
Text({ children: [' '] }),
...body,
],
})
})
}
各フックは、コードからは明らかでない処理も行います。
session.startは、セッション間で永続化されるキーバリューストアである$.storeから保存されたカウントも読み込みます。command.runは、ペインが存在することを Claude Code に伝えるだけです。ペインを開いただけでは何も描画されません。Claude Code はその後ui.renderを発火して、ペインに何を表示するかを問い合わせます。ui.renderは要素ツリー(他のボックス、テキスト、ボタンを保持するBox)を返し、実行されるたびにtabとcountからツリーを構築し直します。
ボタンを押すとその onPress コールバックが実行され、変数が変更されて redraw が呼び出されます。すると Claude Code が ui.render フックを再度実行し、フックは新しい値から新しいツリーを構築します。インタラクティブな描画はすべてこのレンダーサイクルを使用します。つまり、コールバックが状態を変更し、フックが新しい状態から再びレンダリングします。
ペインを開く
シェルで claude --plugin-dir ./hello-tabs を実行して Claude Code を起動します。Claude Code のプロンプトで /hello-tabs を実行します。上部に 1: One と 2: Two が表示されたペインが開きます。2 を押してから、Add one のホットキーである a を何回か押します。カウントが増えます。
カウントが保存されたことを確認する
Esc を押してペインを閉じ、セッションを終了します。シェルで同じ claude --plugin-dir ./hello-tabs コマンドを使って Claude Code を再度起動し、Claude Code のプロンプトで /hello-tabs を実行します。カウントは中断したときの値のままです。
カウントをクリアするには、mod から $.store.delete('count') を呼び出します。各種類の値がどれだけの期間保持されるかについては、状態を保持するを参照してください。
描画する場所を選ぶ
ui.render フックは、描画したい描画箇所に絞り込まない限り、すべての描画箇所で実行されます。描画箇所を選ぶには、matcher と呼ばれるフィルターを on の 2 番目の引数として渡します。{ component: 'Pane' } を渡すと、フックはペインに対してのみ実行されます。フック内では、e.component が描画箇所の名前、e.surface が描画しているアプリ、e.props が描画箇所固有のデータを表します。ペインの場合、e.requestId はペインを開いたときの id です。
ペインとバンドは、mod が内容を描画するまで空です。タブを選択すると、それぞれが何であり、どのように描画するかを確認できます。
ペインは、幅の広いフルスクリーンのターミナルではトランスクリプトの横に表示されるサイドバーで、それ以外の場合はプロンプトの上に表示される枠付きの領域です。複数のペインが開いている場合は、それぞれにタイトルを表示するタブが付きます。
ペインは、$.ui.open({ id: 'hello-tabs' }) のように、mod が任意の id を指定して $.ui.open を呼び出したときに表示されます。その他のフィールドや、ペインが幅の広いターミナルを待つ場合については、適切なタイミングでペインを開くを参照してください。
ペインに描画するには、{ component: 'Pane' } でフィルターし、e.requestId が自分の id であることを確認します。
バンドは、プロンプト入力欄のすぐ上にある帯状の領域です。常に存在し、すべての mod で共有されます。
バンドに何かを表示するにはフックからツリーを返し、何も表示しない場合は next(e) を返します。ツリーを返すと、自分の mod より後に実行される mod がそこに描画する内容が置き換えられます。それらの内容を残すには、await next(e) の結果をツリー内の Box の子要素に含めます。
バンドに描画するには、{ component: 'AbovePrompt' } でフィルターします。
Claude Code がすでに描画しているものを変更する
Claude Code は、メッセージ、ツール呼び出しの行、スピナーなど、インターフェースの大部分を自ら描画します。これらの各部分も描画箇所であるため、mod でスタイルを変更したり置き換えたりできます。変更するには、次の表にある名前で ui.render フックをフィルターします。
| 描画箇所 | 内容 |
|---|---|
UserMessage、AssistantMessage |
トランスクリプト内のメッセージ |
ToolUse、ToolResult、ToolGroup |
ツール呼び出しの行、その結果、折りたたまれた呼び出しのグループ |
CommandOutput |
コマンドが出力した行 |
AskUserQuestion |
Claude がユーザーに質問するために開くダイアログ |
Spinner、ToolProgress、TurnDuration |
ターンのステータスライン:Claude の作業中にアニメーションする行、実行中のツールのリアルタイムの進捗行、ターンの終わりを示す行 |
InfoNotice、SessionMode、PromptHint |
ロゴの下のステータスライン、フッターのモードラベル、プロンプトの下のヒント行 |
Claude Code がすでに描画している描画箇所では、フックで細部を変更したり、描画を置き換えたり、そのままにしたりできます。タブを選択すると、それぞれをスピナーに適用した例を確認できます。これらの例では、チュートリアルの mod と同様に、別のフックがカウントする calls 変数を読み取っています。
Claude Code の描画を維持したまま一部だけを変更するには、props を変更したイベントのコピーを next に渡します。次のフックは、スピナーの単語の後に続くテキストを変更します。
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Keep Claude Code's spinner, and change the text after its word
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)
// No call to next, so this line is drawn in the spinner's place
return Text({ children: ['Claude has made ' + calls + ' tool calls'] })
})
Claude の作業中は、Claude Code のスピナーの代わりに指定した行が表示されます。
Claude has made 2 tool calls
描画箇所を Claude Code が描画するままにするには、next(e) を返します。フックでは、一部のイベントに対してのみこれを行うことがよくあります。次のフックは、カウントする呼び出しが発生するまでスピナーをそのままにします。
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Nothing to show yet, so pass the event on unchanged
if (calls === 0) return next(e)
return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})
最初のツール呼び出しの前は、スピナーは mod がない場合と同じように表示されます。
Thinking…
これらの描画箇所では、自分の mod より後に実行される mod が独自のツリーを返していない限り、next(e) は Claude Code の描画への参照 { type: 'engine', ref } を返します。その描画の内容を変更するには、細部を変更するタブのように、props を変更したイベントのコピーを next に渡します。参照はそのまま返すことも、独自の要素と並べて Box に配置することもできます。
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'] })] })
})
Claude の作業中、スピナーはこれまでどおりアニメーションし、その下に under the spinner が表示されます。
権限プロンプトは描画箇所ではないため、mod でその表示内容を変更することはできません。質問ダイアログ AskUserQuestion は描画箇所であるため、mod で変更できます。ダイアログ用のツリーには参照をちょうど 1 回だけ含め、独自の要素はその上に配置する必要があります。そうでない場合、Claude Code は独自のダイアログを描画します。
ターミナルと Desktop アプリでは、発生する描画箇所がすべて同じではありません。Pane、AbovePrompt、Spinner、およびトランスクリプトの描画箇所は両方で機能します。その他のいくつかのステータスラインはターミナルでのみ発生します。それぞれがどこで発生するかは、描画箇所の表に記載されています。
適切なタイミングでペインを開く
ペインは mod が開いたときにのみ表示されます。どのように、いつ開くかによって、キーボードフォーカスを取得するかどうか、どれだけの領域を求めるか、幅の狭いターミナルで表示されるかどうかが決まります。
ペインを開くには、任意の id を指定して $.ui.open を呼び出します。id はペインの名前です。ui.render フックはこの id を確認し、ペインを閉じる際にも再度渡します。
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
ペインを閉じるには、開いたときの id を指定して $.ui.close を呼び出します。
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 が開いたペインは、幅の狭いターミナルでは表示されないため、小さな画面を占有することはありません。表示されるかどうかは、何がペインを開いたかによって決まります。
- ユーザーの操作によって開かれた場合(ユーザーが実行したコマンドや押したボタンなど)、ペインは幅に関係なく表示されます
- mod が自ら開いた場合(タイマーや
turn.startフックからなど)、ペインは幅が 144 列以上のターミナルでのみ表示されます。ユーザーがそのペインを一度自分で開いた後は、110 列あれば十分です。
ペインが表示されると、$.ui.open は { isPlaced: true } に解決されます。ペインが待機している場合、isPlaced は false になり、reason にはその理由を示す文字列が入ります。待機中のペインは、ユーザーがペインを開くかターミナルの幅を広げると表示されます。ペインを開かずに何かが利用可能であることを伝えるには、$.ui.toast('Your message') を呼び出してトースト通知を表示します。
要素からツリーを構築する
ui.render フックが返すのは要素ツリーです。これは描画する内容を記述したもので、ボックス、テキスト、コントロールを互いに入れ子にして構成します。ユーザーが描画内容を記述すると、Claude Code がそれをターミナルまたは Desktop アプリでレンダリングします。
要素を取得するには、const { Box, Text, Button } = $.ui.resolve(e) のように、フック内で $.ui.resolve(e) を呼び出します。各要素は関数です。props を渡し、その内側に入る要素や文字列は children に入れます。
タブを選択すると、よく使われる各要素と、ターミナルでの描画結果を確認できます。
Text は文字列を描画します。bold や color などのスタイルを任意で指定できます。
Text({ children: ['This is the first tab.'] })
This is the first tab.
Box は内側にあるものを行または列に並べます。この例では、ボタンと 1 行のテキストを 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 |
フレックスコンテナ。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 |
テキストフィールドとドロップダウン | ターミナル、Desktop |
Svg |
SVG ドキュメント | Desktop |
Client |
アニメーションやポインター入力のために、ユーザーが用意した 2 つ目のファイルが描画する領域。そのファイルは mod API を利用できません。フックに届く手段はデータを送信することだけで、そのデータは ui.message イベントとして届きます。 |
ターミナル、Desktop |
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 を使うのではなく、Raster を 1 つ描画します。Raster は key、columns と rows で指定するサイズ、そしてすべてのセルを詰め込んだ base64 文字列である cells を受け取ります。各セルは 3 つの数値で構成されます。文字のコードポイント、その色、背景色です。色は 16 進数の 24 ビット RGB 値で、たとえば赤なら 0xc62828 のように指定します。その範囲より 1 大きい値 0x01000000 は、ターミナルのデフォルトを意味します。
Desktop アプリには Raster がないため、e.surface を確認し、Desktop ではテキストを描画します。次のペイン本体は、3×2 のヒートマップを描画します。
// The value that means "use the terminal's default color"
const DEFAULT_COLOR = 0x01000000
// Pack rows of [character, color] pairs into the one string a Raster takes
// One cell is three numbers: the character's code point, its color, and its background
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) => {
// Draw only in the pane opened with the id 'heat'
if (e.requestId !== 'heat') return next(e)
const { Box, Text, Raster } = $.ui.resolve(e)
// Two rows of three cells, each a block character and its color
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 のペインでのみ描画するため、hello-tabs の例がペインを開くのと同じように、コマンドから $.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 フックを再実行せずに、その要素だけを再描画します。
押下と入力に応答する
ユーザーがボタンを押したとき、フィールドに入力したとき、または mod が描画したリストから選択したとき、Claude Code はそのコントロールのコールバックを呼び出します。コールバックはモジュール内で実行されます。各コントロールはそれぞれ独自のコールバックを受け取ります。
Button:onPress(e)を受け取ります。e.surfaceは押下が発生したアプリですInput:onSubmit(value)とonInput(value)を受け取りますSelect:onSelect(value)を受け取り、選択肢はoptionsに指定します。これは一意の値を持つ 1 つ以上の選択肢のリストで、たとえば[{ value: 'sm', label: 'Small' }, { value: 'lg', label: 'Large' }]のようになります
テストはコントロールの key を使ってそのコントロールを押したり入力したりするため、各コントロールに key を付けてください。コントロールが使用されるたびに、e.element に key を含む ui.press、ui.input、または ui.select も発火し、別の mod がこれらのイベントを処理できます。そのフックはコールバックより先に実行されるため、ユーザーが Input に入力した内容を確認し、変更したり、コールバックの代わりに応答したりできます。mods API には、別の mod のボタンを押すメソッドはありません。
キーボードフォーカスとホットキー
mod 自身がキーボードを読み取ることはありません。ユーザーがキーを押すと、Claude Code がそのキーをどのコントロール宛てかを判断し、そのコントロールのコールバックが実行されます。バンド上の数字ホットキーを除き、これはペインまたはバンドがキーボードフォーカスを持っている間にのみ発生します。それ以外のときは、キーはプロンプトに送られます。
ペインがキーボードフォーカスを得る方法
ペインは次の場合にキーボードフォーカスを得ます。
- mod がコマンドまたは押下から
focus: trueを指定してペインを開いたとき - ユーザーが Ctrl+X に続けて Tab を押したとき
- ユーザーがペインをクリックしたとき
Claude Code が focus: true を許可するのは、プロンプトが空で、ほかに何もキーボードフォーカスを持っていない場合に限られます。ユーザーが入力中に開いたペインは、そのキー入力を奪いません。
各キーの動作
次の表は、ペインまたはバンドがキーボードフォーカスを持っている間の各キーの動作を示しています。
| キー | 動作 |
|---|---|
| Tab | 次のコントロールに移動します |
| Up と Down | 描画内容が収まっている間はコントロール間を移動します。ペインまたはバンドの行数が表示できる行数より多い場合は、スクロールします。 |
| Enter | フォーカスされている Button を押す、フォーカスされている Input を送信する、または Select で選択します |
| ボタンのホットキー | そのボタンを押します。Input がフォーカスを持っている間は、すべての印字可能なキーがフィールドに送られます。 |
| Page Up、Page Down、Home、End | ペインまたはバンドの行数が表示できる行数より多い場合に、スクロールします |
| Ctrl+X に続けて矢印キー | ペインのサイズを変更します。Left または Up で領域を広げ、Right または Down で領域を戻します。 |
| Ctrl+X に続けて X | ペインを閉じます。ペイン内のフィールドがフォーカスを持っている間でも閉じます |
| Esc | キーボードフォーカスをプロンプトに戻します。closeOnEscape: true の場合は、ペインも閉じます。 |
mod は Tab や矢印キーをほかの操作に割り当てることはできないため、ゲームでは w、a、s、d で操作します。
ホットキーと最初のフォーカスを設定する
コントロールの次の props によって、キーボードがそのコントロールにどのように届くかが決まります。
hotkey: ユーザーが 1 つのキーでButtonを押せるようにするには、hotkey: 'a'のように、1 桁の数字または 1 文字の小文字をhotkeyに指定しますautoFocus: ペインを開いたときにどのコントロールがフォーカスを持つかを選ぶには、そのコントロールにautoFocus: trueを追加します。この props はtrueのみを受け付けるため、ほかのコントロールでは省略してください。
ホットキーの表示方法は、ボタンとアプリによって異なります。
| ボタン | ターミナル | Desktop アプリ |
|---|---|---|
| 角括弧付き(デフォルト) | [ Add one ]、ホットキーは表示されない |
ラベルの横に小さなキーが表示される |
plain: true の場合 |
1: One |
ラベルの横に小さなキーが表示される |
ターミナルでは、ユーザーが押すべきキーがわかるように、角括弧付きボタンのラベルでキーを示すか、plain: true を使用してください。Button のその他のルール(action、バンド上の数字ホットキー、1 つのホットキーに 2 つのボタンを割り当てる場合)については、要素リファレンスを参照してください。
入力を受け取り、項目ごとに行を描画する
多くのペインは、テキストフィールドとその下のリストで構成されます。このセクションの例はメモペインです。メモを入力して Enter を押すと追加され、各メモにはそれを削除する x ボタンがあります。メモを 2 つ追加すると、ターミナルはペインを次のように描画します。
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add ✕ │
│ x buy milk │
│ x call bob │
╰──────────────────────────────────────────────────────────╯
この例では次の手法を使用しています。
- 入力を受け取る:
Inputは、ユーザーが Enter を押したときにフィールドのテキストを引数としてonSubmit(value)を呼び出し、変更のたびにonInput(value)を呼び出します - リストを描画する: データを 1 件につき 1 行にマッピングし、各行のボタンにそれぞれ独自の
keyを付けます
次のフックがペインの内容を描画します。
// The list the pane draws
let notes = []
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// Draw only in the pane opened with the 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',
// Draw the field empty each time, which clears it after a submit
value: '',
submitLabel: 'add',
autoFocus: true,
// Runs when you press Enter in the field
onSubmit: async (value) => {
// Ignore an empty line
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
// One row for each note: a delete button, then the note's text
...notes.map((note, i) =>
Box({
flexDirection: 'row',
columnGap: 1,
children: [
Button({
// A key of its own, so each row's button can be told apart
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 フックでメモを読み取ってください。
フィールドの行 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 が要求なしで再描画するとき
Claude Code はサイトの props が変更されるか、ターミナルの幅が変更されるときに ui.render フックを再度実行します。タイマーでフックを実行しません。モジュール内の変数が変更されたときは判断できません。
データが変更されたときに再描画する
データが変更された後にサイトを再度描画するには、$.ui.invalidate('ui.render') を呼び出します。このペインは押下をカウントします。ボタンのコールバックは count を変更し、再描画を要求します。
let count = 0
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== 'counter') return next(e)
const { Box, Text, Button } = $.ui.resolve(e)
return Box({
flexDirection: 'row',
columnGap: 2,
children: [
Button({
key: 'more',
label: 'Add one',
onPress: () => {
count += 1
// データが変更されたため、Claude Code にペインを再度描画するよう要求
$.ui.invalidate('ui.render')
},
}),
Text({ children: ['Count: ' + count] }),
],
})
})
各押下はペイン内の数字を上げます。hello-tabs の例は同じ呼び出しを redraw 関数にラップします。
$.stateに保持する値は呼び出しを必要としません。値を書くことはそれを読むサイトを再描画するためです。
タイマーで再描画する
時計、カウントダウン、またはセッション外の値を最新に保つには、スケジュールで再描画します。モジュールの session.start フックでタイマーを開始します。モジュールが既に 1 つを持っている場合、hello-tabs のように、$.clock.every行をそれに追加します。
on('session.start', async ($, e, next) => {
// 1000 ミリ秒ごとに、Claude Code にサイトを再度描画するよう要求
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})
Claude Code は 1 秒に 1 回 ui.render フックを実行します。タイマーはモジュールがリロードされるときに停止し、モジュールの新しいインスタンスが独自のタイマーを開始します。
サイトが再描画できる頻度
Claude Code はサイトの再描画の頻度を制限するため、mod はデータが変更されるたびに $.ui.invalidate を呼び出すことができます。各サイトが再描画できる頻度については、制限テーブルを参照してください。
制限より速く来る呼び出しは 1 つの再描画に結合されます。その再描画はフックを 1 回実行し、フックはその時点でのデータを読むため、最新の値が表示され、その間の値は表示されません。アニメーションは制限より速く実行できません。
状態を保持する
mod が値をどこに保持するかによって、その値がどれだけ持続するかが決まります。モジュールが再読み込みされるまで、セッションが終了するまで、またはセッションをまたいで持続します。値を持続させる必要がある期間に応じて選択してください。
| 保持する場所 | 持続する期間 | 用途 |
|---|---|---|
| モジュールレベルの変数 | モジュールが再読み込みされるまで。開発中はファイルを保存するたびに再読み込みされます | hello-tabs の tab のように、失われても構わない値 |
$.state |
セッションが終了するまで、またはユーザーが /clear、/resume、/branch を実行するまで |
描画が依存し、再読み込み後も残すべき値 |
$.store |
mod が削除するまで、または cleanupPeriodDays の間どのセッションもストアを読み書きしなかった場合まで。ストアはキーバリューストアで、~/.claude/plugins/store/ の下にプラグイン専用の JSON ファイルとして保存されます。 |
設定、履歴、次回もユーザーが見つけられることを期待するもの全般 |
$.store.get(key) は値または undefined に解決され、$.store.set(key, value) は任意の JSON 値を受け取ります。
`$.state` に値を保持する
$.state はセッションの間値を保持し、再描画も自動で行います。これはリアクティブな状態です。値を読み取る ui.render フックはその値を購読するため、値を書き込むたびに Claude Code がその箇所を再描画し、$.ui.invalidate を呼び出す必要はありません。また、$.state 内の値はモジュールの再読み込み後も残りますが、変数は残りません。
設定するには、値を宣言し、マニフェストでその宣言を指定してから、各値を定義して使用します。以下の例では、hello-tabs の count を $.state に移します。
値を宣言する
型宣言ファイルで値を宣言します。外側のキーはプラグインの名前で、その下の各エントリが値とその型です。これを hello-tabs/types/index.d.ts として保存します。
declare module 'claude-code' {
interface PluginState {
'hello-tabs': {
tab: 'one' | 'two'
count: number
}
}
}
マニフェストで宣言を指定する
claude plugin validate がそのファイルに照らしてコードをチェックできるように、マニフェストにファイルのパスを指定した types フィールドを追加します。
{
"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"
}
値を定義、読み取り、書き込む
モジュール内で、各値をデフォルト値とともに定義し、描画時に読み取り、コールバックから書き込みます。atom は値とそのデフォルトに名前を付け、read は値を返し、update は値を書き込みます。この 3 つのヘルパーが $.state.get と $.state.set を代わりに呼び出します。
import { atom, read, update } from 'claude-code'
// At the top of the module: name the value and give its default
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// In the ui.render hook: read the value to draw it
const n = await read($, count)
// In a Button: write a new value from the old one
onPress: () => update($, count, (value) => value + 1)
ui.render フックが count を読み取ったため、ボタンがそれを書き込むたびに Claude Code はフックを再実行します。
コードには次のルールが適用されます。
pluginとkeyは文字列リテラルで記述する:claude plugin validateはソースからそれらを読み取ります- すべての値を型宣言ファイルで宣言する:宣言しないと、検証が
hello-tabs.count is not declaredで失敗します - コールバックまたは別のイベントのフックから書き込む:
ui.renderフックは状態を読み取れますが書き込めないため、onPress、onSubmit、または別のイベントのフックから書き込みます
`hello-tabs` を `$.state` を使うように変更する
hello-tabs の count を $.state に移すには、それを使用しているすべての行を変更します。
- モジュールの先頭:
import行を追加し、let count = 0をatomの行に置き換えます ui.renderフック内:tabButtonの前にreadの行を追加し、Text内で'Count: ' + nを描画します- Add one ボタン内:
onPressを複数のセッションから保存するにあるものに置き換えます。これはカウントを書き込むだけでなく保存も行います session.startフック内:savedを読み取る 2 行を、/clearの後に保存済みの値を再度読み込むにあるloadCountの呼び出しに置き換えます
tab は引き続き変数なので、タブボタンの redraw はそのまま残します。
`/clear` の後に保存済みの値を再度読み込む
mod が session.start で保存済みの値を $.store から $.state にコピーする場合、/clear、/resume、/branch の後に再度コピーする必要があります。これらのコマンドはすべての $.state の値をデフォルトにリセットし、session.start は再度発火しません。一方、classic.SessionStart はそれぞれの後に e.source が clear、resume、fork に設定された状態で発火するため、そのフックで値を再度コピーします。そうしないと、描画にはデフォルトが表示され、$.state の値を保存するコールバックが保存済みの内容をデフォルトで上書きしてしまいます。
次のコードは、両方のフックから count を読み込みます。これは count が atom で update がインポートされている、$.state 版の hello-tabs を前提としています。loadCount を register の上に置き、既存の session.start フックに loadCount の呼び出しを追加します。classic.SessionStart は起動時とコンテキスト圧縮の後にも発火しますが、これらは $.state をリセットしないため、source のフィルターでフックを 3 つのリセットに限定しています。
// Copy the saved count from $.store into $.state, or 0 if nothing is saved
async function loadCount($) {
const saved = Number((await $.store.get('count')) ?? 0)
await update($, count, () => saved)
}
// Runs before your first prompt, and again after a reload
on('session.start', async ($, e, next) => {
await loadCount($)
return next(e)
})
// Runs again after /clear, /resume, and /branch, which reports 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 ボタンのように変更のたびに保存してください。
セッションなしで再読み込みを確認するには、/clear の後に描画をテストします。
複数のセッションから保存する
マシン上で mod を実行するすべてのセッションは、1 つの $.store を共有します。get の後に set を行う操作はアトミックではありません。2 つのセッションがそれぞれ値を読み取り、変更して書き戻すと競合が発生し、2 回目の書き込みが 1 回目を置き換えます。
これが起こる可能性を下げるには、次のようにします。
- 各項目に専用のキーを与える:
setは自身のキーだけを変更するため、異なるキーに書き込むセッション同士は互いを上書きしません - 書き込む直前に再度読み取る:複数のセッションが変更する値については、コールバック内でキーを
getし、session.startで読み込んだコピーではなく、その値から新しい値を作ります。それでも、getとsetの間に別のセッションの書き込みが入った場合、その書き込みは失われます。
次のボタンは、ストアが現在保持している値に 1 を加算してから、描画を更新します。
onPress: async () => {
// Read what the store holds now, which another session may have changed
const saved = Number((await $.store.get('count')) ?? 0)
// Save the new count, then show it
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
このセッションの開始後に別のセッションが自分のボタンを 3 回押していた場合、このボタンを押すとその 3 回分を含むカウントが表示され、保存されます。
次のステップ
- イベントに反応する: ツール呼び出しとターンから描画にデータを渡します
- mods API を使用する: タイマーとモデル呼び出しから描画にデータを渡します
- 描画をテストする: テストからボタンを押し、複数のサーフェスで確認します
- 描画箇所と要素: 各描画箇所の props と各要素の props