ModsCode日本語ブログ Claude から Mod のコード例を探す

ModsCode / 日本語ブログ / Claude Code の Mod で状態を画面に出す方法

Claude Code の Mod で状態を画面に出す方法

スピナーの横、プロンプトの下の 1 行、上の帯、通知の 4 か所に、ブランチ名や使用率を出す Claude Code Mod の書き方を、動作確認済みのコードで解説します。

Claude Code を長時間使っていると、「今どのブランチか」「コンテキストをどれだけ使ったか」「ツールを何回呼んだか」を、画面のどこかに出しておきたくなります。Mod を使えば、Claude Code が描く画面の決まった場所に、自分の文字を足せます。この記事では、場所ごとの書き方を、動作を確かめたコードで紹介します。コードは Claude Code 2.1.289 の claude plugin validate と claude plugin test で確認しました。

Mod の基本(plugin.json、hooks.json、register.js の 3 ファイル)は 初めての Mod の作り方 で説明しています。この記事では、各 Mod の register.js と、必要なら hooks.json だけを示します。plugin.json の name と description は、それぞれの Mod に合わせて書き換えてください。

先に結論:出す場所は 4 つ

画面に何かを出す方法は、「どれだけ目立たせたいか」「いつまで残すか」で選びます。

場所使うもの向いている内容
プロンプトの下の 1 行$.ui.status(text)変えるまで残る、ブランチ名や接続先など
スピナーの横ui.render の SpinnerClaude が作業している間だけ見たい数値
プロンプトの上の帯ui.render の AbovePrompt常に見えていてほしい、使用率やタイマー
通知$.ui.toast(text)一度だけ知らせたい出来事(4 秒で消える)

迷ったら、まず $.ui.status を使ってください。1 行の関数呼び出しで済み、描画の仕組みを知らなくても動きます。

1. プロンプトの下に、1 行を出す

$.ui.status(text) は、プロンプトの下に 1 行を出し、次に呼び直すまで残します。文字の先頭には ⚠ と Mod の名前が付くので、⚠ branch-line: branch: main のように表示されます。この前置きは Claude Code が付けるので、どの Mod が出した行かが一目で分かります。

次の Mod は、今のブランチ名をこの行に出します。セッションの開始時と、Claude の返答が終わるたびに調べ直します。Claude が git switch でブランチを切り替えることがあるためです。

branch-line/hooks/register.js
// 今のブランチを調べて、プロンプトの下の 1 行に出す
async function showBranch($) {
  try {
    // 引数は配列で渡す。シェルを通さないので、引用符の扱いを気にしなくてよい
    const git = await $.process.run(['git', 'branch', '--show-current'])
    const branch = git.exitCode === 0 ? git.stdout.trim() : ''
    // git のリポジトリでないときは何も出さない
    $.ui.status(branch ? 'branch: ' + branch : '')
  } catch {
    // git が無いなど、起動できなかったとき。表示を足せないだけなので黙って戻る
  }
}

export function register(on) {
  // セッション開始時に 1 回
  on('session.start', async ($, e, next) => {
    await showBranch($)
    return next(e)
  })

  // Claude の返答が終わるたびに調べ直す
  on('turn.complete', async ($, e, next) => {
    await showBranch($)
    return next(e)
  })
}

ポイントは 3 つです。

  • 外部コマンドは $.process.run に配列で渡します。文字列を連結してシェルに渡す形にしないので、ブランチ名に妙な文字が入っていても影響を受けません。
  • どのフックも、最後は next(e) を返します。呼ばずに返すと、同じイベントを受け取る後ろの Mod に届きません。
  • 表示を足すだけの Mod は、失敗しても作業を止めてはいけません。上のコードは、git がなくても例外を握りつぶして戻ります。

テストは次のとおりです。外部コマンドと画面の代役を on で用意するので、セッションを起動せずに確かめられます。

branch-line/tests/branch-line.test.ts
import { expect, test } from 'claude-code/testing'

test('セッション開始でブランチ名を 1 行に出す', async ($, on) => {
  const lines: string[] = []
  on('process.run', () => ({ value: { exitCode: 0, stdout: 'feature/login\n', stderr: '' } }))
  on('ui.status', ($, e) => {
    lines.push(e.text)
    return { value: undefined }
  })
  on('session.start', () => ({ cwd: '/work' }))

  await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
  expect(lines).toEqual(['branch: feature/login'])
})

