Claude Codeのmodでpaneを描く — タブ・ボタン・入力欄の作り方
Claude Codeのmodでpaneやprompt上の帯に画面を描く方法です。描画場所の選び方、要素の木の組み立て、ボタンと入力欄の受け方、再描画をまたぐ状態の持ち方まで手順で示します。
Claude Codeのmodでは、ui.renderフックが返した「要素の木」が、そのまま画面に描かれます。描ける場所はpane(サイドバーまたはprompt上の枠)、prompt直上の帯(band)、スピナーやメッセージなどのClaude Code自身の描画です。この記事では、タブ付きpaneを作る流れを軸に、描画場所の選び方、ボタンと入力欄の受け方、再描画と状態の持ち方を順に見ます。
modの最小構成と作り方の全体像はClaude Codeのmodを80行で作るにあります。ここではその先、画面を描く部分だけを掘り下げます。
描ける場所は「render site」で決まる
Claude Codeは、画面の一部を描く直前にui.renderイベントを発火します。この「描く場所」をrender siteと呼びます。フックはe.componentでどの場所かを知り、返した木がそこに描かれます。
自分で場所を持つ2つのsite
Pane
広いフルスクリーン端末ではtranscriptの右にサイドバーとして、そうでなければprompt上の枠として出ます。開くのは
$.ui.openを呼んだときだけです。複数開くと、タイトルのタブが付きます。AbovePrompt
prompt入力の真上にある帯です。常に存在し、すべてのmodが共有します。Claude Code自身は何も描きません。
フックを場所に絞るには、onの第2引数にmatcherを渡します。
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
// 自分が開いたpaneだけを描く。requestIdはopen時のid
if (e.requestId !== 'hello-tabs') return next(e)
// ...要素の木を返す
})next(e)を返すと「何も描かない」になり、後ろに並ぶ他のmodの描画がそのまま使われます。これを忘れてpane全部を横取りすると、他のmodのpaneが消えます。
帯は共有なので扱いが違います。木を返すと、自分より後に走るmodの描画を置き換えます。他のmodの分も残したいときは、await next(e)の結果を、自分のBoxの子に入れます。
Claude Code自身の描画も変えられる
UserMessage、ToolUse、Spinner、PromptHintなども同じ仕組みのsiteです。変え方は3通りあります。
| やりたいこと | フックの返し方 |
|---|---|
| 一部だけ変える | フックの返し方next({ ...e, props: { ...e.props, suffix: '...' } }) |
| 描画ごと置き換える | フックの返し方nextを呼ばずに木を返す |
| 何もしない | フックの返し方next(e) |
権限の確認プロンプトはsiteではないので、modからは変えられません。質問ダイアログのAskUserQuestionはsiteですが、返す木にはClaude Code側の描画への参照を1回だけ含める必要があります。含めないと、Claude Codeが自前のダイアログを描きます。
paneは「開く」と「描く」の2段で出る
paneの作りで最初につまずくのは、$.ui.openを呼んでも何も描かれない点です。openはpaneの存在を伝えるだけで、中身はui.renderが返します。
await $.ui.open({ id: 'hello-tabs', title: 'Hello tabs', focus: true, closeOnEscape: true })idはpaneの名前です。描画側のe.requestIdと、閉じるときの$.ui.close({ id })で同じ値を使います。名前に使えるのは英数字と_、-で、64文字までです。
openが受けるそのほかのフィールドは次のとおりです。
| フィールド | 働き |
|---|---|
title | 働き複数のpaneが開いているときのタブの表示名 |
focus | 働きキーボードフォーカスの要求 |
closeOnEscape | 働きEscでpaneを閉じる |
holdToasts | 働きそのpaneが表示中のあいだ、端末のtoastを保留する |
rows | 働きpromptの上に出るときの高さ。既定は空きの3分の1 |
columns | 働きtranscriptの横に出るときの幅 |
focus、closeOnEscape、holdToastsはtrueしか受け付けません。falseを渡すとui.open: focus is true or left outのようなエラーで失敗します。条件付きで付けたいときは、条件が成り立つときだけフィールドを足します。
const pane = { id: 'hello-tabs', title: 'Hello tabs' }
await $.ui.open(items.length > 0 ? { ...pane, focus: true } : pane)狭い端末ではpaneが出ないことがある
modが自分の判断で開いたpane(タイマーやturn.startフックからの呼び出し)は、端末が144列以上あるときだけ出ます。ユーザーが一度開いたpaneは110列で足ります。ユーザーがコマンドやボタンで開いた場合は、幅に関係なく出ます。
出たかどうかは$.ui.openの戻り値で分かります。出れば{ isPlaced: true }、待機中ならisPlacedがfalseで、reasonに理由が入ります。待機中のpaneは、ユーザーが開くか端末を広げると現れます。開かずに知らせたいだけなら、$.ui.toast('...')が向いています。
要素の木を組む
フックが返すのは、Box、Text、Buttonなどを入れ子にした木です。要素は$.ui.resolve(e)で取り出します。
const { Box, Text, Button, Input } = $.ui.resolve(e)| 要素 | 描くもの | 使える場所 |
|---|---|---|
Box | 描くものflexコンテナ。flexDirection、columnGap、padding、borderStyleなど | 使える場所端末とDesktop |
Text | 描くものcolor、bold、dimColor、wrap付きの文字 | 使える場所端末とDesktop |
Button | 描くもの押せる部品。onPressを呼ぶ | 使える場所端末とDesktop |
Input、Select | 描くもの入力欄、ドロップダウン | 使える場所端末とDesktop |
Raster | 描くもの色付きセルの格子 | 使える場所端末のみ |
Svg | 描くものSVG文書 | 使える場所Desktopのみ |
Rasterはヒートマップやゲーム盤のように、セルごとにBoxを作ると重いときの手段です。columnsは512まで、rowsは256までで、1文字は1セルの幅に収める必要があります。DesktopにはRasterがないので、e.surfaceを見て文字の描画に切り替えます。
木に誤りがあると、Claude Codeはその場所を自前の描画に戻します。未知の要素、受け付けない属性、子を持てない要素への子、がその例です。--plugin-dirで起動したセッションでは、transcriptに次のような行が出ます。
ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its own画面に何も出ないときは、まずこの行か、デバッグログのa hook returned a tree that does not validateを探します。
タブ付きpaneを通しで作る
公式のhello-tabsの例に沿って、/hello-tabsコマンドで開く2タブのpaneを作ります。2つ目のタブにはカウンターを増やすボタンを置きます。
hello-tabsを動かすまで
- 1
マニフェストとhooks.json
hello-tabs/.claude-plugin/plugin.jsonにname、version、description、authorを書きます。hello-tabs/hooks/hooks.jsonには{ "modules": ["./register.js"] }と書き、コードの入口を指します。 - 2
register.jsを書く
session.startでコマンドを登録し、command.runでpaneを開き、ui.renderで木を返します。 - 3
起動して開く
claude --plugin-dir ./hello-tabsで起動し、/hello-tabsを実行します。 - 4
再起動して値を確かめる
Escで閉じて終了し、同じコマンドで起動し直します。カウントが残っていれば保存できています。
register.jsの骨格は次のとおりです(公式の例に沿った形です)。
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)
})
on('command.run', { command: 'hello-tabs' }, async ($) => {
await $.ui.open({ id: PANE, title: 'Hello tabs', focus: true, closeOnEscape: true })
// transcriptには何も出さない
return {}
})
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== PANE) return next(e)
const { Box, Text, Button } = $.ui.resolve(e)
const redraw = () => $.ui.invalidate('ui.render')
// タブは2つのButton。開いていないほうを薄く描く
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,
] })
})
}タブという専用の要素はありません。「ボタンを横に並べ、いまのタブ名を変数に持ち、その変数で本体を出し分ける」だけで作っています。plain: trueのボタンは括弧が付かず、1: Oneのようにホットキーが見えます。
ボタンを押すと、onPressが変数を書き換えてredrawを呼び、Claude Codeがui.renderを呼び直します。この「コールバックが状態を変え、フックが状態から木を作り直す」循環が、対話するUIすべての基本です。
押す・打つを受ける
コントロールごとに、受けるコールバックが違います。
Button:onPress(e)。e.surfaceは押された側のアプリInput:onSubmit(value)はEnterで、onInput(value)は変更のたびに呼ばれるSelect:onSelect(value)。選択肢はoptionsに、値が重ならない形で1つ以上渡す
modがキーボードを直接読むことはありません。ユーザーがキーを押すと、Claude Codeがどのコントロール宛てかを決めて、そのコールバックを呼びます。キーが届くのは、paneかbandがフォーカスを持っているあいだだけです。それ以外はpromptに行きます。
paneがフォーカスを得る条件は3つです。
focus: trueでコマンドやボタン操作から開いた- Ctrl+X、続けてTab
- クリックした
focus: trueが通るのは、promptが空で、ほかにフォーカスを持つものがないときだけです。入力中にpaneが開いても、打鍵は奪われません。
フォーカス中のキーの働きは次のとおりです。
| キー | 働き |
|---|---|
| Tab | 働き次のコントロールへ |
| 上下 | 働き描画が収まるあいだはコントロール間の移動。溢れるとスクロール |
| Enter | 働きフォーカス中のButtonを押す、Inputを送信する、Selectで選ぶ |
| ボタンのhotkey | 働きそのボタンを押す。Inputにフォーカスがあるあいだは文字がInputへ行く |
| Ctrl+X、続けてX | 働きpaneを閉じる。Inputにフォーカスがあっても効く |
| Esc | 働きフォーカスをpromptへ戻す。closeOnEscapeならpaneも閉じる |
TabとArrowキーを別の用途に割り当てることはできません。キー操作のゲームはw、a、s、dで動かす設計になります。
入力欄と行の一覧を組む
入力欄と一覧の組み合わせは、paneの定番です。Inputのvalueは「描画した時点の値」で、ユーザーの入力は次に描き直されるまでそれを上書きします。そのため、常にvalue: ''で描けば、送信のたびに欄が空になります。
Input({
key: 'new-note',
label: 'Note',
placeholder: 'Type a note and press Enter',
value: '',
submitLabel: 'add',
autoFocus: true,
onSubmit: async (value) => {
if (!value.trim()) return
notes = [...notes, value.trim()]
redraw()
await $.store.set('notes', notes)
},
}),
...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] }),
],
})),行ごとのボタンには、行ごとに別のkeyを付けます。ここで付けたxはラベルであって、ホットキーではありません。Tabでそのボタンまで移ってEnterで押します。
Inputの送信はターンを始めません。始めるには、コールバックから$.prompt.submitを呼びます。
再描画は自分で頼む
描画は「フックが最後に返したもののスナップショット」です。Claude Codeが自発的に描き直すのは、siteのpropsが変わったときと、端末の幅が変わったときです。タイマーでも、モジュール内の変数の変化でも描き直しません。
自分のデータが変わったときは$.ui.invalidate('ui.render')を呼びます。時計やカウントダウンのように外部の値を追うなら、session.startの中でタイマーを張ります。
on('session.start', async ($, e, next) => {
$.clock.every(1000, () => $.ui.invalidate('ui.render'))
return next(e)
})再描画には頻度の上限があります。通常は毎秒10回で、端末で見えているpane、広げた帯、prompt下のヒント行は毎秒30回です。それより速い呼び出しは1回にまとめられ、そのときの最新値で描かれます。途中の値は見えません。アニメーションは、この上限より速くは動きません。
状態の置き場所は3つ
値をどこに置くかで、寿命が決まります。
| 置き場所 | 寿命 | 向く値 |
|---|---|---|
| モジュールの変数 | 寿命モジュールの再読み込みまで。開発中はファイルを保存するたびに起きる | 向く値失ってよい値(hello-tabsのtab) |
$.state | 寿命セッションの終わりまで。/clear、/resume、/branchで既定値に戻る | 向く値再読み込みをまたいで保ちたい描画用の値 |
$.store | 寿命modが消すまで。どのセッションもcleanupPeriodDaysのあいだ触らなければ消える | 向く値設定、履歴、次回も残したいもの |
$.stateの利点は、再描画を頼まなくてよいことです。ui.renderの中で読んだ値は購読され、書き込まれるたびに、そのsiteが描き直されます。使うには、型宣言ファイルに値を宣言し、マニフェストのtypesでそのパスを指し、atomで既定値付きの値を定義します。
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)守る約束が4つあります。pluginとkeyは文字列リテラルで書く。atomの結果はconstに入れる。すべての値を型宣言ファイルに宣言する。書き込みはonPressや別イベントのフックから行い、ui.renderの中では読むだけにする。守らないとclaude plugin validateがtakes a source the scan can readやhello-tabs.count is not declaredで落とします。
$.storeの値を$.stateへ写すなら、/clearの後も写す
session.startで$.storeから$.stateへ値を写している場合、/clear、/resume、/branchの後に$.stateが既定値へ戻ります。session.startは再発火しません。そのままだと画面が既定値を出し、次の保存で、保存済みの値が既定値で上書きされます。
対策は、classic.SessionStartをsource: ['clear', 'resume', 'fork']で受けて、同じ読み込みをもう一度行うことです。
複数セッションで同じ$.storeを使うとき
同じマシンで動く全セッションが、1つの$.storeを共有します。getしてsetする間は不可分ではないので、2つのセッションが同時に更新すると、後の書き込みが先の分を消します。
- 項目ごとに別のキーを使う
- 書く直前にもう一度
getし、その値から新しい値を作る
後者でも、getとsetのあいだに別セッションが書けば、その分は失われます。
詰まりやすい点
- paneが出ない:
ui.openはpaneを開くだけです。ui.renderがrequestIdで一致を判定し、木を返しているか確かめます。transcriptのrefused行も確認します - 自動で開いたpaneが見えない: 端末が144列未満だと待機します。
isPlacedとreasonを見ます - キーが効かない: フォーカスがpromptにあるのかもしれません。
focus: trueは、promptが空のときしか通りません - Desktopだけ崩れる:
RasterとImageは端末専用、SvgはDesktop専用です。e.surfaceで分けます。DesktopのLinkは、https:かhttp://localhostで、@を含まないURLでないとただの文字になります - ホットキーが分からない: 括弧付きのButtonは端末でホットキーを表示しません。ラベルにキー名を書くか、
plain: trueにします
Claudeにmodを書かせるときの指示
Claude Codeにmodを書かせるなら、上の詰まりやすい点を最初の指示に入れておくと、手戻りが減ります。次は指示の一例です。
hello-tabsと同じ構成で、ノートを持つpane付きmodを作って。
- paneのidは固定文字列にして、ui.renderではe.requestIdで一致を判定する
- focus/closeOnEscapeはtrueのときだけフィールドを付ける(falseは渡さない)
- 再描画が要る箇所は$.ui.invalidate('ui.render')を呼ぶ
- 値は$.storeに保存し、session.startで読み戻す
作ったらclaude plugin validateを通し、--plugin-dirで起動してtranscriptに
"refused"の行が出ないことを確かめて。最後の一文が効きます。木の誤りは画面に何も出ない形で現れるので、「検証コマンドを実行させ、出力を読ませる」反復まで指示に含めておくと、描画の失敗を手元で拾えます。Client要素が失敗したときの受け方はui.faultの記事が詳しく、mod描画まわりの修正はv2.1.289のリリースノートでも触れています。
まとめ
pane作りの要点は、openと描画が別であること、描画は再描画を頼まないと更新されないこと、状態の寿命を3つの置き場所から選ぶことです。この3つを押さえると、タブも入力欄も一覧も、同じ型の変形で組めます。まずhello-tabsを動かし、tabとcountを自分の値に置き換えるのが、いちばん短い入り方です。