本文へ移動
Claude Tips

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:

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 に入口を書きます。

json
{
  "modules": ["./register.js"]
}

2. コードを書く#

コードの中のフックは、次の順に働きます。

  • /hello-tabs コマンドを足し、前のセッションが保存した数を読み込む
  • そのコマンドが実行されたらペインを開く
  • ペインの中身(タブの行と、開いているタブの本体)を描く

ペインの状態は、モジュールの変数 tab と count が持ちます。hello-tabs/hooks/register.js として保存します。

javascript
// ペインの 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 に渡します。スピナーの言葉のあとの文字を変える例です。

javascript
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
  // 本体のスピナーを残し、言葉のあとの文字を変える
  return next({ ...e, props: { ...e.props, suffix: ' · tool calls: ' + calls + '…' } })
})

スピナーは動きと言葉を保ち、そのあとに自分の文字が続きます(例:Thinking · tool calls: 2…)。

描画を差し替える:その場所の代わりに自分のものを描くには、木を返し、next を呼びません。スピナーの代わりに1行のテキストを描く例です。

javascript
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) を返します。イベントによって返すかどうかを変えるフックもよくあります。数えるものができるまでスピナーをそのままにする例です。

javascript
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 に渡します。

javascript
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 で自分のペインを見分けます。

javascript
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 が空でないときだけフォーカスを求める例です。

javascript
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:スタイル付きの文字列を描く。

javascript
Text({ children: ['This is the first tab.'] })

Box:中身を行か列に並べる。ボタンと文字を、2桁あけて横に並べる例です。

javascript
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 なら括弧がなく、ホットキーが出ます。

javascript
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 に文字を渡して呼びます。

javascript
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行のヒートマップを描きます。

javascript
// 「ターミナルの既定の色を使う」という値
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つのメモを足したあと、ターミナルには次のように描かれます。

text
╭──────────────────────────────────────────────────────────╮
│ Note: Type a note and press Enter ⏎ add                ✕ │
│ x buy milk                                               │
│ x call bob                                               │
╰──────────────────────────────────────────────────────────╯

使う技法は2つです。

  • 打った文字を受け取る:Input は、ユーザーが Enter を押すと欄の文字で onSubmit(value) を、変化のたびに onInput(value) を呼ぶ
  • 一覧を描く:データを1行ずつに写し、各行のボタンに別々の key を付ける

ペインの中身を描くフックです。

javascript
// ペインが描く一覧
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') を呼びます。押した回数を数えるボタンなら、コールバックで数を変えたあとに呼びます。

javascript
Button({
  key: 'more',
  label: 'Add one',
  onPress: () => {
    count += 1
    // データが変わったので、描き直しを頼む
    $.ui.invalidate('ui.render')
  },
})

hello-tabs の redraw 関数は、この呼び出しを包んだものです。$.state に置いた値はこの呼び出しが要りません。書くと、その値を読む場所が描き直されます。

タイマーで描き直す#

時計・カウントダウン・セッションの外の値のように、決まった間隔で新しくしたいものは、session.start のフックで $.clock.every のタイマーを始めます(hello-tabs のようにフックがすでにあれば、その中に1行足します)。

javascript
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 に保存します。

typescript
declare module 'claude-code' {
  interface PluginState {
    'hello-tabs': {
      tab: 'one' | 'two'
      count: number
    }
  }
}

2. マニフェストを宣言へ向ける#

マニフェストに types フィールドでそのパスを書くと、claude plugin validate がコードを宣言と照らして検査します。

json
{
  "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 を呼びます)。

javascript
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 のフックに呼び出しを足します。

javascript
// $.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を足してから描画を更新します。

javascript
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 を保存します。

json
{
  "name": "gallery",
  "version": "0.1.0",
  "description": "Opens a pane that draws one sample",
  "author": { "name": "Your Name" }
}

gallery/hooks/hooks.json に入口を書きます。

json
{
  "modules": ["./register.js"]
}

コードは gallery/hooks/register.js に保存します。/gallery コマンドがペインを開き、そこに Plain text を描きます。

javascript
// コールバックを取る見本で、自分のコールバックの代わりに使う
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行ずつ描きます。

javascript
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 に渡します。

javascript
Markdown({
  text: '## Release notes\n\nThis build has **two** fixes and one `flag`:\n\n- Faster start\n- Fewer prompts\n\n> Quoted text',
})

見出しは # が消えて太字に、インラインのコードはバッククォートが消えて色付きに、引用は左に縦棒の付いた斜体になります。

ラベルと、そのあとに URL を描きます。

javascript
Link({ href: 'https://code.claude.com/docs', label: 'Claude Code docs' })

ターミナルでは URL は文字として出るだけで、クリックで開けるかはユーザーのターミナル次第です。

Code#

言語は language で指定するか、path を渡して推測させます。startLine を付けると、その番号から行番号が振られます。

javascript
Code({
  language: 'javascript',
  startLine: 1,
  source: "const name = 'mods'\nconsole.log('hello ' + name)",
})

色はユーザーのテーマに従います。

diff としての Code#

format: 'diff' にすると、source を1つ以上の unified diff のハンクとして描きます。

javascript
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#

中身を行か列に並べ、枠線も描けます。次は単語の行の下に、枠付きの箱を置きます。

javascript
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つです。

javascript
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 が動きます。

javascript
Input({
  key: 'title',
  label: 'Title',
  placeholder: 'Type a title and press Enter',
  value: '',
  submitLabel: 'save',
  onSubmit: noop,
})

フォーカスがないときはラベルとプレースホルダーだけです。フォーカスが来るとラベルが太字になってカーソルが出て、⏎ のあとに submitLabel が出ます。打つとプレースホルダーが消えます。

Select#

選択肢から1つを選ばせ、選んだものの value で onSelect を呼びます。

javascript
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 です(仕組みは上の「色付きのセルの格子を描く」)。

javascript
Raster({
  key: 'grid',
  columns: 3,
  rows: 2,
  cells: cellsOf([
    [['█', 0x2e7d32], ['█', 0xf9a825], ['█', 0xc62828]],
    [['█', 0x2e7d32], ['█', 0x2e7d32], ['█', 0xf9a825]],
  ]),
})

色は小さめのパレットに丸められるので、0x2e7d32 は #337733 で描かれます。

Svg#

デスクトップアプリで SVG のドキュメントを描きます。

javascript
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 のリファレンス)

次に読むページ#

公式ドキュメント(英語)

2026年10月5日時点の内容をもとに、日本語でまとめています。

ページの一覧