Claude Media
Claude Codeのmodでpaneを描く — タブ・ボタン・入力欄の作り方

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. 1

    マニフェストとhooks.json

    hello-tabs/.claude-plugin/plugin.jsonにname、version、description、authorを書きます。hello-tabs/hooks/hooks.jsonには{ "modules": ["./register.js"] }と書き、コードの入口を指します。

  2. 2

    register.jsを書く

    session.startでコマンドを登録し、command.runでpaneを開き、ui.renderで木を返します。

  3. 3

    起動して開く

    claude --plugin-dir ./hello-tabsで起動し、/hello-tabsを実行します。

  4. 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を自分の値に置き換えるのが、いちばん短い入り方です。

この記事を共有:XはてブLinkedIn