モッドでインターフェースに描画する
Claude Code モッドからペイン、プロンプト上部のバンド、ボタン、テキストフィールドを描画し、押下と入力を処理し、再描画とセッション間で状態を保持します。
モッドは Claude Code でそれ自身のインターフェースを描画し、Claude Code が既に描画しているインターフェースの一部を変更できます。モッドが描画できる各場所はレンダーサイトと呼ばれます。例えば、ペイン、プロンプト上部のバンド、またはスピナーなどです。Claude Code はレンダーサイトを描画しようとするたびにui.renderイベントを発生させ、そのイベントのフックはそこに何を描画するかを返します。
このマップは、モッドがターミナルセッションのどこに描画できるかを示しています。
より狭いターミナルでは、ペインはトランスクリプトの横ではなくプロンプトの上に配置されます。
最初のモッドを作成してからここを始めてください。実装例から始めます。これは 2 つのタブとカウンターを持つペインを構築し、その後、変更したい各部分のセクションを読んでください。
1 つのプロップまたは制限を調べるには、リファレンスを参照してください。
タブ付きペインを構築する
このセクションでは、/hello-tabs コマンドを追加するモッドを構築します。このコマンドはペインを開きます。ペインは、広いフルスクリーンターミナルではトランスクリプトの横にあるサイドバー、またはそれ以外の場合はプロンプト上部のフレーム領域です。このペインは 2 つのタブを表示し、2 番目のタブにはカウンターに 1 を加えるボタンがあります。カウントは Claude Code を再起動した後も残ります。
完成したモッドは次のようになります。記録はペインを開き、2 番目のタブに切り替え、ボタンを数回押し、最初のタブに戻ります。
Claude Code には組み込みのタブ要素がないため、タブは行内の 2 つのボタンです。モッドはどのタブがアクティブかを追跡し、その行の下にそのタブのコンテンツを描画します。
プラグインを作成する
モッドはマニフェスト、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"]
}
コードを書く
コードは 3 つのジョブを実行し、各フックで 1 つずつ実行します。
/hello-tabsコマンドを追加する- そのコマンドを実行するときペインを開く
- ペインのコンテンツを描画する。タブの行とオープンタブのボディ
2 つのモジュールレベルの変数、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) => {
// 他のモッドのペインはそのままにする
if (e.requestId !== PANE) return next(e)
// このアプリが描画できる要素を取得
const { Box, Text, Button } = $.ui.resolve(e)
// Claude Code にこのフックを再度実行するよう要求
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,
],
})
})
}
各フックはコードが明確にしていないことも実行します。
- **
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 を実行します。カウントは残したままです。
カウントをクリアするには、モッドに $.store.delete('count') を呼び出させます。状態を保持するは各種類の値がどのくらい続くかをカバーしています。
描画する場所を選ぶ
ui.render フックは、希望するレンダーサイトに絞り込まない限り、すべてのレンダーサイトに対して実行されます。レンダーサイトを選択するには、on の 2 番目の引数としてマッチャーと呼ばれるフィルターを渡します。{ component: 'Pane' } はペインに対してのみフックを実行します。フック内では、e.component がサイトに名前を付け、e.surface はどのアプリが描画しているかを示し、e.props はサイト独自のデータを保持します。ペインの場合、e.requestId はそれを開いた id です。
2 つのサイトはモッドがそれらを埋めるまで空です。ペインとバンドです。タブを選択して、各サイトが何であり、どのように描画するかを確認してください。
ペインは、広いフルスクリーンターミナルではトランスクリプトの横にあるサイドバー、またはそれ以外の場合はプロンプト上部のフレーム領域です。複数のペインがオープンされている場合、各ペインはそのタイトルを表示するタブを取得します。
ペインは、モッドが $.ui.open を id で呼び出すときに表示されます。例えば $.ui.open({ id: 'hello-tabs' }) のように。適切なタイミングでペインを開くは他のフィールドと、ペインがより広いターミナルを待つときをカバーしています。
ペインに描画するには、{ component: 'Pane' } でフィルターし、e.requestId が id であることを確認します。
Claude Code が既に描画しているものを変更する
Claude Code はそのインターフェースのほとんどを自分で描画します。メッセージ、ツール呼び出し行、スピナーなど。これらの各部分もレンダーサイトであるため、モッドはそれをリスタイルまたは置き換えることができます。1 つを変更するには、ui.render フックをこのテーブルの名前でフィルターします。
| サイト | それが何であるか |
|---|---|
UserMessage、AssistantMessage |
トランスクリプト内のメッセージ |
ToolUse、ToolResult、ToolGroup |
ツール呼び出しの行、その結果、および折りたたまれた呼び出しの実行 |
CommandOutput |
コマンドが出力した行 |
AskUserQuestion |
Claude があなたに質問するために開くダイアログ |
Spinner、ToolProgress、TurnDuration |
ターンのステータス行。Claude が作業している間にアニメーションする行、実行中のツールのライブ進捗行、ターンを閉じる行 |
InfoNotice、SessionMode、PromptHint |
ロゴの下のステータス行、フッターのモードラベル、プロンプトの下のヒント行 |
Claude Code が既に描画しているサイトでは、フックには 3 つの選択肢があります。詳細を変更する、描画を置き換える、またはそのままにする。タブを選択して、スピナーに適用された各ものを確認してください。例は、チュートリアルモッドのように別のフックがカウントする calls 変数を読みます。
Claude Code の描画を保持し、その一部を変更するには、変更された props を持つイベントのコピーを next に渡します。このフックはスピナーの単語の後のテキストを変更します。
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
// Claude Code のスピナーを保持し、その単語の後のテキストを変更
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 が作業している間、行が表示され、Claude Code のスピナーは表示されません。
Claude has made 2 tool calls
Claude Code が描画するようにサイトをそのままにするには、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 + '…' } })
})
最初のツール呼び出しの前に、スピナーはモッドなしで表示される方法で表示されます。
Thinking…
権限プロンプトはレンダーサイトではないため、モッドはそれが表示するものを変更できません。質問ダイアログ AskUserQuestion は 1 つであるため、モッドはそれを変更できます。
ターミナルと Desktop アプリはすべての同じサイトを発生させません。Pane、AbovePrompt、Spinner、およびトランスクリプトサイトは両方で機能します。他のいくつかのステータス行はターミナルでのみ発生します。レンダーサイトテーブルは各サイトが発生する場所をリストしています。
適切なタイミングでペインを開く
ペインはモッドがそれを開くときにのみ表示されます。どのように、いつ開くかは、キーボードフォーカスを取得するかどうか、どのくらいのスペースを要求するか、および狭いターミナルで表示されるかどうかを決定します。
ペインを開くには、選択した id で$.ui.openを呼び出します。id はペインの名前です。ui.render フックはそれをチェックし、ペインを閉じるときに再度渡します。
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true })
ペインを閉じるには、それを開いた id で $.ui.close を呼び出します。
await $.ui.close({ id: 'hello-tabs' })
id の他に、$.ui.open はこれらのオプションフィールドを取ります。
| フィールド | それが実行すること |
|---|---|
title |
複数のペインがオープンされているときのペインのタブラベル |
focus |
キーボードフォーカスをリクエスト |
closeOnEscape |
Esc でペインを閉じるようにします。true を渡すか、フィールドを省略してください。Claude Code は false を拒否します。 |
holdToasts |
$.ui.toastからの小さな通知であるトーストを、ペインが閉じるまで保持します |
rows |
ペインがプロンプト上部に配置されるときに要求する高さ。デフォルトはスペースの 3 分の 1 です。 |
columns |
ペインがトランスクリプトの横に配置されるときに要求する幅 |
Claude が作業している間にコマンドがペインを開くようにするには、コマンドを登録するときに immediate: true を追加します。それなしでは、ターン中に入力されたコマンドはターンが終わるまで待ちます。
ペインがより広いターミナルを待つとき
モッドが要求されずに開くペインは狭いターミナルに表示されないため、小さな画面を引き継ぐことはできません。表示されるかどうかは、それを開いたものによって異なります。
- ユーザーが実行したコマンドやボタンを押すなど、ユーザーが実行したものによって開かれた場合、ペインは任意の幅で表示されます
- タイマーまたは
turn.startフックなど、モッドが自分で実行したものによって開かれた場合、ペインは少なくとも 144 列幅のターミナルでのみ表示されます。ユーザーが一度そのペインを自分で開いた後は、110 列で十分です。
ペインが表示されるとき、$.ui.open は { isPlaced: true } に解決されます。ペインが待機しているとき、isPlaced は false で、reason は理由を説明する文字列です。待機中のペインはユーザーがそれを開くか、ターミナルを広げるときに表示されます。ペインを開かずに何かが利用可能であることを言うには、$.ui.toast('Your message') を呼び出します。これは数秒後に消える小さな通知を表示します。
要素からツリーを構築する
ui.render フックが返すものは要素ツリーです。これは描画する内容の説明であり、ボックス、テキスト、および制御が互いにネストされています。描画を説明し、Claude Code はターミナルまたは Desktop アプリでそれをレンダリングします。
要素を取得するには、フック内で $.ui.resolve(e) を呼び出します。例えば const { Box, Text, Button } = $.ui.resolve(e) のように。各要素は関数です。プロップを渡し、その中に入るべき要素と文字列を children に配置します。
ほとんどの描画は 4 つの要素を使用します。タブを選択して、各要素とターミナルがそれをどのように描画するかを確認してください。
Text は文字列を描画し、bold や color などのオプションのスタイリングを使用します。
Text({ children: ['This is the first tab.'] })
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 ⏎ add
このテーブルはすべての要素をリストしています。
| 要素 | それが描画するもの | どこで |
|---|---|---|
Box |
フレックスコンテナ。flexDirection、columnGap、padding、borderStyle、width などのレイアウトプロップを取ります。 |
どこでも |
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 プロップでコンテンツを取り、onLinkPress を渡すときは key が必要です。 |
どこでも |
Input、Select |
テキストフィールドとピッカー | ターミナル、Desktop |
Svg |
SVG ドキュメント | Desktop |
Client |
2 番目のファイルで描画される領域。アニメーションとポインター入力用。そのファイルはモッド API を取得しません。フックに到達するのはデータを投稿することだけで、ui.message イベントとして到達します。 |
ターミナル、Desktop |
Raster、Image |
色付きセルのグリッドと画像 | ターミナル |
モジュールが .tsx または .jsx ファイルの場合、ツリーを JSX として記述できます。$.ui.resolve(e) から要素を分割代入してください。フックモジュールには要素グローバルがないためです。
ツリーがアプリが持たない要素、要素が取らないプロップ、または子が入らない場所を使用する場合、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 として同じ理由で記録します。他に何も表示されないため、描画が表示されない場合は、その行またはログを確認してください。
色付きセルのグリッドを描画する
ヒートマップ、スパークライン、またはターミナルのゲームボードの場合、各セルに対して 1 つの Raster を描画し、Box ではありません。Raster は key、columns と rows のサイズ、および cells を取ります。これはすべてのセルを 1 つの文字列にパックします。各セルは 3 つの数字です。文字のコードポイント、その色、背景色。色は 0xc62828 のような赤、または 0x01000000 のようなターミナルのデフォルトの 16 進数です。
Desktop アプリには Raster がないため、e.surface をチェックしてそこにテキストを描画します。このペインボディは 3 x 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' }) をコマンドから開きます。hello-tabs の例がそのペインを開く方法のように。
各文字は 1 セル幅である必要があります。既に画面上にある Raster をアニメーション化するには、ペインの id を requestId として、Raster の key、同じサイズ、および新しいセルで $.ui.blit を呼び出します。この例では、$.ui.blit({ requestId: 'heat', key: 'grid', columns: 3, rows: 2, cells: cellsOf(newRows) }) です。ui.render フックを再度実行せずにその 1 つの要素を再描画します。
押下とタイピングに応答する
ユーザーがボタンを押す、フィールドに入力する、またはモッドが描画したリストから選択するとき、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 でコントロールを押すか入力するため、各コントロールに 1 つを与えます。コントロールの各使用はui.press、ui.input、または ui.selectも発生させます。e.element に key があり、別のモッドはそれらのイベントをフックできます。そのフックはコールバックの前に実行されるため、ユーザーが Input に入力するものを見て、それを変更するか、コールバックの代わりに答えることができます。モッド API には別のモッドのボタンを押すメソッドがありません。
キーボードフォーカスとホットキー
モッドはキーボード自体を読むことはありません。ユーザーがキーを押し、Claude Code はそれがコントロールのどれであるかを決定し、そのコントロールのコールバックが実行されます。バンド上の数字ホットキーを除いて、これはペインまたはバンドがキーボードフォーカスを持っている間にのみ発生します。それ以外の場合、キーはプロンプトに移動します。
ペインがキーボードフォーカスを取得する方法
ペインは 3 つの方法のいずれかでキーボードフォーカスを取得します。
- モッドがコマンドまたは押下から
focus: trueで開く - ユーザーが Ctrl+X を押してから Tab を押す
- ユーザーがそれをクリックする
Claude Code は focus: true をプロンプトが空で、他に何もキーボードフォーカスを持たない間にのみ付与します。ユーザーが入力している間に開くペインはそのキーストロークを取得しません。
各キーが実行すること
このテーブルは、ペインまたはバンドがキーボードフォーカスを持っている間、キーが実行することをリストしています。
| キー | それが実行すること |
|---|---|
| Tab | 次のコントロールに移動 |
| 上下 | 描画がフィットしている間、コントロール間を移動します。ペインまたはバンドが表示できるより多くの行を持つ場合、それらはスクロールします。 |
| Enter | フォーカスされた Button を押す、フォーカスされた Input を送信する、または Select で選択 |
| ボタンのホットキー | そのボタンを押す。Input がフォーカスを持っている間、すべての印字可能キーはフィールドに移動します。 |
| Esc | キーボードフォーカスをプロンプトに返します。closeOnEscape: true を使用すると、ペインも閉じます。 |
モッドは Tab またはアロー キーを他のものにバインドできないため、ゲームは w、a、s、d で操舵します。
ホットキーと最初のフォーカスを設定する
コントロール上の 2 つのプロップがキーボードがそれに到達する方法を決定します。
hotkey: ユーザーがButtonを 1 つのキーで押すことを許可するには、hotkey: 'a'のように 1 つの数字または 1 つの小文字のhotkeyを与えますautoFocus: ペインが開くときどのコントロールがフォーカスを持つかを選択するには、autoFocus: trueを追加します。他のコントロールからプロップを省略してください。Claude Code はautoFocus: falseを拒否します。
ホットキーがどのように表示されるかはボタンとアプリによって異なります。
| ボタン | ターミナルで | 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 │
╰──────────────────────────────────────────────────────────╯
例は 2 つのテクニックを使用します。
- 入力を取得:
Inputはユーザーが Enter を押すとフィールドのテキストでonSubmit(value)を呼び出し、すべての変更でonInput(value)を呼び出します - リストを描画: データを各行にマップし、すべての行のボタンに独自の
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: 'delete-' + i,
label: 'x',
plain: true,
onPress: async () => {
notes = notes.filter((_, j) => j !== i)
redraw()
await $.store.set('notes', notes)
},
}),
Text({ children: [note] }),
],
}),
),
],
})
})
ペインを試すには。
- ノートを追加: 行を入力して Enter を押します。行は新しい行として表示され、フィールドは空になります。
- ノートを削除: Tab を押してノートの
xボタンがフォーカスを持つまで、その後 Enter を押します。xはボタンのラベルであり、ホットキーではないため、文字を入力してもそれを押しません。
各変更は hello-tabs と同じレンダーサイクルに従います。コールバックが notes を変更し、redraw を呼び出し、リストを $.store に保存します。
フィールドは各送信後に空になります。これは value プロップのためです。value はフィールドが描画されるときに保持するテキストであり、ユーザーのタイピングはフックが再度フィールドを描画するまでそれを置き換えます。例は常にフィールドを '' で描画します。
例はノートを保存し、それらを読み込みません。次のセッションでそれらを戻すには、hello-tabs が count を読み込む方法で session.start フックでそれらを読み込みます。
3 つのプロップはフィールドの行を構成します。Note: Type a note and press Enter ⏎ add。
| プロップ | 例では | それが何であるか |
|---|---|---|
label |
Note |
フィールドの前のテキスト。ターミナルはその後に : を描画します。 |
placeholder |
Type a note and press Enter |
フィールドが空の間に表示される暗いテキスト |
submitLabel |
add |
⏎ の後の単語。Enter が実行することを言います |
Input を送信してもターンを開始しません。コールバックが $.prompt.submit を呼び出さない限り。
サイトを再描画する
描画はスナップショットです。ui.render フックが最後に実行したときに返したものを表示します。何か新しいものを表示するには、フックを再度実行する必要があります。Claude Code はいくつかの変更に対して再度実行し、モッドは残りを要求します。
Claude Code が要求なしで再描画するとき
Claude Code はサイトのプロップが変更されるか、ターミナルの幅が変更されるときに 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 は再描画の頻度を制限するため、モッドはデータが変更されるたびに $.ui.invalidate を呼び出すことができます。表示されているペインとバンドは他のサイトより高い制限を持ち、制限テーブルに数字があります。
制限より速く来る呼び出しは 1 つの再描画に結合されます。その再描画はフックを 1 回実行し、フックはその時点でのデータを読むため、最新の値が表示され、その間の値は表示されません。アニメーションは制限より速く実行できません。
状態を保持する
モジュールには値を保持する 3 つの場所があり、値がどのくらい続くかが異なります。モジュールがリロードされるまで、セッションが終了するまで、または次のセッションまで続きます。値がどのくらい続く必要があるかで選択してください。
| 保持場所 | 続く期間 | 用途 |
|---|---|---|
| モジュールレベルの変数 | モジュールがリロードされるまで。開発中にファイルを保存するたびに発生します | hello-tabs の tab のように失っても問題ない値 |
$.state |
セッションが終了するか、ユーザーが /clear、/resume、または /branch を実行するまで |
描画が依存し、リロードを生き残るべき値 |
$.store |
モジュールが削除するか、cleanupPeriodDays の期間、セッションがストアを読み書きしないまで。ストアはキー値ストアで、プラグイン自体の JSON ファイルとして ~/.claude/plugins/store/ に保存されます。 |
設定、履歴、ユーザーが次回見つけることを期待するもの |
$.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'
// モジュールの最上部:値に名前を付け、デフォルトを指定します
const count = atom({ plugin: 'hello-tabs', key: 'count' }, 0)
// ui.render フック内:値を読み取って描画します
const n = await read($, count)
// ボタン内:古い値から新しい値を書き込みます
onPress: () => update($, count, (value) => value + 1)
ui.render フックが count を読み取ったため、ボタンがそれを書き込むたびに Claude Code はフックを再度実行します。
コードに 3 つのルールが適用されます。
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を Save from more than one session のものに置き換えます。これはカウントを保存し、それを書き込みます session.startフック内:savedを読み取る 2 行を Load a saved value again after/clearのloadCount呼び出しに置き換えます
tab はまだ変数であるため、タブボタンの redraw を保持します。
`/clear` の後に保存された値を再度読み込む
モッドが session.start で $.store から保存された値を $.state にコピーする場合、/clear、/resume、または /branch の後に再度コピーする必要があります。これらのコマンドはすべての $.state 値をデフォルトに戻し、session.start は再度発火しません。classic.SessionStart は各値の後に発火し、e.source は clear、resume、または fork に設定されるため、値を再度コピーします。そうしないと、描画はデフォルトを表示し、$.state 値を保存するコールバックは保存したものをデフォルトで上書きします。
このコードは両方のフックから count を読み込みます。これは count がアトムで update がインポートされている hello-tabs の $.state バージョンに基づいています。loadCount を register の上に配置し、loadCount 呼び出しを既に持っている session.start フックに追加します。classic.SessionStart はスタートアップと圧縮後にも発火します。圧縮は $.state をリセットしないため、source のフィルターはフックを 3 つのリセットに保ちます。
// 保存されたカウントを $.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 の後に再度実行されます。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 の後の描画をテストします。
複数のセッションから保存する
マシン上のモッドを実行するすべてのセッションは 1 つの $.store を共有します。get の後に set が続くことはアトミックではありません。2 つのセッションが各値を読み取り、変更し、書き戻すと、競合が発生し、2 番目の書き込みが最初の書き込みを置き換えます。
2 つの選択肢がそれをより可能性が低くします。
- 各アイテムに独自のキーを付与します:
setは独自のキーのみを変更するため、異なるキーを書き込むセッションは互いに上書きしません - 書き込む直前に再度読み取ります:複数のセッションが変更する値の場合、コールバックでキーを
getし、session.startで読み込んだコピーからではなく、その値から新しい値を構築します。別のセッションの書き込みは、getとsetの間に着地した場合でも失われます。
このボタンはストアが現在保持しているものに 1 を追加し、描画を更新します。
onPress: async () => {
// ストアが現在保持しているものを読み取ります。別のセッションが変更した可能性があります
const saved = Number((await $.store.get('count')) ?? 0)
// 新しいカウントを保存し、それを表示します
await $.store.set('count', saved + 1)
await update($, count, () => saved + 1)
}
2 番目のセッションがこのセッションが開始されてから独自のボタンを 3 回押した場合、このプレスはそれらの 3 つを含むカウントを表示して保存します。
次のステップ
- イベントに反応する。ツール呼び出しとターンから描画をフィード
- モッド API を使用する。タイマーとモデル呼び出しから描画をフィード
- 描画をテストする。複数のサーフェスでボタンをテストから押す
- レンダーサイトと要素。各サイトのプロップと各要素のプロップ