Claude Media
Claude Code modのui.faultでClientの失敗を拾って描き直す

Claude Code modのui.faultでClientの失敗を拾って描き直す

Claude Code modのClient要素が失敗したときに上がるui.faultイベントの仕組みと、e.phase・e.reasonで受けてui.renderからClientを外す書き方です。

ui.faultは、modが描いたClient要素が読み込み・描画・実行のどこかで失敗したときに、modのフックへ届くイベントです。e.phaseで失敗の段階、e.reasonでエラーメッセージを受け取り、続くui.renderでClientを外して描き直せます。Claude Code v2.1.289以降で使えます。

ui.faultが届くのはどんなときか

modのUIは、ui.renderフックが返す要素の木で組み立てます。その中のClientは、modが用意した別のファイルに描画を任せる領域です。アニメーションやポインター入力に向く一方、そのファイルにmods APIは渡りません。Clientから値をフックに返したいときは、データを投稿します。それがmod側にui.messageイベントとして届きます。つまりClientとmodの接点は、このui.messageと、失敗時のui.faultの2つです。

Clientの中身は、ui.renderを書いたmodとは別のコードです。読み込めない、描画中に例外を投げる、実行中に落ちる、という失敗は起こりえます。ui.faultは、その失敗をmod側が知るための入口です。

イベントの中身は2つのフィールドだけです。

フィールド値
e.phase値load(読み込み)、render(描画)、run(実行)のいずれか
e.reason値エラーメッセージの文字列

リファレンスの表には、このイベントについて「フックが返せるもの」の列がありません。Clientが失敗した理由を受け取って、記録や通知に使うイベント、と捉えるのが安全です。

失敗したClientは、そこだけが落ちる

v2.1.289より前は、描画中に失敗したClientが、同じmodが周りに描いた領域まで巻き込んでいました。v2.1.289の更新履歴には、これを直してClientだけが単独で失敗するようにし、あわせてui.faultを上げるようにした、とあります。

ターミナルでは、失敗したClientの場所に薄い色の1行が出ます。

my-mod: Client client/spinner.js: boom

この1行は、modの名前、Clientが読み込もうとしたファイル、エラーの内容の順です。周りの描画はそのまま残ります。つまりui.faultを処理しなくても、画面が全滅することはありません。処理を書く意味は、次の2つに絞られます。

  • 失敗したClientの場所に、薄い1行ではなく自前の代替表示を置く
  • 同じ失敗が毎回の再描画で繰り返されないよう、Clientを使わない描き方へ切り替える

同じv2.1.289では、ターミナルが描画中に例外を投げたClient領域が、セッションが終わるまで失敗扱いのままになる問題も直っています。失敗したあとの復帰は、この版で挙動が変わった部分です。

フックの書き方 — 失敗を覚えて、次の描画からClientを外す

Claude Codeは、ui.faultを処理するフックが返ったあとに、ui.renderをもう一度呼びます。再描画を自分で頼む必要はありません。ui.faultでは「失敗した」という印を残し、ui.renderではその印を見てClientを描かない、という分担になります。

次はその骨格です。リファレンスの仕様に沿った例です。Clientのmoduleに渡すパスは自分の構成に合わせて読み替えてください。

// Client が失敗したかどうか。モジュール内の変数に持つ
let clientFailed = false
 
export function register(on) {
  // Client の読み込み・描画・実行の失敗を受ける
  on('ui.fault', async ($, e, next) => {
    clientFailed = true
    $.ui.toast('Client failed at ' + e.phase + ': ' + e.reason)
    return next(e)
  })
 
  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
    if (e.requestId !== 'build-status') return next(e)
    const { Box, Text, Client } = $.ui.resolve(e)
 
    const children = [Text({ children: ['Build status'] })]
    if (clientFailed) {
      // 失敗後は Client を使わず、静的な Text で代替する
      children.push(Text({ dimColor: true, children: ['(animation unavailable)'] }))
    } else {
      children.push(Client({ key: 'spinner', module: 'client/spinner.js' }))
    }
    return Box({ flexDirection: 'column', children })
  })
}