test('git のリポジトリでなければ空の行にする', async ($, on) => {
  const lines: string[] = []
  on('process.run', () => ({ value: { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' } }))
  on('ui.status', ($, e) => {
    lines.push(e.text)
    return { value: undefined }
  })
  on('session.start', () => ({ cwd: '/work' }))

  await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
  expect(lines).toEqual([''])
})

私たちが確かめたテストは、この 2 件で、どちらも通りました。テストの書き方は Mod のテストの書き方 で詳しく説明します。

2. スピナーの横に、数値を足す

Claude が作業している間は、「Thinking…」のようなスピナーが回ります。この行は Claude Code が描いていますが、Mod は描画の一部だけを書き換えることができます。ui.render を { component: 'Spinner' } で絞り、next に props.suffix を変えたイベントを渡すと、スピナーの動きはそのままで、言葉の後ろに文字が付きます。

tool-counter/hooks/register.js(抜粋)
// ツールごとの回数。下の複数のフックが同じ変数を使う
const counts = {}

function total() {
  return Object.values(counts).reduce((sum, n) => sum + n, 0)
}

export function register(on) {
  // Claude がツールを使う直前に呼ばれる。数えるだけで、ツールはそのまま動かす
  on('tool.call', async ($, e, next) => {
    counts[e.tool] = (counts[e.tool] ?? 0) + 1
    // 画面を描き直すよう頼む
    $.ui.invalidate('ui.render')
    return next(e)
  })

  // スピナーの横に合計を足す。スピナー自体は Claude Code のものを使う
  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
    if (total() === 0) return next(e)
    return next({ ...e, props: { ...e.props, suffix: ' · ツール ' + total() + ' 回…' } })
  })
}

この抜粋は、初めての Mod の作り方 で作る Mod から、スピナーに関係する部分だけを取り出したものです。

こう表示されます。

text
Thinking · ツール 2 回…

覚えておきたいのは $.ui.invalidate('ui.render') です。ui.render は、Claude Code が画面を描くたびに呼ばれますが、数値が変わった瞬間に勝手には呼ばれません。値を更新したら、invalidate で描き直しを頼んでください。これを忘れると、次に何かの理由で再描画されるまで、古い数字が出続けます。

Spinner は端末版もデスクトップ版も描く場所です。一方、ToolProgress や TurnDuration のように端末版だけが描く場所もあります。どちらの画面で動かしたいかは、Mod はどこで動くか で整理しています。

3. プロンプトの上の帯に、使用率を出す

常に目に入る場所に出したいなら、プロンプトのすぐ上の「帯」が向いています。帯は全部の Mod で共有する場所です。自分の木(描画の指示)を返すと、自分より後ろの Mod が描いたものを置き換えてしまうので、ほかの Mod の表示を残したいときは await next(e) の結果を自分の Box の子に入れます。

次の Mod は、コンテキストの使用率を帯に 1 行出し、利用枠(レート制限)が 90% を超えたら通知します。値は session.measure から受け取ります。このイベントは、返答のたびと、利用枠の使用率が変わったときに呼ばれます。

usage-note/hooks/register.js
// 直近の計測値。session.measure で更新し、ui.render で表示する
let percent = null
// すでに知らせた利用枠の種類。同じ枠で何度も通知しないため
const notified = new Set()

export function register(on) {
  // 返答のたびと、利用枠の割合が変わったときに呼ばれる
  on('session.measure', async ($, e, next) => {
    percent = e.context.percent ?? null
    for (const limit of e.rateLimits) {
      if (limit.percentUsed >= 90 && !notified.has(limit.kind)) {
        notified.add(limit.kind)
        $.ui.toast(limit.kind + ' の枠を ' + limit.percentUsed + '% 使いました')
      }
    }
    // 画面を描き直すよう頼む
    $.ui.invalidate('ui.render')
    return next(e)
  })

  // プロンプトの上の帯に、コンテキストの使用率を 1 行足す
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
    // まだ計測値がなければ、何も足さない
    if (percent === null) return next(e)
    const { Box, Text } = $.ui.resolve(e)
    // 他の Mod が帯に描いたものを残して、その下に足す
    const theirs = await next(e)
    return Box({
      flexDirection: 'column',
      children: [theirs, Text({ bold: percent >= 80, dimColor: percent < 80, children: ['コンテキスト ' + percent + '% 使用'] })],
    })
  })
}

hooks.json は、モジュールを 1 つ指すだけです。

usage-note/hooks/hooks.json
{
  "description": "The usage-note hooks module",
  "modules": ["./register.js"]
}