読みどころは3点です。

  1. ui.faultのフックは、他のフックと同じくnext(e)を返して後続に渡す。ミドルウェアと同じ作法です。
  2. Clientを外す判断は、ui.renderの中で行う。ui.faultのフック自体は木を返しません。
  3. 上の例のモジュール変数clientFailedが変わっても、Claude Codeは変数の変化を検知して再描画しません。再描画のきっかけは、ui.faultのあとに自動で走る1回です。$.stateに値を持つ場合は、書き込み自体が再描画のきっかけになります(次の節)。

e.phaseで分岐すれば、扱いを変えられます。たとえば次のように書けば、loadの失敗だけを理由にClientを外す設計にできます。

on('ui.fault', async ($, e, next) => {
  if (e.phase === 'load') clientFailed = true
  return next(e)
})

renderとrunの失敗では、Clientを外さずに次の描画でもう一度試す、という選び方もあります。

失敗フラグを$.stateに持つ

上の例のモジュール変数は、開発中にファイルを保存するたびにモジュールが読み直されて消えます。--plugin-dirで動かしながら編集していると、保存のたびにclientFailedがfalseへ戻り、失敗したClientをもう一度描いて、また失敗します。

$.stateに持てば、この問題は起きません。$.stateの値はセッションが終わるまで(/clear、/resume、/branchでも)残り、モジュールを読み直しても消えません。さらに、ui.renderが読んだ値は購読され、書き込むたびにそのサイトが自動で描き直されます。

使うには、型の宣言ファイルを用意して、マニフェストのtypesでそのパスを指します。

// types/index.d.ts
declare module 'claude-code' {
  interface PluginState {
    'my-mod': {
      clientFailed: boolean
    }
  }
}
import { atom, read, update } from 'claude-code'
 
// 値の名前と既定値。plugin と key は文字列リテラルで書く
const clientFailed = atom({ plugin: 'my-mod', key: 'clientFailed' }, false)
 
export function register(on) {
  on('ui.fault', async ($, e, next) => {
    // ui.render では書けないので、別イベントのフックから書く
    await update($, clientFailed, () => true)
    return next(e)
  })
 
  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
    const failed = await read($, clientFailed)
    // failed が true なら Client を描かない(木の組み立ては前の例と同じ)
    return next(e)
  })
}

atomはconstに入れて保持します。letで宣言するとclaude plugin validateが落ちます。宣言ファイルに無い値を使った場合も同じです。ui.renderのフックは値を読めても書けないため、書き込みはui.faultやonPressなどから行います。

--plugin-dirで読み込んだディレクトリは、Claude Codeが監視していて、中のファイルが変わるとフックのモジュールを読み直します。読み直すたびにregisterがもう一度走るので、モジュール内の変数は初期値に戻ります。ui.faultを試すためにClientのファイルをわざと壊して保存し、直してまた保存する、という流れでは、この差がそのまま見えます。モジュール変数だと保存のたびに失敗がなかったことになり、$.stateだと失敗の記録が残ります。

使い分けはこうなります。保存のたびに失敗を忘れてよいなら、モジュール変数で足ります。失敗後は再試行せず、保存してもClientを外したままにしたいなら$.stateです。ユーザーが次回のセッションでも覚えていてほしい設定なら、$.storeの役目になります。

どのClientが失敗したかは、イベントからは分からない

リファレンスがui.faultについて挙げているのはe.phaseとe.reasonだけです。複数のClientを描いているmodで、どれが失敗したのかをイベントから特定できるかは、記載がありません。

複数のClientを使うなら、次のどちらかで備えるのが現実的です。

  • e.reasonに含まれるファイル名で、失敗したClientを見分ける。ターミナルの代替行にclient/spinner.jsのようなファイルが入ることから、メッセージにファイルが含まれる場合はあります
  • 失敗時には、その領域のClientをまとめて外す

ui.messageはClientがmodへデータを投稿するためのイベントで、ui.faultとは別の経路です。ui.faultのイベントが運ぶのはe.phaseとe.reasonだけなので、Clientの識別に使える情報を増やす目的でui.messageを組み合わせても、失敗の通知そのものが変わるわけではありません。失敗の判別は、あくまでreasonの文字列に頼ることになります。

どちらの場合も、reasonの文字列の形式は仕様として保証されていません。部分一致に頼る場合は、見分けられなかったときの既定の動き(まとめて外す)も決めておくと安全です。

再描画の頻度と上限

自動の再描画は、次の流れに乗ります。Claude Codeは、サイトのpropsが変わったときと、ターミナルの幅が変わったときにもui.renderを呼びます。Clientが失敗してui.faultを処理するフックがある場合は、その後にもう1回呼びます。ui.renderが読んだ$.stateの値を書き込んだときにも、そのサイトが描き直されます。一方、タイマーで定期的に呼ぶことはなく、モジュール変数の変化も検知しません。

自分で$.ui.invalidate('ui.render')を呼んで再描画を頼む場合は、毎秒10回まで、ターミナルで見えているペイン・展開したバンド・プロンプト下のヒント行は毎秒30回までに抑えられ、それより速い呼び出しはまとめられます。失敗したClientを外し直すだけなら、この上限に近づく場面はありません。

フック自体にも制約があります。1イベントあたりの実行時間は10秒で、超えるとスキップされます。.catchハンドラーは1秒です。ui.faultのフックで重い処理(ネットワーク呼び出しなど)を待つ構成にすると、再描画が遅れるか、フックごと飛ばされます。記録や通知は短く済ませ、重い処理は別のフックに逃がしておくとよいでしょう。

失敗したClientが「描かれない」別のケース

ui.faultの対象は、Clientが読み込み・描画・実行で失敗した場合です。ほかにも、Clientの周りで描画が出なくなる原因はあります。

  • moduleが10,000文字を超える: Claude Codeがそのサイトの自前の描画に切り替えます。上限は、Codeのlanguage・path、Selectのoptionのvalueにも同じ値で適用されます
  • 木の検証に落ちる: 存在しない要素、その要素が取らないprop、子を持てない要素に子がある、のいずれかです。--plugin-dirで起動したセッションなら、ui.render (Pane) refused: Text prop "bogusProp" is not allowed; the engine drew its ownのような行が記録に出ます
  • フック自体が例外を投げる: フックの失敗は、modとイベントと理由を並べた1行で報告されます

この3つは、ui.faultのドキュメントに「届く」と書かれていない経路です。Clientが出ないときは、まず記録の行を確かめ、ui.faultを待つ設計に頼りすぎないようにします。

テストするとき

claude plugin testは、ui.mountでサイトを描いて、ボタンの操作や要素の検索ができます。ただし、テストのドキュメントにui.faultを直接発火させる例はありません。

確実に書けるのは、ui.renderの分岐そのものの確認です。clientFailedを切り替える入口(たとえばテスト用のコマンド)を用意し、Clientあり・なしの木がどちらも想定どおりに描かれることを確かめます。テストは5秒で打ち切られるので、Clientの実体に依存する部分は含めない構成にします。

どんなmodに必要か

Clientを使わないmodには、ui.faultは関係しません。Box・Text・Buttonだけでペインを組んでいる場合、このイベントは届かないからです。

Clientを使うmodでは、ui.faultを処理しなくても画面は壊れません。Clientからデータを受け取るだけならui.message、失敗にも備えるならui.faultを足す、という順で考えると整理しやすくなります。また、失敗の記録を保存のたびに忘れてよいか、残したいかで、変数と$.stateのどちらに持つかも決まります。処理を足す価値があるのは、失敗の理由をユーザーに見せたい場合と、失敗したClientを静的な表示に置き換えたい場合です。

更新履歴の全体像はClaude Code v2.1.289のリリースノートに、mod自体の作り方はClaude Codeのmodを80行で作る手順にあります。modの導入そのものはv2.1.287の更新内容で確認できます。

よくある質問

ui.faultは、v2.1.289より前のClaude Codeでも使えますか

使えません。v2.1.289以降が必要で、それより前はClientの失敗が周りの描画にも及びます。

Desktopアプリでも同じように動きますか

ClientはターミナルとDesktopの両方に描ける要素として載っています。一方、失敗時に薄い1行が出る挙動として書かれているのはターミナルの場合です。Desktopでの見え方は、記載がありません。

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