この Mod には、設計上の工夫が 3 つあります。

  • 計測がまだなら、何も足さない。 percent が null のあいだは next(e) をそのまま返すので、帯は Claude Code の元の状態のままです。
  • 使用率が 80% を超えたら太字にする。 80% 未満は薄い色(dimColor)で目立たせません。「普段は邪魔をしないが、危なくなったら目に入る」形です。
  • 通知は 1 種類の枠につき 1 回だけ。 notified に覚えておかないと、使用率が変わるたびに同じ通知が出て、うるさくなります。

画面の木が正しくないと、どうなるか

ui.render が返す木に、使えない属性が入っていたり、形が合っていなかったりすると、Claude Code は自分の描画に戻します。画面にエラーは出ません。--plugin-dir で起動したセッションなら、ui.render (Pane) refused: ... のような 1 行が会話に出ます。「出るはずの表示が出ない」ときは、まずこの行を探してください。原因の探し方は Mod が動かないときの確認手順 にまとめています。

帯のテスト

画面の描画は、$.ui.mount で確かめられます。Claude Code の描画の代役を on('ui.render', ...) で用意し、自分の Mod が足した文字を find で探します。

usage-note/tests/usage-note.test.ts
import { expect, test } from 'claude-code/testing'

const BAND = {
  plugin: 'usage-note',
  component: 'AbovePrompt',
  requestId: 'band',
  viewport: { columns: 100, rows: 30 },
  props: { hasSurvey: false, isWorking: false, maxRows: 6, bodyColumns: 80, scroll: { offset: 0, bodyRows: 6 }, view: {} },
  surface: 'terminal',
} as const

test('計測値が届くと、帯にコンテキストの使用率が出る', async ($, on) => {
  on('session.measure', ($, e) => ({ changed: e.changed }))
  on('ui.render', () => ({ type: 'Text', props: {}, children: ['drawn by Claude Code'] }))

  await $.session.measure({ context: { window: 200000, tokens: 90000, percent: 45 }, rateLimits: [], changed: ['context'] })
  const ui = await $.ui.mount(BAND)
  expect(await ui.find({ type: 'Text', text: 'コンテキスト 45% 使用' })).toBeDefined()
})

test('利用枠が 90% を超えたら、1 回だけ通知する', async ($, on) => {
  const toasts: string[] = []
  on('ui.toast', ($, e) => {
    toasts.push(e.text)
    return { value: undefined }
  })
  on('session.measure', ($, e) => ({ changed: e.changed }))

  const reading = { context: { window: 200000 }, rateLimits: [{ kind: 'five_hour', percentUsed: 91 }], changed: ['rateLimits'] }
  await $.session.measure(reading)
  await $.session.measure(reading)
  expect(toasts).toEqual(['five_hour の枠を 91% 使いました'])
})

2 件とも通りました。2 件目では同じ計測値を 2 回送り、通知が 1 回だけだと確かめています。

4. 通知(トースト)を出す

一度だけ知らせたいことは、$.ui.toast(text) が向いています。何も指定しなければ 4 秒で消えます。全画面表示では右上の箱、従来の表示ではプロンプトの下の右側に出ます。上の usage-note のように、session.measure の中で呼べば、利用枠が危なくなった瞬間に知らせられます。

通知は消えるので、あとから見返したい情報には向きません。見返したいなら、$.ui.status の行に残すか、帯に出してください。

5. 使い分けの目安

  • 1 つの値を、変えるまで見せたい → $.ui.status。最も簡単で、失敗しにくい。
  • 作業中だけ見せたい → スピナーの suffix。余計な場所を取らない。
  • 常に見せたい、書式も変えたい → 帯。色や太字を使えるが、木を正しく組む手間がある。
  • 一度きりの知らせ → $.ui.toast。
  • 複数の画面やボタンが要る → ペイン($.ui.open)。この記事の範囲を超えるので、公式ドキュメントの「Draw in the interface with a mod」を参照してください。

Mod を作ったら、他人に渡す前に Mod の安全チェックリスト で、使っている API が何を読むかを確かめてください。画面に出すだけの Mod でも、$.process.run で外部コマンドを動かすなら、確認の対象になります。

まとめ

  • 画面に出す場所は、プロンプトの下の 1 行、スピナーの横、上の帯、通知の 4 つ。迷ったら $.ui.status から始める。
  • 値を変えたら $.ui.invalidate('ui.render') で描き直しを頼む。
  • 帯は共有の場所なので、await next(e) の結果を子に入れて、ほかの Mod の表示を残す。
  • 表示だけの Mod は、失敗しても作業を止めないように、例外を握る。
  • claude plugin test の $.ui.mount で、描画も確かめられる。

ほかの人の書き方と見比べたいときは、ModsCode の MCP サーバー の find_code_examples に status や band を渡すと、最小の動く形と、公開されている Mod の実際のコードが返ります。