SLOPSHOPPER

ui-sampler

A labelled catalog of every place a mod can draw, every API it can call and every value it can read

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ui-sampler
│ ┃ [Pane] UI 見本市 ✕ › fix the failing auth test and add an audit log call │ ┃ [Pane] UI 見本市の目次 │ ┃ 分類を開くと、その分類で描ける場所と呼べる ⏺ Read(src/auth.ts) │ ┃ API が 1 行ずつ並ぶ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ 閉じる ] ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 分類 ⎿ 3 pass, 1 fail │ ┃ 標準の行・帯 5 件 [ 開く ] エンジン │ ┃ ヒント行 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 欄の上の │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ 会話の行 9 件 [ 開く ] 会話欄の │ ┃ 義でター › /ui-sampler │ ┃ [CommandOutput] 自前のツリー [command.run] UI 見本パネルを開きました │ ┃ ダイアログ 4 件 [ 開く ] 質問ダイ │ ┃ ダイアロ │ ┃ │ ┃ $.ui の API 13 件 [ 開く ] ボタンで │ ┃ API(ステ │ ┃ 、パネル │ ┃ ンド自体 │ ┃ │ ┃ イベント・入力 7 件 [ 開く ] スラッシ │ ┃ 欄 、プロン │ ┃ 値は [詳 ✻ [Spinner] word を書き換え… [AbovePrompt] [ 帯のボタン ] [ [DialogPane] を開く ] 帯の入力: ここに入力して Enter ⏎ 写す ボタン: まだ何もしてい [AbovePrompt] この下はほかの mod の帯(next(e) が返したもの) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ ui-sampler: [$.ui.status] セッション開始時に出したステータス行です [SessionMode] モード表示を描き替え(元: auto-accept edits)

Draws

Band
[AbovePrompt] [ 帯のボタン ] [ [DialogPane] を開く ] 帯の入力: ここに入力して Enter ⏎ 写す ボタン [AbovePrompt] この下はほかの mod の帯(next(e) が返したもの) ⟨Claude Code's own drawing⟩
Pane · [Pane] UI 見本市
[Pane] UI 見本市の目次 分類を開くと、その分類で描ける場所と呼べる API が 1 行ずつ並ぶ [ 閉じる ] 分類 標準の行・帯 5 件 [ 開く ] エンジンが描く行(スピナ ドの結果)と、プロンプト 会話の行 9 件 [ 開く ] 会話欄の利用者・Claude・ けとされる行は数えるだけ ダイアログ 4 件 [ 開く ] 質問ダイアログ、許可ダイ く別のパネル $.ui の API 13 件 [ 開く ] ボタンで呼ぶ API(ステータス行、トース ど)と、このパネル・コマ イベント・入力 7 件 [ 開く ] スラッシュコマンドの説明 欄 提案と下書き。受け取った 部品 15 件 [ 開く ] パネルに置ける部品(Text age、Client など)の見本。ここで見本 で並べて描く 副作用のある 5 件 [ 開く ] ボタンを押したときだけ呼 API API(音を出す、環境変数を のセッションの中で元に戻 値 [ 開く ] $ で取れる値、イベントが の答え。秘密になりうる値 す この描画の場所 e.surface terminal $.session.surfaces() terminal e.viewport 160 桁 × 42 行(isFullscreen: true) placement dock(本体 56 桁)
Pane · [DialogPane] ダイアログ風のパネル
[DialogPane] ダイアログ風のパネル $.ui.open のダイアログ向けのオプションを付けて開いたパネル。どこから 開き、どこに置かれたかを示す [ 閉じる ] 開いたところ 開いた入口 /ui-sampler-dialog(command.run、pr isFullscreen: true、160 桁) $.ui.open の結果 isPlaced: true e.props.placement dock e.props.title [DialogPane] ダイアログ風のパネル e.props.isFocused false e.props.bodyColumns 56 e.surface terminal e.viewport 160 桁 × 42 行(isFullscreen: true) placement は dock(本体の横)か inline(プロンプト欄の上)のどちらか。型定義には、ダイア ログとして重ねて出すよう頼むオプションはない 頼んだオプション focus: true 開いたときにキー入力をこのパネルへ移 ト欄が空のときだけ) closeOnEscape: true キー入力を持っている間に Esc を押す holdToasts: true このパネルが出ている間はトーストを止 出す rows: 12 プロンプト欄の上に出るとき、本文に 行ほしいと頼む(横に出るときは使われ 試す [DialogPane] 入力: 開いてすぐ打てるか試す ⏎ 写す Enter で確定 まだ何もしていない [ トーストを出す(holdToasts) ] トースト まだ何もしていない
Command output
[CommandOutput] 自前のツリー [command.run] UI 見本パネルを開きました
README

ui-sampler

mod が描ける場所、呼べる API、受け取れる値を、一通り試すための見本です。 画面に出す文字はすべて [名前] で始まり、どの定義(ui.render の場所、$ の API、イベント)が出したものかが分かります。 コードの各節にも同じ名前の見出しコメントがあるので、画面の文字で検索すれば、その表示を作ったコードに着きます。

どの場所が実際に出るかは、Claude Code のバージョンや、ターミナルかデスクトップアプリかで変わります。 この mod は、その違いを確かめるための道具です。

使い方

/ui-sampler で「UI 見本市」のパネルが開きます。 パネルは目次から始まり、分類を開くと、その分類の場所や API が 1 行ずつ並びます。 各行の [詳細] を押すと、その場所が受け取った値や、呼んだ結果が出ます。

分類中身
標準の行・帯スピナー、モード表示、ヒント行、コマンドの結果の行、プロンプト欄の上の帯。書き換え方を切り替えられる
会話の行利用者・Claude・ツールの行の上に、ラベルの行を足す
ダイアログ質問ダイアログ、許可ダイアログの下の行、ダイアログ風に開くパネル(/ui-sampler-dialog でも開く)
$.ui の APIステータス行、トースト、ログ、コピー、パネルの一覧、スクロール、フォーカスなどをボタンで呼ぶ。押下・フォーカス・描き直しの記録も見られる
イベント・入力欄スラッシュコマンドの説明、/config の行、ターンの終わり、入力欄の提案と下書き
部品パネルに置ける部品(Text、Box、Button、Input、Select、Link、Code、Markdown、Svg、Raster、Image、Client)の見本
副作用のある API音を出す、環境変数を書く、一時フォルダにファイルを書く。どれもボタンを押したときだけ動く
値$ で取れる値(セッション、使用量、ツールやコマンドの一覧など)、各イベントが受け取った値、ボタンで呼ぶ API の答え

標準の表示や会話の行を書き換える場所は、パネルの切り替えでオン・オフできます。 切り替えはそのセッションの間だけ有効で、新しいセッションでは初期値に戻ります。

注意

  • 「値」の画面は、ファイルの中身、設定の値、環境変数の値、会話の文、下書きの文を表示しません。キー名、件数、文字数だけを出します。
  • 次のボタンは、押すと費用や通信が発生します。ボタンの名前に書いてあります。
  • 「内訳を取る(API を呼ぶ)」:使用量の内訳を出すため、API を 1 回呼ぶ
  • 「呼ぶ(トークンを使う)」:小さなモデルに短い問いを 1 回送る
  • 「取りに行く(ネットワーク)」:https://example.com/ に 1 回アクセスする
  • 「一時フォルダに書いて読み直す」は、OS の一時フォルダに ui-sampler-sample.txt を 1 つ作ります。
  • 会話を圧縮する、サブエージェントを起動する、設定を書き換える、ターンを止める API は入れていません。

動作を確認したバージョン

Claude Code 2.1.286(デスクトップアプリ同梱)、2.1.287(ターミナル、validate とエンジンのテストのみ)

Source 19 files
hooks/register.js 116 lines
1// A reference mod for the places a mod can draw. Everything it puts on screen starts with an
2// ASCII label in brackets, `[Spinner]`, `[$.ui.toast]`, naming the site or API that drew it;
3// the same label is the `id` of its SITES entry (sites.js) and heads the code that draws it.
4//
5// sites.js         the SITES table, the per-site switches ($.state) and the call counts
6// pane.js          [Pane]: one pane whose view switches: the table of contents, or one category's
7//                  sites a line each with the [詳細] of one, and the one-shot API buttons
8// elements.js      [Pane/samples] [Pane/Text] [Pane/Box] ...: the pane's view of one sample per
9//                  element; [$.ui.blit] repaints [Pane/Raster] and [Pane/Image], [ui.message]
10//                  answers [Pane/Client]
11// client-echo.js   [Pane/Client]: the surface module the Client sample runs (no $ there)
12// bytes.js         the bytes of [Pane/Raster], [Pane/Image] and [$.audio.play]'s beep
13// dialog.js        [DialogPane]: a second pane, opened with $.ui.open's dialog options
14// engine-lines.js  [Spinner] [SessionMode] [PromptHint] [AbovePrompt] [CommandOutput]: the engine's
15//                  own lines, and the band above the prompt
16// transcript.js    [UserMessage] [AssistantMessage] [ToolUse] [ToolResult] [ToolGroup]: the
17//                  transcript's rows; [ToolProgress] [TurnDuration] [InfoNotice]: counted only
18// asks.js          [AskUserQuestion] [$.ui.notice]: the question dialog, the permission dialog
19// events.js        [command.describe] [config.describe] [turn.complete] [prompt.suggest]: engine
20//                  events that are not drawings (the $.prompt buttons are in pane.js, as are the
21//                  effects category's [$.audio.*] [$.env.set] [$.fs.write])
22// values.js        [Pane/values]: the 値 category's view, what a mod can obtain (the getters on
23//                  $, the last input of each event, calls made from buttons, $.state and
24//                  $.store), and the event hooks that keep those inputs
25// value-format.js  the values view's tables, and the reducing of values to what may be shown
26// redraw.js        when a hook's new call or props are worth redrawing the pane for
27// diag.js          [診断/press]: the press, focus and redraw log
28// press-guard.js   [press/再実行]: runs a press again that did not reach its onPress
29// style.js         the shared look: spacing, column widths, colors, button roles, and pure
30//                  builders for headers, sections, table rows, fields and cards
31
32import { COMMAND, DIALOG_COMMAND, DIALOG_OPEN, PANE, noteCall } from './sites.js'
33import { registerPane } from './pane.js'
34import { registerElements } from './elements.js'
35import { registerDialog } from './dialog.js'
36import { registerEngineLines } from './engine-lines.js'
37import { registerTranscript } from './transcript.js'
38import { registerAsks } from './asks.js'
39import { registerEvents } from './events.js'
40import { registerValues } from './values.js'
41import { holdEvent, mergeHeld, restoreHeld } from './event-log.js'
42import { eventShape } from './value-format.js'
43import { paneShowsView } from './redraw.js'
44import { atom, update } from 'claude-code'
45
46// The state this file writes (declared in types/index.d.ts): the pane's view, [DialogPane]'s
47// echo lines, which say which entry opened it, and the values view's event inputs
48const view = atom({ plugin: 'ui-sampler', key: 'view' }, 'toc')
49const ECHO = { plugin: 'ui-sampler', key: 'echo' }
50const EVENTS = { plugin: 'ui-sampler', key: 'events' }
51
52// Keeps one event's reduced input for the values view (event-log.js): written now, unless
53// that view is shown and wrote within REDRAW_GAP_MS, when its timer (values.js) writes it.
54// Never throws: the event goes on whatever happens.
55async function keepEvent($, name, shape) {
56  const batch = holdEvent(name, shape, Date.now(), paneShowsView('values'))
57  if (!batch) return
58  try {
59    await update($, EVENTS, current => mergeHeld(current, batch))
60  } catch {
61    restoreHeld(batch)
62  }
63}
64
65export function register(on) {
66  // ===== session.start: /ui-sampler, /ui-sampler-dialog and the first [$.ui.status] =====
67  // Also keeps its input for the values view's [session.start] (values.js hooks the other
68  // events; a plugin hooks an event without a matcher once)
69  on('session.start', async ($, e, next) => {
70    await keepEvent($, 'session.start', eventShape('session.start', e))
71    await $.command.register({ name: COMMAND, description: 'UI の見本パネルを開く' })
72    await $.command.register({ name: DIALOG_COMMAND, description: 'ダイアログ風のパネル [DialogPane] を開く' })
73    noteCall('$.ui.status')
74    $.ui.status('[$.ui.status] セッション開始時に出したステータス行です')
75    return next(e)
76  })
77
78  // ===== [command.run] on('command.run', { command: 'ui-sampler' }) =====
79  // Its text becomes the transcript row [CommandOutput] draws (engine-lines.js). The matcher is
80  // a literal (COMMAND's value) so `plugin validate` can list it; an imported const shows as `?`.
81  // It starts the pane at the contents, which is also the way back from a view that does not draw.
82  on('command.run', { command: 'ui-sampler' }, async $ => {
83    noteCall('command.run')
84    await update($, view, () => 'toc')
85    const opened = await $.ui.open({ id: PANE, title: '[Pane] UI 見本市' })
86    const placed = opened.isPlaced ? '' : `(まだ表示されていない: ${opened.reason})`
87    return { text: `[command.run] UI 見本パネルを開きました${placed}` }
88  })
89
90  // ===== [command.run] on('command.run', { command: 'ui-sampler-dialog' }) =====
91  // Another entry point for [DialogPane], beside the main pane's dialogs view button and the band's: a
92  // command the person typed, with the same open options (DIALOG_OPEN). The opener is written
93  // before the open so the pane's first drawing shows it.
94  on('command.run', { command: 'ui-sampler-dialog' }, async ($, e) => {
95    noteCall('command.run')
96    const where = `isFullscreen: ${e.presentation.isFullscreen}、${e.presentation.columns} 桁`
97    await update($, { ...ECHO, id: 'DialogPane:openedBy' }, () => `/ui-sampler-dialog(command.run、presentation: ${where})`)
98    const opened = await $.ui.open(DIALOG_OPEN)
99    const result = opened.isPlaced ? 'isPlaced: true' : `isPlaced: false、reason: ${opened.reason}`
100    await update($, { ...ECHO, id: 'DialogPane:openResult' }, () => result)
101    return { text: `[command.run] /ui-sampler-dialog で [DialogPane] を開きました(${result}、presentation の ${where})` }
102  })
103
104  // registerValues, then registerElements, before registerPane: all three hook the pane, and
105  // the first registered is the outer link, so the values view answers 'values', the samples
106  // view 'samples', and each passes the rest on to [Pane]
107  registerValues(on)
108  registerElements(on)
109  registerPane(on)
110  registerDialog(on)
111  registerEngineLines(on)
112  registerTranscript(on)
113  registerAsks(on)
114  registerEvents(on)
115}
116
hooks/sites.js 888 lines
1// The list of every place this mod draws or calls, what the per-site on/off switches mean, and the
2// call counts the pane shows. Each SITES entry's `id` is also the `[id]` label its output
3// starts with and the label in the heading comment above its code, so grepping `[Spinner]`
4// finds the entry, the hook and the text on screen.
5
6export const PANE = 'ui-sampler'
7export const DIALOG_PANE = 'ui-sampler-dialog'
8export const COMMAND = 'ui-sampler'
9export const DIALOG_COMMAND = 'ui-sampler-dialog'
10
11/**
12 * What every entry point passes to $.ui.open for [DialogPane]: the dialog category's button, the
13 * [AbovePrompt] band's button and /ui-sampler-dialog open it the same way, so only where the
14 * open came from differs. The type declarations offer no option that picks the placement:
15 * the surface seats a pane `dock` or `inline` itself (Pane props `placement`).
16 */
17export const DIALOG_OPEN = {
18  id: DIALOG_PANE,
19  title: '[DialogPane] ダイアログ風のパネル',
20  focus: true,
21  closeOnEscape: true,
22  holdToasts: true,
23  rows: 12,
24}
25
26/**
27 * The categories the table of contents lists, in its order. Each SITES entry names one in
28 * `category`. [開く] switches the one pane to the category's view (pane.js), which lists its
29 * sites one line each; the element samples are a view of their own ('samples', elements.js)
30 * reached from the 部品 category. A category marked `isPending` is listed as not made yet. One
31 * marked `hasOwnView` lists no SITES: its view draws its own rows (値: values.js). `note` is a
32 * line under the category view's buttons.
33 */
34export const CATEGORIES = [
35  {
36    id: 'lines',
37    label: '標準の行・帯',
38    about: 'エンジンが描く行(スピナー、モード表示、ヒント行、コマンドの結果)と、プロンプト欄の上の帯',
39  },
40  {
41    id: 'transcript',
42    label: '会話の行',
43    about: '会話欄の利用者・Claude・ツールの行。型定義でターミナルだけとされる行は数えるだけ',
44  },
45  {
46    id: 'dialogs',
47    label: 'ダイアログ',
48    about: '質問ダイアログ、許可ダイアログの下の行、ダイアログ風に開く別のパネル',
49  },
50  {
51    id: 'api',
52    label: '$.ui の API',
53    about: 'ボタンで呼ぶ API(ステータス行、トースト、ログ、コピー、パネルの一覧など)と、このパネル・コマンド自体',
54  },
55  {
56    id: 'events',
57    label: 'イベント・入力欄',
58    about: 'スラッシュコマンドの説明、ターンの終わり、プロンプト欄の提案と下書き。受け取った値は [詳細] に出る',
59  },
60  {
61    id: 'elements',
62    label: '部品',
63    about: 'パネルに置ける部品(Text、Box、Button、Input、Raster、Image、Client など)の見本。ここで見本ごとにオン・オフし、[見本を見る] で並べて描く',
64  },
65  {
66    id: 'effects',
67    label: '副作用のある API',
68    about: 'ボタンを押したときだけ呼ぶ、状態を変える API(音を出す、環境変数を書く、ファイルを書く)。どれもこのセッションの中で元に戻せるものだけ',
69    note: '入れていないもの: $.session.compact(会話を圧縮して元に戻せない)、$.agent.spawn(サブエージェントがトークンを使う)、$.config.set(利用者の設定を書き換える)、$.turn.abort(実行中のターンを止める)',
70  },
71  {
72    id: 'values',
73    label: '値',
74    about: '$ で取れる値、イベントが受け取った値、ボタンで呼ぶ API の答え。秘密になりうる値は、キー名・件数・文字数だけを出す',
75    hasOwnView: true,
76  },
77]
78
79/** The note under the props of a transcript row site: many rows share one site. */
80const ROW_PROPS_NOTE =
81  '会話欄に何行あっても、ここに出るのは最後に描かれた 1 行の props。行が描き直されてもこのパネルは描き直さないので、「回数を更新」で最新にする。呼び出しの回数には描き直しも入る'
82
83/**
84 * kind: 'render' (a ui.render site), 'api' (a $ method the pane or a hook calls), 'event' (a
85 * hook on an engine event), 'element' (one sample in the samples view, elements.js; `element`
86 * names the entry of the surface's element table it draws). category: its CATEGORIES id.
87 * toggleable sites can be switched off from the pane; an off site's hook passes `next(e)` on
88 * unchanged, an off element sample is left out of the samples view's tree. A render site's
89 * `props` names each field of its render props with a short note of what it means (from the
90 * type declarations); the pane lists the last props the site received against it, and
91 * `propsNote` adds a line under that list. An event site's `props` does the same for the
92 * fields of its input `e`.
93 */
94export const SITES = [
95  {
96    id: 'PromptHint',
97    label: '[PromptHint]',
98    kind: 'render',
99    category: 'lines',
100    where: 'プロンプト欄のすぐ下の薄いヒント行。ターミナルでは待機中に `? for shortcuts`、ターンの実行中に `esc to interrupt` が出る行',
101    toggleable: true,
102    defaultOn: true,
103    props: {
104      isDraft: 'プロンプト欄に打ちかけの文字があると true',
105      isWorking: 'ターンの実行中は true',
106      hint: 'エンジンが描く行の文字(1 つの文字列。書き換えると行ごと置き換わる)',
107      tail: 'フックが行の後ろに足す文字。エンジンからは渡されない',
108    },
109  },
110  {
111    id: 'Spinner',
112    label: '[Spinner]',
113    kind: 'render',
114    category: 'lines',
115    where: 'ターンの実行中に動く行。ターミナルでは `Sauteing… (12s, 300 tokens)` のように、動く単語・経過秒数・トークン数が並ぶ行',
116    toggleable: true,
117    defaultOn: true,
118    props: {
119      word: '動く単語。ターンごとに選ばれる(デスクトップでは今の手順の説明、なければ Working)',
120      message: '状態が単語を上書きしている間、単語の代わりに出る文字。なければ null',
121      suffix: '単語の直後に付く「まだ続いている」印(省略記号 1 文字)',
122      mode: 'ターンが今していること: requesting / responding / thinking / tool-input / tool-use',
123    },
124    propsNote: '経過時間・トークン数・effort は props には入っておらず、画面の側が持っている(型定義の説明)',
125  },
126  {
127    id: 'SessionMode',
128    label: '[SessionMode]',
129    kind: 'render',
130    category: 'lines',
131    where: 'プロンプト欄のフッターの右側に薄く出るモード名(`focus`、`memory paused` など。複数あれば ` & ` でつながる)',
132    toggleable: true,
133    defaultOn: true,
134    props: {
135      modes: 'フッターに出ているモード名の並び。なければ空',
136    },
137  },
138  {
139    id: 'AbovePrompt',
140    label: '[AbovePrompt]',
141    kind: 'render',
142    category: 'lines',
143    where: 'プロンプト欄のすぐ上の帯。エンジン自身は何も描かず、アンケートが出る場所。ほかの mod の帯(next(e) の結果)は消さずに、その上に 1 段足す',
144    toggleable: true,
145    defaultOn: true,
146    props: {
147      hasSurvey: 'アンケートが帯を使っている間は true(そのときは譲る)',
148      isWorking: 'ターンの実行中は true',
149      maxRows: '帯が使える行数',
150      bodyColumns: '帯の幅(桁)',
151      scroll: '背の高い中身を見せる窓(offset: 先頭の行、bodyRows: 見える行数)',
152      view: '帯の上に出ている会話(agentId がなければ本体の会話)',
153    },
154  },
155  {
156    id: 'CommandOutput',
157    label: '[CommandOutput]',
158    kind: 'render',
159    category: 'lines',
160    where: '/ui-sampler を実行した後、会話欄でコマンドの下に残る結果の行(コマンドが返した text が出る)',
161    toggleable: true,
162    defaultOn: true,
163    props: {
164      command: 'この行を出したコマンド名(スラッシュなし)',
165      args: 'コマンドに付けた引数',
166      text: '行の文字(コマンドが返した text。Markdown として描かれる)',
167      isErrored: 'コマンドが失敗した行なら true',
168      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null',
169    },
170  },
171  {
172    id: 'Pane',
173    label: '[Pane]',
174    kind: 'render',
175    category: 'api',
176    where: '$.ui.open で開く枠。/ui-sampler で開くこのパネル「[Pane] UI 見本市」。目次・分類ごとの一覧・部品の見本を、同じパネルの中で切り替えて描く。/ui-sampler で開き直すと目次に戻る',
177    toggleable: false,
178    defaultOn: true,
179  },
180
181
182  {
183    id: 'DialogPane',
184    label: '[DialogPane]',
185    kind: 'render',
186    category: 'dialogs',
187    where: '$.ui.open に focus / closeOnEscape / holdToasts / rows を付けて、ダイアログのように開くパネル。開き方は 3 通り: この行のボタン、[AbovePrompt] の帯のボタン、/ui-sampler-dialog',
188    toggleable: false,
189    defaultOn: true,
190  },
191  {
192    id: '$.ui.status',
193    label: '[$.ui.status]',
194    kind: 'api',
195    category: 'api',
196    where: 'プロンプト欄の下に固定される、この mod のステータス行(mod ごとに 1 行。次の呼び出しで置き換わる)',
197    toggleable: false,
198    defaultOn: true,
199  },
200  {
201    id: '$.ui.status/clear',
202    label: '[$.ui.status/clear]',
203    kind: 'api',
204    category: 'api',
205    where: '$.ui.status(undefined): 上のステータス行を消す',
206    toggleable: false,
207    defaultOn: true,
208  },
209  {
210    id: '$.ui.toast',
211    label: '[$.ui.toast]',
212    kind: 'api',
213    category: 'api',
214    where: '会話欄の右上に重なって数秒だけ出る小さな箱(mod 名付き。既定は 4 秒)',
215    toggleable: false,
216    defaultOn: true,
217  },
218  {
219    id: '$.ui.toast/timeoutMs',
220    label: '[$.ui.toast/timeoutMs]',
221    kind: 'api',
222    category: 'api',
223    where: '$.ui.toast(text, { timeoutMs: 10000 }): 同じ箱を 10 秒出す',
224    toggleable: false,
225    defaultOn: true,
226  },
227  {
228    id: '$.ui.log',
229    label: '[$.ui.log]',
230    kind: 'api',
231    category: 'api',
232    where: '会話欄に挟まる薄い 1 行(モデルには送られない)',
233    toggleable: false,
234    defaultOn: true,
235  },
236  {
237    id: '$.ui.log/debug',
238    label: '[$.ui.log/debug]',
239    kind: 'api',
240    category: 'api',
241    where: "$.ui.log(text, { to: 'debug' }): デバッグログ(claude --debug)にだけ書き、画面には何も出さない",
242    toggleable: false,
243    defaultOn: true,
244  },
245  {
246    id: '$.ui.ask',
247    label: '[$.ui.ask]',
248    kind: 'api',
249    category: 'dialogs',
250    where: 'エンジン標準の質問ダイアログ(AskUserQuestion と同じもの)。[AskUserQuestion] をオンにしてから押すと、その描き替えも試せる',
251    toggleable: false,
252    defaultOn: true,
253  },
254  {
255    id: '$.ui.copy',
256    label: '[$.ui.copy]',
257    kind: 'api',
258    category: 'api',
259    where: '押した画面のクリップボードに文字を入れる。結果(isCopied と、失敗ならその理由)を下に出す',
260    toggleable: false,
261    defaultOn: true,
262  },
263  {
264    id: '$.ui.panes',
265    label: '[$.ui.panes]',
266    kind: 'api',
267    category: 'api',
268    where: 'この mod が開いているパネルの一覧(id・title・isShown・isFocused・isPlaced)を下に出す',
269    toggleable: false,
270    defaultOn: true,
271  },
272  {
273    id: '$.ui.scroll',
274    label: '[$.ui.scroll]',
275    kind: 'api',
276    category: 'api',
277    where: "$.ui.scroll({ in: 'ui-sampler', to: 'start' }): このパネルをいちばん上までスクロールする",
278    toggleable: false,
279    defaultOn: true,
280  },
281  {
282    id: '$.ui.focus',
283    label: '[$.ui.focus]',
284    kind: 'api',
285    category: 'api',
286    where: "$.ui.focus({ requestId: 'ui-sampler', key: 'refresh' }): このパネルのフォーカスの枠を上の「回数を更新」ボタンに移す(パネルがキー入力を持っている間だけ)",
287    toggleable: false,
288    defaultOn: true,
289  },
290  {
291    id: '$.session.append',
292    label: '[$.session.append]',
293    kind: 'api',
294    category: 'api',
295    where: '会話の記録に足す system 行(モデルは読まない)',
296    toggleable: false,
297    defaultOn: true,
298  },
299  {
300    id: 'command.run',
301    label: '[command.run]',
302    kind: 'event',
303    category: 'api',
304    where: '/ui-sampler と /ui-sampler-dialog の実行。/ui-sampler の返した text が [CommandOutput] の行になる。/ui-sampler-dialog は [DialogPane] を開き、[command.run] で始まる text を返す([CommandOutput] は描き替えない)',
305    toggleable: false,
306    defaultOn: true,
307  },
308  {
309    id: 'UserMessage',
310    label: '[UserMessage]',
311    kind: 'render',
312    category: 'transcript',
313    where: '会話欄の利用者側の行。ターミナルでは `> ` で始まる、打ったプロンプトの行。バックグラウンドのタスクの通知や、ほかのエージェント・セッションから届いたメッセージの行もここ。オンにすると、エンジンの行はそのままで、その上に [UserMessage] の 1 行を足す',
314    toggleable: true,
315    defaultOn: false,
316    props: {
317      text: '行に出る文字(打ったプロンプト、通知の要約、届いたメッセージの本文)',
318      origin: 'メッセージの出どころ(kind: プロンプト欄、タスクの通知、ほかのセッション、プラグインなど)。読み取り専用',
319      isExpanded: '行を全部出しているか(ctrl+o の記録表示など)。false なら送り主の名前だけの薄い 1 行になることがある',
320      task: '通知の行のときだけ: そのバックグラウンドのタスク(id、status、durationMs)',
321      from: 'ほかのエージェント・チームメイト・セッションから届いた行のときだけ: 送り主',
322      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null、画面が知らせないときはなし',
323    },
324    propsNote: ROW_PROPS_NOTE,
325  },
326  {
327    id: 'AssistantMessage',
328    label: '[AssistantMessage]',
329    kind: 'render',
330    category: 'transcript',
331    where: 'Claude の返答の文章。返答の text ブロック 1 つが 1 行で、ターミナルでは返答の最初のブロックの頭に ● が付く。オンにすると、ブロックごとにその上に [AssistantMessage] の 1 行を足す',
332    toggleable: true,
333    defaultOn: false,
334    props: {
335      text: 'ブロックの文字(Markdown)。画面が隠す部分は除かれている',
336      isFirstOfReply: '返答の最初のブロック(頭の印を描くもの)なら true',
337      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null、画面が知らせないときはなし',
338    },
339    propsNote: ROW_PROPS_NOTE,
340  },
341  {
342    id: 'ToolUse',
343    label: '[ToolUse]',
344    kind: 'render',
345    category: 'transcript',
346    where: 'ツールの呼び出しの行。ターミナルでは `● Bash(ls -la)` のように、ツール名と入力が出る行。オンにすると、その上に [ToolUse] の 1 行を足す',
347    toggleable: true,
348    defaultOn: false,
349    props: {
350      tool_use_id: 'この呼び出しの id(tool.call の e.tool_use_id と同じ)。読み取り専用',
351      tool: 'ツール名(Bash、Read、プラグインのツールなど)',
352      input: 'モデルが送った入力',
353      isRunning: 'まだ実行中なら true',
354      isErrored: 'エラーで終わったら true(許可ダイアログで断ったときも)',
355      isInterrupted: 'Esc などの中断で終わったら true',
356      output: '終わった後の結果(Bash なら stdout・stderr など)。実行中はなし',
357      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null、画面が知らせないときはなし',
358    },
359    propsNote: ROW_PROPS_NOTE,
360  },
361  {
362    id: 'ToolResult',
363    label: '[ToolResult]',
364    kind: 'render',
365    category: 'transcript',
366    where: 'ツールの呼び出しの行の下に出る結果。ターミナルでは `⎿` に続くコマンドの出力など。まとめられていない単独の呼び出しの行だけ。オンにすると、その上に [ToolResult] の 1 行を足す',
367    toggleable: true,
368    defaultOn: false,
369    props: {
370      tool_use_id: 'この結果の呼び出しの id。読み取り専用',
371      tool: 'ツール名。読み取り専用',
372      output: 'ツールの結果そのもの(ToolUse の output と同じもの)',
373      isErrored: 'エラーで終わったら true(そのときは output ではなくエラーの文字が出る)。読み取り専用',
374      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null、画面が知らせないときはなし',
375    },
376    propsNote: ROW_PROPS_NOTE,
377  },
378  {
379    id: 'ToolGroup',
380    label: '[ToolGroup]',
381    kind: 'render',
382    category: 'transcript',
383    where: '続けて呼ばれた読み取り・検索などのツールを 1 行にまとめた行。ターミナルでは `Read 3 files, ran 2 shell commands` のような行。オンにすると、その上に [ToolGroup] の 1 行を足す',
384    toggleable: true,
385    defaultOn: false,
386    props: {
387      calls: 'まとめた呼び出しの並び(呼ばれた順。1 件ずつ tool・input・isRunning・output など)',
388      isActive: 'まだ続きが加わりうる、いま動いているまとまりなら true',
389      isExpanded: '呼び出しごとの [ToolUse] の行に開いて出すなら true、1 行にまとめるなら false。書き換えて画面が変わるのはこれだけ',
390      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null、画面が知らせないときはなし',
391    },
392    propsNote: ROW_PROPS_NOTE,
393  },
394  {
395    id: 'ToolGroup/isExpanded',
396    label: '[ToolGroup/isExpanded]',
397    kind: 'render',
398    category: 'transcript',
399    where: '[ToolGroup] のまとめた行で、isExpanded を true にして next(e) に渡し、呼び出しごとの行に開く。開いたことは [ToolGroup/isExpanded] の 1 行で示す。開いた行は [ToolUse] の行なので、[ToolUse] もオンにすると印が付く',
400    toggleable: true,
401    defaultOn: false,
402  },
403  {
404    id: 'AskUserQuestion',
405    label: '[AskUserQuestion]',
406    kind: 'render',
407    category: 'dialogs',
408    where: 'Claude が AskUserQuestion ツールで出す質問ダイアログ([$.ui.ask] のボタンでも出る)。オンにすると、質問文の頭に [AskUserQuestion] を付けて next(e) に渡す(見出しの header は 12 文字までなので使わない)',
409    toggleable: true,
410    defaultOn: false,
411    props: {
412      tool: 'ダイアログを開いたツールの名前(AskUserQuestion)',
413      questions: 'ツールの questions 入力(question・header・options・multiSelect)。書き換えてもツールの形に合わないと元のまま出る',
414      metadataSource: '誰が聞いたか(モデルが付けたときだけ)。集計用で、画面には出ない',
415    },
416  },
417  {
418    id: '$.ui.notice',
419    label: '[$.ui.notice]',
420    kind: 'api',
421    category: 'dialogs',
422    where: 'Bash の許可ダイアログの下に足す 1 行。オンにすると、tool.call(Bash)のフックが next(e) の前に $.ui.notice(e.tool_use_id, text) を呼ぶ。行は呼び出しが終わるとエンジンが消す。許可ダイアログが出ない Bash(許可済みのコマンドなど)では、出る場所がない',
423    toggleable: true,
424    defaultOn: false,
425  },
426  {
427    id: 'ToolProgress',
428    label: '[ToolProgress]',
429    kind: 'render',
430    category: 'transcript',
431    where: 'ターミナルで、実行中のツールの行の下に薄く出る `(ctrl+b to run in background)` の行。呼ばれた回数と props を記録するだけで、描画は変えない',
432    toggleable: false,
433    defaultOn: true,
434    props: {
435      tool_use_id: 'この行が属するツールの呼び出しの id。読み取り専用',
436      kind: '進み具合の行の種類(いまは background_hint だけ)。読み取り専用',
437      hint: 'エンジンが描く文字(`(ctrl+b to run in background)` など)',
438    },
439  },
440  {
441    id: 'TurnDuration',
442    label: '[TurnDuration]',
443    kind: 'render',
444    category: 'transcript',
445    where: 'ターミナルで、ターンの終わりに会話欄へ残る `Baked for 3s` の行。呼ばれた回数と props を記録するだけで、描画は変えない',
446    toggleable: false,
447    defaultOn: true,
448    props: {
449      word: '行の過去形の単語(Baked など)',
450      durationMs: 'ターンにかかった時間(ミリ秒)',
451      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null、画面が知らせないときはなし',
452    },
453    propsNote: ROW_PROPS_NOTE,
454  },
455  {
456    id: 'InfoNotice',
457    label: '[InfoNotice]',
458    kind: 'render',
459    category: 'transcript',
460    where: 'ターミナルの起動時に、ロゴの下に薄く出るお知らせの行(使っているモデルの出どころ、設定のヒントなど。後ろに /コマンド が付くことがある)。呼ばれた回数と props を記録するだけで、描画は変えない',
461    toggleable: false,
462    defaultOn: true,
463    props: {
464      text: 'お知らせの文字(1 つの文字列にしたもの)',
465      command: '後ろに付く /コマンド。なければ null',
466      onScreen: '会話欄の見えている範囲にこの行のどこが入っているか。外なら null、画面が知らせないときはなし',
467    },
468    propsNote: ROW_PROPS_NOTE,
469  },
470  {
471    id: 'command.describe',
472    label: '[command.describe]',
473    kind: 'event',
474    category: 'events',
475    where: '/ を打ったときの候補と /help に出る、/ui-sampler と /ui-sampler-dialog の説明。オンにすると説明の頭に [command.describe] を付ける。答えはセッションの間覚えられるので、切り替えたときに $.ui.invalidate("command.describe") で聞き直させる',
476    toggleable: true,
477    defaultOn: false,
478    props: {
479      command: 'コマンド名(スラッシュなし)。書き換えは断られる',
480      description: '候補と /help に出る 1 行の説明(登録したときのもの)',
481      argumentHint: '名前の後ろに薄く出るヒント。あるときだけ',
482      isHidden: '候補と /help から外すなら true(打てば実行はできる)',
483      immediate: 'ターンの途中でもすぐ実行するコマンドなら true。読み取り専用',
484      provider: 'コマンドを出している plugin とその層。書き換えは断られる',
485    },
486  },
487  {
488    id: 'config.describe',
489    label: '[config.describe]',
490    kind: 'event',
491    category: 'events',
492    where: '/config のメニューの各行($.config.list の答えにも使われる)。行を足すことはできず、ラベル・説明の書き換えと隠すことだけができる。オンにすると、どの行もラベルの頭に [config.describe] を付ける。答えは覚えられるので、切り替えたときに $.ui.invalidate("config.describe") で聞き直させる',
493    toggleable: true,
494    defaultOn: false,
495    props: {
496      key: '行の名前($.config.set の key と同じ)。書き換えは断られる',
497      label: 'メニューで値の前に出る文字',
498      description: 'ラベルの下の説明(プラグインの userConfig の項目のときなど)。なければなし',
499      isHidden: 'メニューから外すなら true(隠しても $.config.set と /config key=value は効く)',
500      provider: 'その行の持ち主(plugin と tier)。書き換えは断られる',
501    },
502  },
503  {
504    id: 'turn.complete',
505    label: '[turn.complete]',
506    kind: 'event',
507    category: 'events',
508    where: 'ターンが終わったとき(所要時間が出るところ)。オンにすると、答えの下に [turn.complete] の 1 行を出す(返す text を答えと違うものにすると、答えの下に出る)。サブエージェントのターンには何もしない',
509    toggleable: true,
510    defaultOn: false,
511    props: {
512      answer: 'このターンの最後に見えた答えの文字(なければ空)',
513      durationMs: 'ターンにかかった時間(ミリ秒)',
514      isAborted: '中断で終わったら true',
515      reason: '終わった理由: answer / aborted / refusal / error',
516      refusal: 'reason が refusal のときだけ: API が言った断りの中身',
517      turnId: 'このターンの id(turn.start・turn.step と同じ)',
518      agentId: 'サブエージェントのターンのときだけ: その id',
519      usage: 'このターンのトークン数(input_tokens・output_tokens・キャッシュ)と model。数えるものがなければなし',
520    },
521  },
522  {
523    id: 'prompt.suggest',
524    label: '[prompt.suggest]',
525    kind: 'event',
526    category: 'events',
527    where: 'プロンプト欄が空のときに薄く出る提案(Tab で取り込む)。エンジンのターン後の推測か、ほかの plugin の $.prompt.suggest のときに呼ばれる(この mod 自身の [$.prompt.suggest] はこのフックを通らない)。オンにすると、提案の頭に [prompt.suggest] を付ける',
528    toggleable: true,
529    defaultOn: false,
530    props: {
531      text: '提案する文字。取り込むと下書きになる',
532      origin: '誰の提案か(kind: suggestion ならエンジン、plugin なら name も)。読み取り専用',
533    },
534  },
535  {
536    id: '$.prompt.suggest',
537    label: '[$.prompt.suggest]',
538    kind: 'api',
539    category: 'events',
540    where: 'プロンプト欄に薄い提案を出す(Tab で取り込める)。欄に文字があるとき、ターンの実行中は出ない(isShown: false)',
541    toggleable: false,
542    defaultOn: true,
543  },
544  {
545    id: '$.prompt.fill',
546    label: '[$.prompt.fill]',
547    kind: 'api',
548    category: 'events',
549    where: "$.prompt.fill({ text, mode: 'append' }): プロンプト欄の下書きの後ろに文字を足す。打ちかけの文字は残る。ボタンを押したときだけ呼ぶ",
550    toggleable: false,
551    defaultOn: true,
552  },
553  {
554    id: '$.prompt.read',
555    label: '[$.prompt.read]',
556    kind: 'api',
557    category: 'events',
558    where: 'プロンプト欄の下書きを読む。ここには文字数とカーソルの位置だけを出し、中身は出さない',
559    toggleable: false,
560    defaultOn: true,
561  },
562  {
563    id: 'Pane/Text',
564    label: '[Pane/Text]',
565    kind: 'element',
566    category: 'elements',
567    element: 'Text',
568    where: 'Text: color(テーマ名と色コード)/ backgroundColor / bold / dimColor / italic / underline / strikethrough / inverse / wrap / hover',
569    toggleable: true,
570    defaultOn: true,
571  },
572  {
573    id: 'Pane/Box',
574    label: '[Pane/Box]',
575    kind: 'element',
576    category: 'elements',
577    element: 'Box',
578    where: 'Box: borderStyle / borderColor / borderDimColor / backgroundColor / padding / hover / hover.scope / position: absolute と display: none',
579    toggleable: true,
580    defaultOn: true,
581  },
582  {
583    id: 'Pane/Button',
584    label: '[Pane/Button]',
585    kind: 'element',
586    category: 'elements',
587    element: 'Button',
588    where: 'Button: 既定 / variant / plain / hotkey / dimColor / autoFocus / role: dismiss / hover',
589    toggleable: true,
590    defaultOn: true,
591  },
592  {
593    id: 'Pane/Button/action',
594    label: '[Pane/Button/action]',
595    kind: 'element',
596    category: 'elements',
597    element: 'Button',
598    where: 'Button の action(エンジンのキー操作 app:cycleDiffBase)。名前が通らないと見本ごと描かれないので別の切り替え',
599    toggleable: true,
600    defaultOn: true,
601  },
602  {
603    id: 'Pane/Input',
604    label: '[Pane/Input]',
605    kind: 'element',
606    category: 'elements',
607    element: 'Input',
608    where: 'Input: label / placeholder / submitLabel / value。打った字を下に写す',
609    toggleable: true,
610    defaultOn: true,
611  },
612  {
613    id: 'Pane/Select',
614    label: '[Pane/Select]',
615    kind: 'element',
616    category: 'elements',
617    element: 'Select',
618    where: 'Select: label / options(label あり・なし)/ value。選んだ値を下に写す',
619    toggleable: true,
620    defaultOn: true,
621  },
622  {
623    id: 'Pane/Link',
624    label: '[Pane/Link]',
625    kind: 'element',
626    category: 'elements',
627    element: 'Link',
628    where: 'Link: 文中(children)/ label / どちらもなし(URL がそのまま出る)/ http://localhost',
629    toggleable: true,
630    defaultOn: true,
631  },
632  {
633    id: 'Pane/Code',
634    label: '[Pane/Code]',
635    kind: 'element',
636    category: 'elements',
637    element: 'Code',
638    where: 'Code: language + startLine / path から言語を推測 / format: diff / wrap: truncate-end',
639    toggleable: true,
640    defaultOn: true,
641  },
642  {
643    id: 'Pane/Markdown',
644    label: '[Pane/Markdown]',
645    kind: 'element',
646    category: 'elements',
647    element: 'Markdown',
648    where: 'Markdown: 見出し・表・コード・リンク / dimColor / onLinkPress + pressableLinks',
649    toggleable: true,
650    defaultOn: true,
651  },
652  {
653    id: 'Pane/Svg',
654    label: '[Pane/Svg]',
655    kind: 'element',
656    category: 'elements',
657    element: 'Svg',
658    where: 'Svg: 画像として / width・height で縮める / isInteractive(:hover と動き)',
659    toggleable: true,
660    defaultOn: true,
661  },
662  {
663    id: 'Pane/Raster',
664    label: '[Pane/Raster]',
665    kind: 'element',
666    category: 'elements',
667    element: 'Raster',
668    where: 'Raster: 24×6 マスの色のグラデーション。型定義ではターミナルだけの部品。「動かす」で [$.ui.blit] を 2 秒間(10 fps)呼んで色を流す',
669    toggleable: true,
670    defaultOn: true,
671  },
672  {
673    id: 'Pane/Image',
674    label: '[Pane/Image]',
675    kind: 'element',
676    category: 'elements',
677    element: 'Image',
678    where: 'Image: 8×4 ピクセルの RGBA を 8 桁×2 行に描く。型定義ではターミナルだけの部品で、絵を出せない端末では alt の文字が出る。「入れ替える」で [$.ui.blit] が別の色の絵に替える',
679    toggleable: true,
680    defaultOn: true,
681  },
682  {
683    id: 'Pane/Client',
684    label: '[Pane/Client]',
685    kind: 'element',
686    category: 'elements',
687    element: 'Client',
688    where: 'Client: この mod の小さな surface module(hooks/client-echo.js)が描く枠。中の「送る」で surface.post し、[ui.message] のフックが返す props で答えを写す',
689    toggleable: true,
690    defaultOn: true,
691  },
692  {
693    id: '$.ui.blit',
694    label: '[$.ui.blit]',
695    kind: 'api',
696    category: 'elements',
697    where: '描き直しなしで、Raster の cells やキー付きの Image の source を差し替える。見本の「動かす」「入れ替える」から呼ぶ。結果は {}(受け取った)か deny の理由',
698    toggleable: false,
699    defaultOn: true,
700  },
701  {
702    id: 'ui.message',
703    label: '[ui.message]',
704    kind: 'event',
705    category: 'elements',
706    where: '[Pane/Client] の surface module が surface.post で送ったものを受け取るフック。{ props: { reply } } を返して、その Client に答えを渡す',
707    toggleable: false,
708    defaultOn: true,
709    props: {
710      surface: 'Client が描かれている surface',
711      component: 'Client を描いた場所(Pane など)',
712      requestId: 'Client を描いた描画の id',
713      element: 'Client の key',
714      module: 'Client の surface module のパス',
715      data: 'surface module が送ったもの(コードが送った値なので、事実として扱わない)',
716    },
717  },
718  {
719    id: '$.audio.play',
720    label: '[$.audio.play]',
721    kind: 'api',
722    category: 'effects',
723    where: '0.2 秒の小さな音(660 Hz、この mod がその場で作った WAV を base64 で渡す)を 1 回鳴らす。音が出る。終わると結果が出る',
724    toggleable: false,
725    defaultOn: true,
726  },
727  {
728    id: '$.audio.speak',
729    label: '[$.audio.speak]',
730    kind: 'api',
731    category: 'effects',
732    where: 'OS の音声合成で「ユーアイ サンプラー」と読み上げる。音が出る。読み終わると、どの合成器が読んだか(via)が出る',
733    toggleable: false,
734    defaultOn: true,
735  },
736  {
737    id: '$.env.set',
738    label: '[$.env.set]',
739    kind: 'api',
740    category: 'effects',
741    where: 'Claude Code のプロセスの環境変数 UI_SAMPLER_SAMPLE に値を入れ、$.env.get で読み直す。この mod だけが使う名前で、セッションを終えると消える',
742    toggleable: false,
743    defaultOn: true,
744  },
745  {
746    id: '$.env.set/unset',
747    label: '[$.env.set/unset]',
748    kind: 'api',
749    category: 'effects',
750    where: '$.env.set に undefined を渡して UI_SAMPLER_SAMPLE を消し、$.env.get で読み直す',
751    toggleable: false,
752    defaultOn: true,
753  },
754  {
755    id: '$.fs.write',
756    label: '[$.fs.write]',
757    kind: 'api',
758    category: 'effects',
759    where: 'OS の一時フォルダ(環境変数 TMPDIR、TEMP、TMP の順に探す)に ui-sampler-sample.txt を書き、$.fs.read で読み直す。毎回同じファイルを上書きする。一時フォルダが分からなければ書かない。場所は画面に出さない',
760    toggleable: false,
761    defaultOn: true,
762  },
763]
764
765export const KIND_LABEL = { render: '描画', api: 'API', event: 'イベント', element: '部品' }
766
767export function siteOf(id) {
768  const site = SITES.find(one => one.id === id)
769  if (!site) throw new Error('unknown site: ' + id)
770  return site
771}
772
773// ----- Toggles: $.state, session only -----
774// The engine follows $ only into functions of the same file, and reads a state reference only
775// where it is written as literals in the file that uses it. So the files that draw declare
776// `{ plugin: 'ui-sampler', key: 'toggles' }` (a family, one member per site id) and
777// `promptHintMode` themselves and read and write them there; this file keeps the pure part.
778// The pane's view (`view`) and the site each category view details (`selected`, one member per
779// CATEGORIES id) are kept the same way.
780
781/** What a switch's stored value means: unset is the site's default; a fixed site is always on. */
782export function toggleValue(id, value) {
783  const site = siteOf(id)
784  if (!site.toggleable) return true
785  return value ?? site.defaultOn
786}
787
788// ----- Call counts: module variables -----
789// $.state.set is refused while a render hook draws, so the render hooks count here instead.
790// A reload starts the counts over, which is fine for a diagnostic.
791
792const calls = new Map()
793
794/**
795 * Counts one call of a site, with the surface it came from when there is one.
796 * Returns true the first time a site or a surface is seen, so a render hook can ask for one
797 * redraw of the pane without redrawing itself on every call.
798 */
799export function noteCall(id, surface) {
800  let entry = calls.get(id)
801  const isNewSite = !entry
802  if (!entry) {
803    entry = { count: 0, surfaces: new Set() }
804    calls.set(id, entry)
805  }
806  entry.count += 1
807  const isNewSurface = surface !== undefined && !entry.surfaces.has(surface)
808  if (surface !== undefined) entry.surfaces.add(surface)
809  return isNewSite || isNewSurface
810}
811
812/** The pane's line for a site's count: how many calls, and on which surfaces. */
813export function callSummary(id) {
814  const entry = calls.get(id)
815  if (!entry) return 'まだ一度も呼ばれていない'
816  const surfaces = entry.surfaces.size > 0 ? `(${[...entry.surfaces].join(', ')})` : ''
817  return `${entry.count} 回${surfaces}`
818}
819
820/** The category list's short count: `3 回`, `0 回`. */
821export function callCount(id) {
822  return `${calls.get(id)?.count ?? 0} 回`
823}
824
825// ----- Last props seen: module variables -----
826// What a render site last received, for the pane to list field by field. Kept here for the
827// same reason as the counts: no $.state.set while drawing.
828
829const seen = new Map()
830
831/** Keeps a render site's latest surface and props; returns true when they changed. */
832export function noteProps(id, e) {
833  const snapshot = { surface: e.surface, props: e.props }
834  const text = JSON.stringify(snapshot)
835  if (seen.get(id)?.text === text) return false
836  seen.set(id, { text, snapshot })
837  return true
838}
839
840/**
841 * Keeps a transcript row site's latest surface and props without comparing them: many rows
842 * share one site, so "changed" would hold on nearly every call, and a row's output can be
843 * large enough that serializing it on every drawing is wasted work.
844 */
845export function keepProps(id, e) {
846  seen.set(id, { text: undefined, snapshot: { surface: e.surface, props: e.props } })
847}
848
849/**
850 * Keeps an event site's latest input `e` (as the props of a surfaceless snapshot, so the pane
851 * lists it the way it lists a render site's props); returns true when it changed.
852 */
853export function noteEvent(id, e) {
854  return noteProps(id, { surface: undefined, props: e })
855}
856
857/** The last `{ surface, props }` a render site received, or undefined before its first call. */
858export function lastProps(id) {
859  return seen.get(id)?.snapshot
860}
861
862/** Longest value the pane shows in full; a tool's output can run to many thousand characters. */
863const VALUE_LIMIT = 300
864
865/** One value as the pane and the spinner line show it: strings quoted, absent said so. */
866export function formatValue(value) {
867  if (value === undefined) return '(なし)'
868  const text = JSON.stringify(value)
869  if (text.length <= VALUE_LIMIT) return text
870  return `${text.slice(0, VALUE_LIMIT)}…(全 ${text.length} 文字)`
871}
872
873// ----- Last results: module variables -----
874// The line the pane shows under a site after something ran it: a button's call, or a hook
875// on an engine event ([$.ui.notice]'s tool.call). A reload starts them over.
876
877const results = new Map()
878
879/** Keeps the latest result line of a site. */
880export function noteResult(id, text) {
881  results.set(id, text)
882}
883
884/** The latest result line of a site, or undefined when nothing ran it yet. */
885export function resultOf(id) {
886  return results.get(id)
887}
888
hooks/pane.js 837 lines
1// The pane /ui-sampler opens. One pane whose view ($.state `view`, session only) switches what
2// it draws, since a second pane opened from a button may not come to the front:
3//   'toc'          the table of contents: where it is drawn, one row per CATEGORIES entry
4//   a category id  that category's sites as a table, one row apiece (label, on/off switch,
5//                  call count, [詳細], the API's button), an API's last result under its row, and
6//                  below the list one detail card for the site picked with [詳細]: where it shows,
7//                  the choice of what [PromptHint], [Spinner] and [CommandOutput] write, the
8//                  call count by surface, the props a render site (or the input an event site)
9//                  last received, the last result
10//   'samples'      the element samples, drawn by elements.js's hook, which wraps this one
11//   'values'       the 値 category: what a mod can obtain, drawn by values.js's hook, which
12//                  wraps both
13// /ui-sampler sets the view back to 'toc' (register.js), the way back from a view that does
14// not draw. Spacing, columns, colors and button roles come from style.js.
15// Each drawing tells redraw.js what it shows, so the other hooks redraw it only for a change
16// it shows.
17
18import {
19  DIALOG_OPEN,
20  CATEGORIES,
21  SITES,
22  KIND_LABEL,
23  toggleValue,
24  noteCall,
25  callSummary,
26  callCount,
27  lastProps,
28  formatValue,
29  noteResult,
30  resultOf,
31} from './sites.js'
32import {
33  SPACE,
34  WIDTH,
35  BUTTON,
36  choice,
37  dim,
38  inline,
39  cell,
40  fill,
41  tableRow,
42  field,
43  header,
44  section,
45  card,
46  page,
47  stateMark,
48  table,
49} from './style.js'
50import { noteDiag, noteInvalidate, notePaneRender, diagLines, clearDiag, isNoiseKept, toggleNoise } from './diag.js'
51import { REDRAW_GAP_MS, redrawFor, notePaneShows, notePaneClosed } from './redraw.js'
52import { GRACE_MS, guardDrawing, beginPress, hasStarted, takeOver, endPress } from './press-guard.js'
53import { beepWav } from './bytes.js'
54import { atom, read, update } from 'claude-code'
55
56// The state this file reads and writes (declared in types/index.d.ts): the pane's view, one
57// switch per site id, the site each category view details (by category id), which prop
58// [PromptHint], [Spinner] and [CommandOutput] write, and [DialogPane]'s echo lines (which entry
59// opened it)
60const view = atom({ plugin: 'ui-sampler', key: 'view' }, 'toc')
61const TOGGLES = { plugin: 'ui-sampler', key: 'toggles' }
62const SELECTED = { plugin: 'ui-sampler', key: 'selected' }
63const ECHO = { plugin: 'ui-sampler', key: 'echo' }
64const promptHintMode = atom({ plugin: 'ui-sampler', key: 'promptHintMode' }, 'hint')
65const spinnerMode = atom({ plugin: 'ui-sampler', key: 'spinnerMode' }, 'word')
66const commandOutputMode = atom({ plugin: 'ui-sampler', key: 'commandOutputMode' }, 'tree')
67
68/** The ways a site can be drawn, by site id: each choice's stored value and its button text. */
69const MODE_CHOICES = {
70  PromptHint: [
71    { value: 'hint', label: 'hint(行ごと置き換える)' },
72    { value: 'tail', label: 'tail(後ろに足す)' },
73  ],
74  Spinner: [
75    { value: 'word', label: 'word を書き換え' },
76    { value: 'message', label: 'message を書き換え' },
77    { value: 'suffix', label: 'suffix を書き換え' },
78    { value: 'tree', label: '自前のツリー(props を並べる)' },
79    { value: 'props', label: 'word に props を並べる' },
80  ],
81  CommandOutput: [
82    { value: 'tree', label: '自前のツリー' },
83    { value: 'text', label: 'text を書き換え' },
84  ],
85}
86
87/** The one-shot APIs: the text of each one's button, by site id. runAction does the call. */
88const ACTION_BUTTONS = {
89  DialogPane: 'ダイアログ風に開く',
90  '$.ui.status': 'ステータス行を書き換える',
91  '$.ui.status/clear': 'ステータス行を消す',
92  '$.ui.toast': 'トーストを出す',
93  '$.ui.toast/timeoutMs': '10 秒のトーストを出す',
94  '$.ui.log': 'ログ行を出す',
95  '$.ui.log/debug': 'デバッグログに書く',
96  '$.ui.ask': '質問する',
97  '$.ui.copy': 'コピーする',
98  '$.ui.panes': '一覧を取る',
99  '$.ui.scroll': 'いちばん上へ',
100  '$.ui.focus': 'フォーカスを移す',
101  '$.session.append': 'system 行を足す',
102  '$.prompt.suggest': '提案を出す',
103  '$.prompt.fill': '下書きに足す',
104  '$.prompt.read': '下書きを読む',
105  '$.audio.play': '短い音を鳴らす(音が出る)',
106  '$.audio.speak': '読み上げる(音が出る)',
107  '$.env.set': 'UI_SAMPLER_SAMPLE を設定して読み直す',
108  '$.env.set/unset': 'UI_SAMPLER_SAMPLE を消して読み直す',
109  '$.fs.write': '一時フォルダに書いて読み直す',
110}
111
112/** What [$.audio.speak] says. */
113const SPEECH = 'ユーアイ サンプラー'
114/** The file [$.fs.write] writes in the OS temp folder, overwritten on every press. */
115const SAMPLE_FILE = 'ui-sampler-sample.txt'
116
117/**
118 * Under the props of a site whose hook redraws for new props (redraw.js); the transcript rows
119 * never do, and their own note says so.
120 */
121const REDRAW_NOTE = `値が変わると、この [詳細] を開いている間だけパネルを描き直す(${REDRAW_GAP_MS / 1000} 秒に 1 回まで)。最新にするには「回数を更新」`
122
123// A module variable is enough for this: a reload starting it over does no harm
124let statusPresses = 0
125
126// Element keys allow a plain set of characters; site ids carry '$', '.' and '/'
127const keyOf = (prefix, id) => prefix + '-' + id.replace(/[^A-Za-z0-9_-]/g, '_')
128
129// One function rather than a table of closures: the engine follows $ only into functions
130// declared in this file, called by name. `press` is the button's ui.press event.
131async function runAction($, id, press) {
132  switch (id) {
133    // ===== [DialogPane] $.ui.open({ id: 'ui-sampler-dialog', focus, closeOnEscape, holdToasts, rows }) =====
134    // One of three entry points (also the band's button and /ui-sampler-dialog), all with
135    // DIALOG_OPEN. The opener is written before the open so the pane's first drawing shows it.
136    case 'DialogPane': {
137      await update($, { ...ECHO, id: 'DialogPane:openedBy' }, () => `「ダイアログ」の一覧のボタン(ui.press、surface: ${press.surface})`)
138      const opened = await $.ui.open(DIALOG_OPEN)
139      const result = opened.isPlaced ? 'isPlaced: true' : `isPlaced: false、reason: ${opened.reason}`
140      await update($, { ...ECHO, id: 'DialogPane:openResult' }, () => result)
141      return opened.isPlaced ? '開いた' : '開いたがまだ表示されていない: ' + opened.reason
142    }
143
144    // ===== [$.ui.status] $.ui.status(text) =====
145    case '$.ui.status': {
146      statusPresses += 1
147      $.ui.status(`[$.ui.status] ボタンが ${statusPresses} 回押されました`)
148      return `ステータス行を「ボタンが ${statusPresses} 回押されました」にした`
149    }
150
151    // ===== [$.ui.status/clear] $.ui.status(undefined) =====
152    case '$.ui.status/clear': {
153      $.ui.status(undefined)
154      return '消した'
155    }
156
157    // ===== [$.ui.toast] $.ui.toast(text) =====
158    case '$.ui.toast': {
159      $.ui.toast('[$.ui.toast] トーストです(既定の 4 秒で消えます)')
160      return '出した'
161    }
162
163    // ===== [$.ui.toast/timeoutMs] $.ui.toast(text, { timeoutMs: 10000 }) =====
164    case '$.ui.toast/timeoutMs': {
165      $.ui.toast('[$.ui.toast/timeoutMs] 10 秒出るトーストです', { timeoutMs: 10000 })
166      return '出した(timeoutMs: 10000)'
167    }
168
169    // ===== [$.ui.log] $.ui.log(text) =====
170    case '$.ui.log': {
171      $.ui.log('[$.ui.log] 会話欄に出る薄い 1 行です')
172      return '出した'
173    }
174
175    // ===== [$.ui.log/debug] $.ui.log(text, { to: 'debug' }) =====
176    case '$.ui.log/debug': {
177      $.ui.log('[$.ui.log/debug] デバッグログにだけ書いた行です', { to: 'debug' })
178      return "書いた(to: 'debug')"
179    }
180
181    // ===== [$.ui.ask] $.ui.ask(question, options) =====
182    case '$.ui.ask': {
183      try {
184        return '答え: ' + (await $.ui.ask('[$.ui.ask] どの色が好きですか?', ['赤', '青', '緑']))
185      } catch {
186        return '閉じられた(答えなし)'
187      }
188    }
189
190    // ===== [$.ui.copy] $.ui.copy({ text, surface }) =====
191    case '$.ui.copy': {
192      const copied = await $.ui.copy({ text: '[$.ui.copy] コピーした文字です', surface: press.surface })
193      return copied.isCopied
194        ? `isCopied: true(surface: ${press.surface})。どこかに貼り付けて確かめる`
195        : `isCopied: false、reason: ${copied.reason}(surface: ${press.surface})`
196    }
197
198    // ===== [$.ui.panes] $.ui.panes() =====
199    case '$.ui.panes': {
200      const panes = await $.ui.panes()
201      if (panes.length === 0) return '開いているパネルはない'
202      return panes
203        .map(
204          pane =>
205            `${pane.id}「${pane.title}」isShown: ${pane.isShown}, isFocused: ${pane.isFocused}, isPlaced: ${pane.isPlaced}`,
206        )
207        .join(' / ')
208    }
209
210    // ===== [$.ui.scroll] $.ui.scroll({ in: 'ui-sampler', to: 'start' }) =====
211    case '$.ui.scroll': {
212      const scrolled = await $.ui.scroll({ in: 'ui-sampler', to: 'start' })
213      return scrolled.deny ? '動かなかった: ' + scrolled.deny : '動いた(いちばん上へ)'
214    }
215
216    // ===== [$.ui.focus] $.ui.focus({ requestId: 'ui-sampler', key: 'refresh' }) =====
217    case '$.ui.focus': {
218      const focused = await $.ui.focus({ requestId: 'ui-sampler', key: 'refresh' })
219      return focused.deny ? '動かなかった: ' + focused.deny : '動いた(「回数を更新」ボタンへ)'
220    }
221
222    // ===== [$.session.append] $.session.append({ message: { type: 'system' } }) =====
223    case '$.session.append': {
224      try {
225        const result = await $.session.append({
226          message: { type: 'system', content: [{ type: 'text', text: '[$.session.append] system 通知行です' }] },
227        })
228        return result?.deny ? '拒否: ' + result.deny : '成功: ' + JSON.stringify(result)
229      } catch (error) {
230        return 'エラー: ' + String(error?.message ?? error)
231      }
232    }
233
234    // ===== [$.prompt.suggest] $.prompt.suggest({ text }) =====
235    case '$.prompt.suggest': {
236      const suggested = await $.prompt.suggest({ text: '[$.prompt.suggest] ui-sampler の提案です' })
237      return suggested.isShown
238        ? 'isShown: true。プロンプト欄に薄く出ているはず(Tab で取り込める)'
239        : 'isShown: false(欄に文字がある、ターンの実行中、など)'
240    }
241
242    // ===== [$.prompt.fill] $.prompt.fill({ text, mode: 'append' }) =====
243    // Only from this button: it writes into the person's draft. 'append' keeps what they typed.
244    case '$.prompt.fill': {
245      const filled = await $.prompt.fill({ text: '[$.prompt.fill] ui-sampler が足した文字', mode: 'append' })
246      if (!filled.isFilled) return `isFilled: false、refusal: ${filled.refusal ?? '(なし)'}`
247      return `isFilled: true。下書きは ${filled.text.length} 文字、cursor: ${filled.cursor}`
248    }
249
250    // ===== [$.prompt.read] $.prompt.read() =====
251    // The draft is the person's: only its length and the cursor are shown, never its text
252    case '$.prompt.read': {
253      const box = await $.prompt.read()
254      return `下書きは ${box.text.length} 文字、cursor: ${box.cursor}`
255    }
256
257    // ===== [$.audio.play] $.audio.play({ base64, mime: 'audio/wav' }, { gain: 0.5 }) =====
258    // Only from this button: it makes a sound. Resolves when the clip has played.
259    case '$.audio.play': {
260      try {
261        await $.audio.play(beepWav(), { gain: 0.5 })
262        return '鳴らし終えた(0.2 秒の WAV、gain: 0.5)'
263      } catch (error) {
264        return '鳴らせなかった: ' + errorText(error)
265      }
266    }
267
268    // ===== [$.audio.speak] $.audio.speak(text) =====
269    // Only from this button: it makes a sound. Resolves when the utterance has ended.
270    case '$.audio.speak': {
271      try {
272        const spoken = await $.audio.speak(SPEECH)
273        return `読み終えた(via: ${spoken?.via ?? '不明'})`
274      } catch (error) {
275        return '読み上げられなかった: ' + errorText(error)
276      }
277    }
278
279    // ===== [$.env.set] $.env.set('UI_SAMPLER_SAMPLE', value), then $.env.get =====
280    // A name of this mod's own; the names are literals, so `plugin validate` lists them
281    case '$.env.set': {
282      try {
283        const value = 'ui-sampler ' + new Date().toISOString()
284        await $.env.set('UI_SAMPLER_SAMPLE', value)
285        const back = await $.env.get('UI_SAMPLER_SAMPLE')
286        return `UI_SAMPLER_SAMPLE に「${value}」を入れた。読み直した値: ${back === undefined ? '未設定' : `「${back}」`}`
287      } catch (error) {
288        return 'エラー: ' + errorText(error)
289      }
290    }
291
292    // ===== [$.env.set/unset] $.env.set('UI_SAMPLER_SAMPLE', undefined), then $.env.get =====
293    case '$.env.set/unset': {
294      try {
295        await $.env.set('UI_SAMPLER_SAMPLE', undefined)
296        const back = await $.env.get('UI_SAMPLER_SAMPLE')
297        return `UI_SAMPLER_SAMPLE を消した。読み直した値: ${back === undefined ? '未設定' : `「${back}」`}`
298      } catch (error) {
299        return 'エラー: ' + errorText(error)
300      }
301    }
302
303    // ===== [$.fs.write] $.fs.write(<OS temp folder>/ui-sampler-sample.txt, text), then $.fs.read =====
304    // Only under the OS temp folder, found from the environment; the path holds the person's
305    // user name on some systems, so it is never shown, only the file's name
306    case '$.fs.write': {
307      let folder
308      try {
309        folder = (await $.env.get('TMPDIR')) ?? (await $.env.get('TEMP')) ?? (await $.env.get('TMP'))
310        if (!folder) return '一時フォルダが分からない(TMPDIR、TEMP、TMP のどれもない)ので書かなかった'
311        const path = folder.replace(/[\\/]+$/, '') + '/' + SAMPLE_FILE
312        const text = '[$.fs.write] ui-sampler が書いた行 ' + new Date().toISOString()
313        await $.fs.write(path, text)
314        const back = await $.fs.read(path)
315        return `一時フォルダの ${SAMPLE_FILE} に ${text.length} 文字を書いた。読み直した中身が一致: ${back === text}`
316      } catch (error) {
317        // The OS's message can name the path: the folder, with either separator, is replaced
318        // before it is shown
319        let message = errorText(error)
320        if (folder) {
321          const bare = folder.replace(/[\\/]+$/, '')
322          for (const form of [bare, bare.replace(/\\/g, '/'), bare.replace(/\//g, '\\')]) {
323            message = message.split(form).join('<一時フォルダ>')
324          }
325        }
326        return 'エラー: ' + message
327      }
328    }
329
330    default:
331      return '未対応: ' + id
332  }
333}
334
335// Counts the call, and redraws once when a site or surface is new and this pane lists it
336// (redraw.js)
337function noteRender($, id, e) {
338  const isNew = noteCall(id, e.surface)
339  if (!redrawFor(id, { isNew }, Date.now())) return
340  noteInvalidate(`${id} の初回描画(新しい surface)`)
341  $.ui.invalidate('ui.render')
342}
343
344// Switches the pane's view from a press handler (never while drawing), and brings the new
345// view's top into sight
346async function go($, next) {
347  await update($, view, () => next)
348  await $.ui.scroll({ in: 'ui-sampler', to: 'start' })
349}
350
351// Reading the switch while drawing subscribes the pane, so a toggle redraws it
352async function isOn($, id) {
353  return toggleValue(id, await read($, { ...TOGGLES, id }))
354}
355
356// Flips a switch from a press handler (never while drawing). A site whose answer the engine
357// caches has it asked again.
358async function toggle($, id) {
359  await update($, { ...TOGGLES, id }, value => !toggleValue(id, value))
360  switch (id) {
361    case 'command.describe':
362      $.ui.invalidate('command.describe')
363      return
364    case 'config.describe':
365      $.ui.invalidate('config.describe')
366      return
367    default:
368      return
369  }
370}
371
372// The site a category view details; reading it while drawing subscribes the pane
373async function selectedOf($, categoryId) {
374  return read($, { ...SELECTED, id: categoryId })
375}
376
377// Picks the site to detail, or clears the pick when it is pressed again (never while drawing).
378// Cleared is '' ($.state.set takes no undefined), which names no site.
379function select($, categoryId, siteId) {
380  return update($, { ...SELECTED, id: categoryId }, value => (value === siteId ? '' : siteId))
381}
382
383// Reads the current choice of a site in MODE_CHOICES while drawing (subscribes the pane)
384async function modeOf($, id) {
385  switch (id) {
386    case 'PromptHint':
387      return read($, promptHintMode)
388    case 'Spinner':
389      return read($, spinnerMode)
390    case 'CommandOutput':
391      return read($, commandOutputMode)
392    default:
393      return undefined
394  }
395}
396
397// Stores a choice from a press handler (never while drawing)
398function setMode($, id, value) {
399  switch (id) {
400    case 'PromptHint':
401      return update($, promptHintMode, () => value)
402    case 'Spinner':
403      return update($, spinnerMode, () => value)
404    case 'CommandOutput':
405      return update($, commandOutputMode, () => value)
406    default:
407      return undefined
408  }
409}
410
411/**
412 * The props a render site last received, each against its note: `meta` says when and where
413 * they came, `entries` one `{ name, value, note }` per prop, `note` the site's line under them.
414 */
415function propsEntries(site) {
416  const seen = lastProps(site.id)
417  if (!seen) return { meta: 'まだ呼ばれていない', entries: [], note: undefined }
418  const names = [...new Set([...Object.keys(site.props), ...Object.keys(seen.props)])]
419  return {
420    meta: site.kind === 'event' ? '最後の 1 回' : `最後の 1 回、e.surface: ${seen.surface}`,
421    entries: names.map(name => ({
422      name,
423      value: formatValue(seen.props[name]),
424      note: site.props[name] ?? '型定義に説明のない項目',
425    })),
426    note: site.propsNote,
427  }
428}
429
430// ===== [Pane] view 'toc': the table of contents =====
431// `guard` (press-guard.js) keeps this drawing's Button closures for [press/再実行]
432async function drawContents($, e, guard) {
433  // No counts or props in this view, so no hook needs to redraw it (redraw.js)
434  notePaneShows('toc', undefined)
435  const ui = guard.wrap($.ui.resolve(e))
436  const { Text, Button } = ui
437
438  // Where this drawing is
439  const surfaces = await $.session.surfaces()
440  const viewport = e.viewport
441    ? `${e.viewport.columns} 桁 × ${e.viewport.rows} 行(isFullscreen: ${e.viewport.isFullscreen ?? '不明'})`
442    : 'なし(まだ測られていない)'
443
444  // One row per category: name, count, [開く], then the description, which wraps in its column
445  const rows = CATEGORIES.map(category => {
446    const count = SITES.filter(site => site.category === category.id).length
447    return tableRow(
448      ui,
449      'category-' + category.id,
450      [
451        cell(ui, WIDTH.category, [Text({ bold: true, children: [category.label] })]),
452        cell(ui, WIDTH.count, [Text({ dimColor: true, children: [category.isPending || category.hasOwnView ? '' : `${count} 件`] })]),
453        cell(ui, WIDTH.detail, [
454          category.isPending
455            ? Text({ dimColor: true, children: ['未実装'] })
456            : Button({ key: 'open-' + category.id, label: '開く', ...BUTTON.nav, onPress: () => go($, category.id) }),
457        ]),
458        fill(ui, [Text({ dimColor: true, wrap: 'wrap', children: [category.about] })], 'column'),
459      ],
460      'flex-start',
461    )
462  })
463
464  return page(ui, [
465    header(ui, {
466      title: '[Pane] UI 見本市の目次',
467      about: '分類を開くと、その分類で描ける場所と呼べる API が 1 行ずつ並ぶ',
468      nav: [
469        Button({ key: 'close', label: '閉じる', role: 'dismiss', ...BUTTON.nav, onPress: () => $.ui.close({ id: 'ui-sampler' }) }),
470      ],
471    }),
472    section(ui, 'categories', '分類', rows, SPACE.item),
473    section(ui, 'where', 'この描画の場所', [
474      field(ui, 'where-surface', 'e.surface', e.surface),
475      field(ui, 'where-surfaces', '$.session.surfaces()', surfaces.length > 0 ? surfaces.join(', ') : 'なし'),
476      field(ui, 'where-viewport', 'e.viewport', viewport),
477      field(ui, 'where-placement', 'placement', `${e.props.placement}(本体 ${e.props.bodyColumns} 桁)`),
478    ]),
479  ])
480}
481
482// ===== [Pane] view <category id>: one category's sites and the [詳細] of one =====
483async function drawCategory($, e, category, guard) {
484  const sites = SITES.filter(site => site.category === category.id)
485  const ui = guard.wrap($.ui.resolve(e))
486  const { Box, Text, Button } = ui
487  const redraw = () => {
488    noteInvalidate('パネルのボタンから')
489    $.ui.invalidate('ui.render')
490  }
491  const dash = () => Text({ dimColor: true, children: ['—'] })
492
493  // ----- The list: one row per site, columns label · state · count · [詳細] · action -----
494  // The action column is there only when some site of the list has a button to run. Each
495  // site's row and its last result are one table entry, so the gap falls between sites.
496  // Reading each switch and the pick here subscribes the pane, so a press redraws it
497  const picked = await selectedOf($, category.id)
498  // This view lists the category's counts and details `picked` (redraw.js)
499  notePaneShows(category.id, picked)
500  const hasActions = sites.some(site => ACTION_BUTTONS[site.id])
501  const headCells = [
502    cell(ui, WIDTH.label, [Text({ dimColor: true, children: ['項目'] })]),
503    cell(ui, WIDTH.state, [Text({ dimColor: true, children: ['状態'] })]),
504    cell(ui, WIDTH.count, [Text({ dimColor: true, children: ['回数'] })]),
505    cell(ui, WIDTH.detail, []),
506  ]
507  if (hasActions) headCells.push(fill(ui, [Text({ dimColor: true, children: ['操作'] })]))
508  const entries = [tableRow(ui, 'list-head', headCells)]
509  for (const site of sites) {
510    let state = dash()
511    if (site.toggleable) {
512      const isSiteOn = await isOn($, site.id)
513      state = inline(
514        ui,
515        keyOf('state', site.id),
516        [
517          stateMark(ui, isSiteOn),
518          Button({
519            key: keyOf('toggle', site.id),
520            label: isSiteOn ? 'オン' : 'オフ',
521            ...(isSiteOn ? BUTTON.nav : BUTTON.minor),
522            onPress: () => toggle($, site.id),
523          }),
524        ],
525        0,
526        SPACE.mark,
527      )
528    }
529    const cells = [
530      cell(ui, WIDTH.label, [Text({ children: [site.label] })]),
531      cell(ui, WIDTH.state, [state]),
532      cell(ui, WIDTH.count, [Text({ dimColor: true, children: [callCount(site.id)] })]),
533      cell(ui, WIDTH.detail, [
534        Button({
535          key: keyOf('detail', site.id),
536          label: '詳細',
537          ...choice(picked === site.id),
538          onPress: () => select($, category.id, site.id),
539        }),
540      ]),
541    ]
542    const actionLabel = ACTION_BUTTONS[site.id]
543    if (hasActions) {
544      cells.push(
545        fill(ui, [
546          actionLabel
547            ? Button({
548                key: keyOf('run', site.id),
549                label: actionLabel,
550                onPress: async press => {
551                  // A render site's count is its drawings, not the presses that open it
552                  if (site.kind === 'api') noteCall(site.id, press.surface)
553                  noteResult(site.id, await runAction($, site.id, press))
554                  redraw()
555                },
556              })
557            : null,
558        ]),
559      )
560    }
561    const result = resultOf(site.id)
562    entries.push(
563      Box({
564        key: keyOf('entry', site.id),
565        flexDirection: 'column',
566        width: '100%',
567        children: [
568          tableRow(ui, keyOf('line', site.id), cells),
569          result !== undefined
570            ? Box({
571                key: keyOf('result', site.id),
572                paddingLeft: SPACE.indent,
573                children: [Text({ dimColor: true, wrap: 'truncate-end', children: ['結果: ' + result] })],
574              })
575            : null,
576        ],
577      }),
578    )
579  }
580
581  // ----- [詳細] The picked site's detail card -----
582  const site = sites.find(one => one.id === picked)
583  let detail = dim(ui, 'detail-none', '[詳細] を押すと、その項目の場所・受け取った値・結果がここに出る')
584  if (site) {
585    const fields = [field(ui, 'detail-where', '場所', site.where, WIDTH.prop)]
586    const choices = MODE_CHOICES[site.id]
587    if (choices) {
588      const current = await modeOf($, site.id)
589      fields.push(
590        field(
591          ui,
592          'detail-modes',
593          '書き方',
594          choices.map(one =>
595            Button({
596              key: keyOf('mode-' + one.value, site.id),
597              label: one.label,
598              ...choice(one.value === current),
599              onPress: () => setMode($, site.id, one.value),
600            }),
601          ),
602          WIDTH.prop,
603        ),
604      )
605    }
606    fields.push(field(ui, 'detail-calls', '呼び出し', callSummary(site.id), WIDTH.prop))
607    const result = resultOf(site.id)
608    if (result !== undefined) fields.push(field(ui, 'detail-result', '結果', result, WIDTH.prop))
609
610    const blocks = [
611      inline(ui, 'detail-heading', [
612        Text({ bold: true, children: [`${site.label} の詳細`] }),
613        Text({ dimColor: true, children: [KIND_LABEL[site.kind]] }),
614      ]),
615      Box({ key: 'detail-fields', flexDirection: 'column', rowGap: SPACE.row, width: '100%', children: fields }),
616    ]
617    if (site.props) {
618      const props = propsEntries(site)
619      blocks.push(
620        Box({
621          key: 'detail-props',
622          flexDirection: 'column',
623          rowGap: SPACE.row,
624          width: '100%',
625          children: [
626            inline(ui, 'detail-props-heading', [
627              Text({ bold: true, children: [site.kind === 'event' ? '受け取った e' : '受け取った props'] }),
628              Text({ dimColor: true, children: [props.meta] }),
629            ]),
630            ...props.entries.map(entry =>
631              tableRow(
632                ui,
633                keyOf('prop', entry.name),
634                [
635                  cell(ui, WIDTH.prop, [Text({ dimColor: true, children: [entry.name] })]),
636                  fill(
637                    ui,
638                    [
639                      Text({ wrap: 'wrap', children: [entry.value] }),
640                      Text({ dimColor: true, wrap: 'wrap', children: [entry.note] }),
641                    ],
642                    'column',
643                  ),
644                ],
645                'flex-start',
646              ),
647            ),
648            props.note ? dim(ui, 'detail-props-note', props.note) : null,
649            site.category !== 'transcript' ? dim(ui, 'detail-props-redraw', REDRAW_NOTE) : null,
650          ],
651        }),
652      )
653    }
654    detail = card(ui, 'detail', blocks)
655  }
656
657  // The elements category also leads to the samples view; a sample the surface refuses blanks
658  // that view only, and /ui-sampler brings back the contents
659  const nav = [
660    Button({ key: 'contents', label: '目次へ', ...BUTTON.nav, onPress: () => go($, 'toc') }),
661    Button({ key: 'refresh', label: '回数を更新', ...BUTTON.nav, onPress: redraw }),
662  ]
663  if (category.id === 'elements') {
664    nav.push(Button({ key: 'samples', label: '見本を見る', ...BUTTON.main, onPress: () => go($, 'samples') }))
665  }
666
667  return page(ui, [
668    header(ui, {
669      title: '[Pane] ' + category.label,
670      about: category.about,
671      nav,
672      note:
673        category.id === 'elements'
674          ? '見本でパネルが真っ白になったら、/ui-sampler で目次に戻り、ここで [Pane/…] を 1 つずつオフにすると、どれが断られているか分かる'
675          : category.note,
676    }),
677    section(ui, 'list', '一覧', [table(ui, 'list-table', entries)]),
678    section(ui, 'detail-section', '詳細', [detail]),
679    category.id === 'api' ? diagSection(ui, redraw) : null,
680  ])
681}
682
683// ===== [診断/press] the press, focus and redraw log (diag.js), in the API view =====
684// What arrived around a press: ui.press and ui.focus as this mod's hooks saw them, each
685// drawing of this pane with e.props.isFocused, and each $.ui.invalidate this mod made. As of
686// this drawing; [記録を更新] draws it again. The Spinner's redraws and the drawings they bring
687// are left out unless the switch keeps them.
688function diagSection(ui, redraw) {
689  const { Button } = ui
690  const lines = diagLines()
691  const isNoiseOn = isNoiseKept()
692  return section(ui, 'diag', '[診断/press] 押下・フォーカス・再描画の記録(新しい順、100 件まで)', [
693    dim(
694      ui,
695      'diag-about',
696      'press は ui.press フックに届いた押下と next(e) の結果、[press/再実行] は onPress まで届かなかった押下をこの mod が最新の描画で実行し直したこと、focus は ui.focus フック、render はこのパネルの描画、invalidate はこの mod が頼んだ再描画。スピナーが原因の invalidate と render は、「スピナー由来も記録」をオンにしたときだけ残す。表示はこの描画の時点のもので、[記録を更新] で最新になる',
697    ),
698    inline(ui, 'diag-nav', [
699      Button({ key: 'diag-refresh', label: '記録を更新', ...BUTTON.nav, onPress: redraw }),
700      Button({
701        key: 'diag-clear',
702        label: '記録を消す',
703        ...BUTTON.minor,
704        onPress: () => {
705          clearDiag()
706          redraw()
707        },
708      }),
709      inline(
710        ui,
711        'diag-noise',
712        [
713          stateMark(ui, isNoiseOn),
714          Button({
715            key: 'diag-noise-toggle',
716            label: isNoiseOn ? 'スピナー由来も記録: オン' : 'スピナー由来も記録: オフ',
717            ...(isNoiseOn ? BUTTON.nav : BUTTON.minor),
718            onPress: () => {
719              toggleNoise()
720              redraw()
721            },
722          }),
723        ],
724        0,
725        SPACE.mark,
726      ),
727    ]),
728    lines.length > 0
729      ? ui.Box({
730          key: 'diag-lines',
731          flexDirection: 'column',
732          width: '100%',
733          children: lines.map((line, index) => ui.Text({ key: 'diag-line-' + index, wrap: 'wrap', children: [line] })),
734        })
735      : dim(ui, 'diag-empty', 'まだ何も記録されていない'),
736  ])
737}
738
739// What next(e) settled to, as one diag phrase: the value as JSON, or the error's text
740function describeOutcome(outcome) {
741  if (outcome.kind === 'error') return '例外: ' + errorText(outcome.error)
742  return '値: ' + (outcome.value === undefined ? 'undefined' : JSON.stringify(outcome.value))
743}
744
745function errorText(error) {
746  return String(error?.message ?? error)
747}
748
749export function registerPane(on) {
750  // ===== [Pane] ui.render { component: 'Pane', requestId: 'ui-sampler' } =====
751  // The matcher is PANE's value as a literal, so `plugin validate` can list it. The view
752  // 'samples' is answered by elements.js's hook on the same matcher, registered before this
753  // one so it is the outer link; it passes every other view on to here.
754  on('ui.render', { component: 'Pane', requestId: 'ui-sampler' }, async ($, e) => {
755    notePaneRender(e)
756    noteRender($, 'Pane', e)
757    const current = await read($, view)
758    const category = CATEGORIES.find(one => one.id === current && !one.isPending)
759    const guard = guardDrawing(e.requestId)
760    return guard.done(category ? await drawCategory($, e, category, guard) : await drawContents($, e, guard))
761  })
762
763  // ===== [診断/press] [press/再実行] ui.press { plugin: 'ui-sampler' } =====
764  // Watches every press on this mod's elements (both panes and the band), noting what next(e)
765  // resolved to or threw. On a guarded pane (press-guard.js) it also checks that the press
766  // reached the Button's onPress, judged by the wrapper around that onPress rather than by
767  // next(e)'s answer, which says only what the chain settled on. When the chain settled, threw,
768  // or stayed silent for GRACE_MS without the onPress having started, the press is run once
769  // with the closure the latest drawing holds under the same key.
770  on('ui.press', { plugin: 'ui-sampler' }, async ($, e, next) => {
771    noteDiag('press', `${e.element}(${e.component} ${e.requestId}、surface: ${e.surface})`)
772    const record = beginPress(e)
773    const chain = next(e).then(
774      value => ({ kind: 'value', value }),
775      error => ({ kind: 'error', error }),
776    )
777
778    // The grace: a sleep that ends early once the chain settles. A sleep the host refuses
779    // never ends the wait, so the chain alone decides.
780    let outcome
781    if (record) {
782      const timer = new AbortController()
783      const grace = $.clock.sleep(GRACE_MS, { signal: timer.signal }).then(
784        () => ({ kind: 'grace' }),
785        () => new Promise(() => {}),
786      )
787      outcome = await Promise.race([chain, grace])
788      timer.abort()
789      if (outcome.kind === 'grace' && hasStarted(record)) outcome = await chain
790    } else {
791      outcome = await chain
792    }
793
794    if (outcome.kind === 'grace') {
795      noteDiag('press', `${e.element} の next(e) が ${GRACE_MS}ms 返らず、onPress も始まっていない`)
796      chain.then(late => noteDiag('press', `${e.element} の next(e) が後から ${describeOutcome(late)}`))
797    } else {
798      const reached = record ? (hasStarted(record) ? '、onPress まで届いた' : '、onPress は呼ばれていない') : ''
799      noteDiag('press', `${e.element} の next(e) → ${describeOutcome(outcome)}${reached}`)
800    }
801
802    if (!record || hasStarted(record)) {
803      if (record) endPress(e, record)
804      if (outcome.kind === 'error') throw outcome.error
805      return outcome.value
806    }
807    const why = outcome.kind === 'grace' ? 'onPress が始まらない' : 'onPress まで届かなかった'
808    try {
809      await takeOver(record, e, why)
810    } catch (error) {
811      noteDiag('[press/再実行]', `${e.element} の再実行で例外: ${errorText(error)}`)
812    }
813    return { element: e.element }
814  })
815
816  // ===== [Pane] ui.close { id: 'ui-sampler' } =====
817  // Once the pane is closed nothing of it is on screen, so the other hooks stop redrawing it
818  // (redraw.js). Lets the close through unchanged.
819  on('ui.close', { id: 'ui-sampler' }, async ($, e, next) => {
820    const result = await next(e)
821    if (!result?.deny) notePaneClosed()
822    return result
823  })
824
825  // ===== [診断/press] ui.focus { component: 'Pane' } =====
826  // Watches every move of a pane's focus ring (a click, Tab, autoFocus, $.ui.focus) and lets
827  // it through unchanged, noting whether it landed
828  on('ui.focus', { component: 'Pane' }, async ($, e, next) => {
829    const origin = e.origin.kind === 'plugin' ? `plugin ${e.origin.name}` : e.origin.kind
830    const target = e.element ?? '(エンジンの停止位置)'
831    const result = await next(e)
832    const outcome = result?.deny ? `deny: ${result.deny}` : '移った'
833    noteDiag('focus', `${e.requestId} → ${target}(origin: ${origin})${outcome}`)
834    return result
835  })
836}
837
hooks/elements.js 612 lines
1// The samples view of the pane (view 'samples'): one labelled sample per 'element' row of
2// SITES, drawn with the element table of the surface asking. A tree the surface refuses is not
3// drawn at all, so the samples are a view of their own and each has an on/off switch, listed
4// in the elements category's view (pane.js), not here: /ui-sampler goes back to the contents, then
5// switch samples off until this view shows; the last one switched off is the one refused.
6// [Pane/Raster] and [Pane/Image] have buttons that repaint them with [$.ui.blit]; [Pane/Client]
7// runs this mod's surface module (client-echo.js), answered by the [ui.message] hook here.
8
9import { SITES, toggleValue, noteCall, noteEvent, noteResult } from './sites.js'
10import { SPACE, COLOR, BUTTON, dim, field, header, section, page } from './style.js'
11import { noteInvalidate, notePaneRender } from './diag.js'
12import { redrawFor, notePaneShows } from './redraw.js'
13import { guardDrawing } from './press-guard.js'
14import { RASTER, IMAGE, rasterCells, imageSource } from './bytes.js'
15import { atom, read, update } from 'claude-code'
16
17// The state this file reads and writes (declared in types/index.d.ts): the pane's view, one
18// switch per site id, and one echo line per sample (the last press, the typed text, the
19// picked value)
20const view = atom({ plugin: 'ui-sampler', key: 'view' }, 'toc')
21const TOGGLES = { plugin: 'ui-sampler', key: 'toggles' }
22const ECHO = { plugin: 'ui-sampler', key: 'echo' }
23
24const LINK_HREF = 'https://example.com/'
25const PRESSABLE_HREF = 'https://example.com/press'
26
27// Element keys allow a plain set of characters; site ids carry '/'
28const keyOf = (prefix, id) => prefix + '-' + id.replace(/[^A-Za-z0-9_-]/g, '_')
29
30// ----- [$.ui.blit]: module variables -----
31// The blits run from a timer, and a $.state write per frame would redraw the pane the blit is
32// meant to spare. So the phase each picture is at, the running animation and the last reply
33// to the Client are kept here; a redraw draws the pictures at their current phase. Only the
34// start and the end of an animation write $.state (the echo line under the sample).
35
36/** The animation of [Pane/Raster]: one blit every FRAME_MS, FRAMES of them (2 s at 10 fps). */
37const FRAME_MS = 100
38const FRAMES = 20
39
40let rasterPhase = 0
41let imagePhase = 0
42// The running animation: { timer, frames, taken, denied, lastDeny }, or undefined
43let animation
44// What the [ui.message] hook last answered the Client, handed again on each redraw
45let clientReply
46
47/** One line for the blits of an animation: how many, how many taken, the last deny. */
48function blitSummary(run) {
49  const denied = run.denied > 0 ? `、deny が ${run.denied} 回(最後の理由: ${run.lastDeny})` : ''
50  return `$.ui.blit を ${run.frames} 回呼んだ: {} が ${run.taken} 回${denied}`
51}
52
53// Counts the call, and redraws once when a site or surface is new and the main pane lists
54// the site (redraw.js)
55function noteRender($, id, e) {
56  const isNew = noteCall(id, e.surface)
57  if (!redrawFor(id, { isNew }, Date.now())) return
58  noteInvalidate(`${id} の初回描画(新しい surface)`)
59  $.ui.invalidate('ui.render')
60}
61
62// Switches the pane's view from a press handler (never while drawing), and brings the new
63// view's top into sight
64async function go($, next) {
65  await update($, view, () => next)
66  await $.ui.scroll({ in: 'ui-sampler', to: 'start' })
67}
68
69// Reading the switch while drawing subscribes the pane, so a toggle redraws it
70async function isOn($, id) {
71  return toggleValue(id, await read($, { ...TOGGLES, id }))
72}
73
74// Writes a sample's echo line from a handler (never while drawing)
75function echoTo($, id, text) {
76  return update($, { ...ECHO, id }, () => text)
77}
78
79// Reading an echo while drawing subscribes the pane, so a write redraws it
80async function echoOf($, id) {
81  return (await read($, { ...ECHO, id })) ?? 'まだ何もしていない'
82}
83
84// ===== [$.ui.blit] $.ui.blit({ requestId: 'ui-sampler', key: 'raster-sample', cells }) =====
85// One frame of the animation, from its timer: the next phase's cells, counted by the answer
86async function blitFrame($) {
87  const run = animation
88  if (!run) return
89  run.frames += 1
90  rasterPhase = (rasterPhase + 1 / FRAMES) % 1
91  const result = await $.ui.blit({ requestId: 'ui-sampler', key: 'raster-sample', cells: rasterCells(RASTER.columns, RASTER.rows, rasterPhase) })
92  noteCall('$.ui.blit')
93  if (result?.deny) {
94    run.denied += 1
95    run.lastDeny = result.deny
96  } else {
97    run.taken += 1
98  }
99  if (run.frames >= FRAMES) await stopAnimation($, run, `${(FRAMES * FRAME_MS) / 1000} 秒たったので止まった`)
100}
101
102// Starts the animation from 「動かす」 (a press, never while drawing); a second press while it
103// runs does nothing
104async function startAnimation($) {
105  if (animation) return
106  const run = { timer: undefined, frames: 0, taken: 0, denied: 0, lastDeny: undefined }
107  animation = run
108  await echoTo($, 'Pane/Raster', `動かしている(${FRAME_MS} ms ごとに ${FRAMES} 回)`)
109  try {
110    run.timer = $.clock.every(FRAME_MS, () => {
111      blitFrame($).catch(error => stopAnimation($, run, '例外で止まった: ' + String(error?.message ?? error)))
112    })
113  } catch (error) {
114    await stopAnimation($, run, 'タイマーを始められなかった: ' + String(error?.message ?? error))
115  }
116}
117
118// Ends the animation `run` (its last frame, 「止める」, or a failure) and writes its summary
119// once, which redraws the sample at the phase it reached
120async function stopAnimation($, run, why) {
121  if (!run || animation !== run) return
122  animation = undefined
123  run.timer?.cancel()
124  const summary = blitSummary(run)
125  noteResult('$.ui.blit', '[Pane/Raster] ' + summary)
126  await echoTo($, 'Pane/Raster', `${why}。${summary}`)
127}
128
129// ===== [$.ui.blit] $.ui.blit({ requestId: 'ui-sampler', key: 'image-sample', source }) =====
130// 「入れ替える」: the Image's next picture, the gradient half a turn on
131async function swapImage($) {
132  imagePhase = (imagePhase + 0.5) % 1
133  let text
134  try {
135    const result = await $.ui.blit({ requestId: 'ui-sampler', key: 'image-sample', source: imageSource(IMAGE.width, IMAGE.height, imagePhase) })
136    text = result?.deny ? 'deny: ' + result.deny : '{}(受け取った)'
137  } catch (error) {
138    text = 'エラー: ' + String(error?.message ?? error)
139  }
140  noteCall('$.ui.blit')
141  noteResult('$.ui.blit', '[Pane/Image] ' + text)
142  await echoTo($, 'Pane/Image', '入れ替えた結果: ' + text)
143}
144
145// Whether a tree holds an element of the given type. The table a surface hands out may be
146// completed with every element name, an absent one drawing a fragment (a column Box), so a
147// name being in the table is not enough to say the surface draws it.
148function containsType(node, type) {
149  if (!node || typeof node !== 'object') return false
150  if (node.type === type) return true
151  return (node.children ?? []).some(child => containsType(child, type))
152}
153
154const svgBadge = [
155  '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="48" viewBox="0 0 160 48">',
156  '<rect x="1" y="1" width="158" height="46" rx="8" fill="#2b6cb0" stroke="#90cdf4" stroke-width="2"/>',
157  '<circle cx="24" cy="24" r="12" fill="#f6ad55"/>',
158  '<text x="46" y="30" font-family="sans-serif" font-size="16" fill="#ffffff">[Pane/Svg]</text>',
159  '</svg>',
160].join('')
161
162const svgInteractive = [
163  '<svg xmlns="http://www.w3.org/2000/svg" width="160" height="48" viewBox="0 0 160 48">',
164  '<style>.dot { fill: #48bb78 } .dot:hover { fill: #e53e3e }</style>',
165  '<title>[Pane/Svg] isInteractive: tooltip</title>',
166  '<rect x="1" y="1" width="158" height="46" rx="8" fill="#1a202c" stroke="#a0aec0"/>',
167  '<circle class="dot" cx="24" cy="24" r="12">',
168  '<animate attributeName="r" values="8;14;8" dur="2s" repeatCount="indefinite"/>',
169  '</circle>',
170  '<text x="46" y="30" font-family="sans-serif" font-size="14" fill="#ffffff">hover me</text>',
171  '</svg>',
172].join('')
173
174const markdownText = [
175  '### [Pane/Markdown] 見出し',
176  '',
177  '**太字** と *斜体* と `インラインコード`',
178  '',
179  '- 箇条書き 1',
180  '- 箇条書き 2',
181  '',
182  '| 列 A | 列 B |',
183  '| --- | --- |',
184  '| 1 | 2 |',
185  '',
186  '```js',
187  'const answer = 42',
188  '```',
189  '',
190  `[example.com へのリンク](${LINK_HREF})`,
191].join('\n')
192
193const diffSource = [
194  '--- a/greet.js',
195  '+++ b/greet.js',
196  '@@ -1,3 +1,3 @@',
197  ' function greet(name) {',
198  "-  return 'Hello, ' + name",
199  "+  return 'こんにちは、' + name",
200  ' }',
201].join('\n')
202
203/**
204 * The sample of one 'element' row of SITES: a list of nodes drawn under its heading.
205 * One function with a switch rather than a table of closures: the engine follows $ only into
206 * functions declared in this file, called by name.
207 */
208async function drawSample($, table, id) {
209  const { Box, Text } = table
210  const note = text => Text({ dimColor: true, wrap: 'wrap', children: [text] })
211  const row = children =>
212    Box({ flexDirection: 'row', columnGap: SPACE.inline, rowGap: SPACE.control, alignItems: 'center', flexWrap: 'wrap', children })
213  // Lines that belong together (a caption and its code), kept without the gap between parts
214  const group = children => Box({ flexDirection: 'column', width: '100%', children })
215
216  switch (id) {
217    // ===== [Pane/Text] Text { color, backgroundColor, bold, dimColor, italic, underline, strikethrough, inverse, wrap, hover } =====
218    case 'Pane/Text': {
219      return [
220        row([
221          Text({ color: 'success', children: ['color: success'] }),
222          Text({ color: 'warning', children: ['color: warning'] }),
223          Text({ color: '#4f8cc9', children: ['color: #4f8cc9'] }),
224          Text({ backgroundColor: '#553c9a', color: '#ffffff', children: ['backgroundColor'] }),
225        ]),
226        row([
227          Text({ bold: true, children: ['bold'] }),
228          Text({ dimColor: true, children: ['dimColor'] }),
229          Text({ italic: true, children: ['italic'] }),
230          Text({ underline: true, children: ['underline'] }),
231          Text({ strikethrough: true, children: ['strikethrough'] }),
232          Text({ inverse: true, children: ['inverse'] }),
233        ]),
234        Text({
235          children: ['入れ子: ', Text({ bold: true, children: ['太字の中に '] }), Text({ color: 'success', children: ['色'] })],
236        }),
237        Box({
238          width: 24,
239          children: [Text({ wrap: 'truncate-end', children: ['wrap: truncate-end の長い行は幅 24 で切られて末尾が省略される'] })],
240        }),
241        // A Text's hover applies under the nearest keyed Box
242        Box({
243          key: 'text-hover',
244          children: [Text({ hover: { color: 'warning', bold: true }, wrap: 'wrap', children: ['hover: この行にポインタを載せると色が変わる'] })],
245        }),
246      ]
247    }
248
249    // ===== [Pane/Box] Box { borderStyle, borderColor, borderDimColor, backgroundColor, padding, hover, position, display } =====
250    case 'Pane/Box': {
251      return [
252        row([
253          Box({ borderStyle: 'single', paddingX: 1, children: [Text({ children: ['single'] })] }),
254          Box({ borderStyle: 'round', borderColor: 'success', paddingX: 1, children: [Text({ children: ['round + borderColor'] })] }),
255          Box({ borderStyle: 'double', borderDimColor: true, paddingX: 1, children: [Text({ children: ['double + borderDimColor'] })] }),
256          Box({ borderStyle: 'bold', paddingX: 1, children: [Text({ children: ['bold'] })] }),
257        ]),
258        Box({ backgroundColor: '#2c5282', padding: 1, children: [Text({ color: '#ffffff', children: ['backgroundColor + padding: 1'] })] }),
259        // hover restyles the keyed Box itself while the pointer is over it
260        Box({
261          key: 'box-hover',
262          borderStyle: 'round',
263          paddingX: 1,
264          hover: { borderColor: 'warning', backgroundColor: '#744210' },
265          children: [Text({ children: ['hover: 枠と背景の色が変わる'] })],
266        }),
267        // A card drawn display: none, revealed by hover over the glyph, placed absolute so
268        // revealing it moves nothing
269        Box({
270          key: 'box-card',
271          children: [
272            Text({ wrap: 'wrap', children: ['position: absolute: ここにポインタを載せると、上にカードが重なって出る'] }),
273            Box({
274              position: 'absolute',
275              top: -3,
276              left: 4,
277              display: 'none',
278              borderStyle: 'round',
279              backgroundColor: '#1a202c',
280              paddingX: 1,
281              hover: { display: 'flex' },
282              children: [Text({ children: ['[Pane/Box] 重なったカード'] })],
283            }),
284          ],
285        }),
286        // Two Texts in one hover scope: hovering either lights both
287        row([
288          Text({ hover: { scope: 'ui-sampler-scope', inverse: true }, children: ['hover.scope: こちら'] }),
289          Text({ children: ['と'] }),
290          Text({ hover: { scope: 'ui-sampler-scope', inverse: true }, children: ['こちらは一緒に光る'] }),
291        ]),
292      ]
293    }
294
295    // ===== [Pane/Button] Button { variant, plain, hotkey, dimColor, autoFocus, role, hover } =====
296    case 'Pane/Button': {
297      const pressed = label => press => echoTo($, 'Pane/Button', `押したボタン: ${label}(e.surface: ${press.surface})`)
298      return [
299        row([
300          table.Button({ key: 'btn-default', label: '既定', onPress: pressed('既定') }),
301          table.Button({ key: 'btn-primary', label: 'variant: primary', variant: 'primary', onPress: pressed('variant: primary') }),
302          table.Button({ key: 'btn-secondary', label: 'variant: secondary', variant: 'secondary', onPress: pressed('variant: secondary') }),
303          table.Button({ key: 'btn-dim', label: 'dimColor', dimColor: true, onPress: pressed('dimColor') }),
304          table.Button({ key: 'btn-autofocus', label: 'autoFocus', autoFocus: true, onPress: pressed('autoFocus') }),
305        ]),
306        row([
307          table.Button({ key: 'btn-plain', label: 'plain', plain: true, onPress: pressed('plain') }),
308          table.Button({ key: 'btn-plain-hotkey', label: 'plain + hotkey 1', plain: true, hotkey: '1', onPress: pressed('plain + hotkey 1') }),
309          table.Button({ key: 'btn-hotkey', label: 'hotkey w', hotkey: 'w', onPress: pressed('hotkey w') }),
310        ]),
311        // A Button's hover applies under the nearest keyed Box
312        Box({
313          key: 'btn-hover-box',
314          flexDirection: 'row',
315          columnGap: 1,
316          alignItems: 'center',
317          children: [
318            table.Button({ key: 'btn-hover', label: 'hover', hover: { color: 'warning', bold: true }, onPress: pressed('hover') }),
319            note('この行にポインタを載せるとラベルの色が変わる'),
320          ],
321        }),
322        row([
323          table.Button({
324            key: 'btn-dismiss',
325            label: 'role: dismiss(このパネルを閉じる)',
326            role: 'dismiss',
327            onPress: () => $.ui.close({ id: 'ui-sampler' }),
328          }),
329          note('デスクトップでは枠の端に閉じるボタンとして出る、と型定義にある'),
330        ]),
331        note(await echoOf($, 'Pane/Button')),
332      ]
333    }
334
335    // ===== [Pane/Button/action] Button { action: 'app:cycleDiffBase' } =====
336    // An unknown action name refuses the tree, so this has a switch of its own
337    case 'Pane/Button/action': {
338      return [
339        row([
340          table.Button({
341            key: 'btn-action',
342            label: 'action: app:cycleDiffBase',
343            action: 'app:cycleDiffBase',
344            onPress: press => echoTo($, 'Pane/Button/action', `押された(e.surface: ${press.surface})`),
345          }),
346          note('プロンプトでその操作のキーを押しても押される'),
347        ]),
348        note(await echoOf($, 'Pane/Button/action')),
349      ]
350    }
351
352    // ===== [Pane/Input] Input { label, placeholder, submitLabel, value, onInput, onSubmit } =====
353    case 'Pane/Input': {
354      return [
355        group([
356          table.Input({
357            key: 'input-echo',
358            label: '[Pane/Input]',
359            placeholder: 'ここに入力(placeholder)',
360            submitLabel: '写す',
361            onInput: value => echoTo($, 'Pane/Input:change', value),
362            onSubmit: value => echoTo($, 'Pane/Input:submit', value),
363          }),
364          note('入力中(onInput): ' + (await echoOf($, 'Pane/Input:change'))),
365          note('Enter で確定(onSubmit): ' + (await echoOf($, 'Pane/Input:submit'))),
366        ]),
367        table.Input({
368          key: 'input-value',
369          label: 'value あり',
370          value: '最初から入っている文字',
371          onSubmit: value => echoTo($, 'Pane/Input:submit', value),
372        }),
373      ]
374    }
375
376    // ===== [Pane/Select] Select { label, options, value, onSelect } =====
377    case 'Pane/Select': {
378      const picked = await read($, { ...ECHO, id: 'Pane/Select' })
379      return [
380        table.Select({
381          key: 'select-echo',
382          label: '[Pane/Select] 色',
383          options: [
384            { value: 'red', label: '赤' },
385            { value: 'blue', label: '青' },
386            { value: 'green' },
387          ],
388          value: picked ?? 'red',
389          onSelect: value => echoTo($, 'Pane/Select', value),
390        }),
391        note('選んだ値(onSelect): ' + (picked ?? 'まだ選んでいない(value: red)') + '。green は label なしなので値がそのまま出る'),
392      ]
393    }
394
395    // ===== [Pane/Link] Link { href, label, children } =====
396    case 'Pane/Link': {
397      return [
398        group([
399          Text({ children: ['文中の ', table.Link({ href: LINK_HREF, children: ['children のリンク'] }), ' です'] }),
400          Text({ children: [table.Link({ href: LINK_HREF, label: 'label のリンク' })] }),
401          Text({ children: ['どちらもなし: ', table.Link({ href: LINK_HREF })] }),
402          Text({ children: ['localhost: ', table.Link({ href: 'http://localhost:3000/', label: 'http://localhost:3000/' })] }),
403        ]),
404      ]
405    }
406
407    // ===== [Pane/Code] Code { source, language, path, startLine, format, wrap } =====
408    case 'Pane/Code': {
409      return [
410        group([
411          note('language: js + startLine: 10'),
412          table.Code({ source: "function greet(name) {\n  return 'こんにちは、' + name\n}", language: 'js', startLine: 10 }),
413        ]),
414        group([
415          note('path: example.py(language なし、拡張子から推測)'),
416          table.Code({ source: 'def greet(name):\n    return f"こんにちは、{name}"', path: 'example.py' }),
417        ]),
418        group([note("format: 'diff'"), table.Code({ source: diffSource, format: 'diff' })]),
419        group([
420          note("wrap: 'truncate-end'"),
421          table.Code({
422            source: "const message = 'この行は長いので、パネルの幅に収まらなければ末尾が省略されるはずです。' + '続き'.repeat(20)",
423            language: 'js',
424            wrap: 'truncate-end',
425          }),
426        ]),
427      ]
428    }
429
430    // ===== [Pane/Markdown] Markdown { text, dimColor, key, onLinkPress, pressableLinks } =====
431    case 'Pane/Markdown': {
432      return [
433        table.Markdown({ text: markdownText }),
434        table.Markdown({ text: 'dimColor: 全体が *薄く* 出る', dimColor: true }),
435        table.Markdown({
436          key: 'md-press',
437          text: `onLinkPress: [押すと下に写るリンク](${PRESSABLE_HREF}) と [ふつうに開くリンク](${LINK_HREF})`,
438          pressableLinks: [PRESSABLE_HREF],
439          onLinkPress: (link, press) => echoTo($, 'Pane/Markdown', `押されたリンク: ${link.href}(e.surface: ${press.surface})`),
440        }),
441        note(await echoOf($, 'Pane/Markdown')),
442      ]
443    }
444
445    // ===== [Pane/Raster] Raster { key, columns, rows, cells } =====
446    case 'Pane/Raster': {
447      return [
448        table.Raster({
449          key: 'raster-sample',
450          columns: RASTER.columns,
451          rows: RASTER.rows,
452          cells: rasterCells(RASTER.columns, RASTER.rows, rasterPhase),
453        }),
454      ]
455    }
456
457    // ===== [Pane/Image] Image { key, source, columns, rows, alt } =====
458    case 'Pane/Image': {
459      return [
460        table.Image({
461          key: 'image-sample',
462          source: imageSource(IMAGE.width, IMAGE.height, imagePhase),
463          columns: IMAGE.columns,
464          rows: IMAGE.rows,
465          alt: '[Pane/Image] 8×4 ピクセルの色のグラデーション(絵を出せない端末ではこの文字)',
466        }),
467      ]
468    }
469
470    // ===== [Pane/Client] Client { key, module, props } =====
471    // `module` is a literal: the engine reads the surface module off this file's source
472    case 'Pane/Client': {
473      return [
474        table.Client({ key: 'client-echo', module: './client-echo.js', props: { reply: clientReply ?? null } }),
475        note('上の枠の中は、この mod の surface module(hooks/client-echo.js)が描いている'),
476      ]
477    }
478
479    // ===== [Pane/Svg] Svg { source, alt, width, height, isInteractive } =====
480    case 'Pane/Svg': {
481      return [
482        row([
483          table.Svg({ source: svgBadge, alt: '[Pane/Svg] 画像として描いた SVG' }),
484          note('画像として(160×48)'),
485        ]),
486        row([
487          table.Svg({ source: svgBadge, alt: '[Pane/Svg] 縮めた SVG', width: 80, height: 24 }),
488          note('width: 80, height: 24'),
489        ]),
490        row([
491          table.Svg({ source: svgInteractive, alt: '[Pane/Svg] isInteractive の SVG', isInteractive: true }),
492          note('isInteractive: 丸が動き、ポインタで赤くなり、ツールチップが出る'),
493        ]),
494      ]
495    }
496
497    default:
498      return [note('未対応: ' + id)]
499  }
500}
501
502/**
503 * The buttons under a sample that repaints with [$.ui.blit], and its echo line; none for the
504 * other samples. Drawn whether or not the surface draws the element, so a blit's deny there
505 * can be seen too.
506 */
507async function drawControls($, table, id) {
508  const { Box, Text } = table
509  const row = children =>
510    Box({ flexDirection: 'row', columnGap: SPACE.inline, rowGap: SPACE.control, alignItems: 'center', flexWrap: 'wrap', children })
511  switch (id) {
512    case 'Pane/Raster':
513      return [
514        row([
515          table.Button({ key: 'raster-start', label: '動かす', onPress: () => startAnimation($) }),
516          table.Button({ key: 'raster-stop', label: '止める', ...BUTTON.minor, onPress: () => stopAnimation($, animation, '「止める」で止めた') }),
517        ]),
518        Box({ key: 'raster-echo', width: '100%', children: [Text({ dimColor: true, wrap: 'wrap', children: [await echoOf($, 'Pane/Raster')] })] }),
519      ]
520    case 'Pane/Image':
521      return [
522        row([table.Button({ key: 'image-swap', label: '入れ替える', onPress: () => swapImage($) })]),
523        Box({ key: 'image-echo', width: '100%', children: [Text({ dimColor: true, wrap: 'wrap', children: [await echoOf($, 'Pane/Image')] })] }),
524      ]
525    default:
526      return []
527  }
528}
529
530export function registerElements(on) {
531  // ===== [Pane/samples] ui.render { component: 'Pane', requestId: 'ui-sampler' }, view 'samples' =====
532  // The same matcher as [Pane] (pane.js); registered before it, so this is the outer link and
533  // passes every other view on with next(e)
534  on('ui.render', { component: 'Pane', requestId: 'ui-sampler' }, async ($, e, next) => {
535    if ((await read($, view)) !== 'samples') return next(e)
536    notePaneRender(e)
537    noteRender($, 'Pane', e)
538    // No counts or props in this view, so no hook needs to redraw it (redraw.js)
539    notePaneShows('samples', undefined)
540    // The guard keeps this drawing's Button closures for [press/再実行] (press-guard.js)
541    const guard = guardDrawing(e.requestId)
542    const table = guard.wrap($.ui.resolve(e))
543    const { Box, Text, Button } = table
544
545    // Each sample as a section: its label, what it shows, then its parts a blank row apart; a
546    // sample switched off says so. The switches are in the elements category's view, which
547    // draws whatever this view does
548    const sections = []
549    for (const site of SITES) {
550      if (site.kind !== 'element') continue
551      let body
552      if (!(await isOn($, site.id))) {
553        body = [dim(table, keyOf('off', site.id), 'オフにしてあるので描いていない(「部品」の一覧で切り替える)')]
554      } else if (typeof table[site.element] !== 'function') {
555        body = [
556          Text({ color: COLOR.warn, children: [`${site.label} この surface にはない(表に ${site.element} がない)`] }),
557          ...(await drawControls($, table, site.id)),
558        ]
559      } else {
560        const sample = await drawSample($, table, site.id)
561        if (sample.some(node => containsType(node, site.element))) {
562          noteCall(site.id, e.surface)
563          body = [...sample, ...(await drawControls($, table, site.id))]
564        } else {
565          body = [
566            Text({ color: COLOR.warn, children: [`${site.label} この surface にはない(表の ${site.element} が別の要素を返した)`] }),
567            ...(await drawControls($, table, site.id)),
568          ]
569        }
570      }
571
572      sections.push(
573        section(table, keyOf('sample', site.id), site.label, [
574          dim(table, keyOf('where', site.id), site.where),
575          Box({ flexDirection: 'column', rowGap: SPACE.item, marginTop: 1, width: '100%', children: body }),
576        ]),
577      )
578    }
579
580    return guard.done(page(table, [
581      header(table, {
582        title: '[Pane/samples] 部品の見本',
583        about: 'パネルに置ける部品を 1 種類ずつ描いた見本。オン・オフは「部品」の一覧で切り替える',
584        nav: [
585          Button({ key: 'contents', label: '目次へ', ...BUTTON.nav, onPress: () => go($, 'toc') }),
586          Button({ key: 'switches', label: '部品の一覧へ(オン・オフ)', ...BUTTON.nav, onPress: () => go($, 'elements') }),
587        ],
588      }),
589      ...sections,
590      section(table, 'where', 'この描画の場所', [
591        field(table, 'where-surface', 'e.surface', e.surface),
592        field(table, 'where-table', '$.ui.resolve(e) の表', Object.keys(table).join(', ')),
593      ]),
594    ]))
595  })
596
597  // ===== [ui.message] on('ui.message'): what [Pane/Client]'s surface module posts =====
598  // Answers the posting Client its next props (the reply), with no redraw; the reply is also
599  // kept for the samples view's next drawing. The data came from code, so only a number is
600  // taken from it.
601  on('ui.message', async ($, e, next) => {
602    noteCall('ui.message', e.surface)
603    noteEvent('ui.message', e)
604    const result = await next(e)
605    if (e.element !== 'client-echo') return result
606    const count = typeof e.data?.count === 'number' ? e.data.count : '(数でない値)'
607    clientReply = `${count} 回目を受け取った(surface: ${e.surface}、component: ${e.component})`
608    noteResult('ui.message', clientReply)
609    return { ...result, props: { reply: clientReply } }
610  })
611}
612
hooks/dialog.js 102 lines
1// The dialog-style pane: opened with $.ui.open's dialog options (focus, closeOnEscape,
2// holdToasts, rows) from three entry points (the main pane's dialogs view button, the [AbovePrompt] band's
3// button, /ui-sampler-dialog). Its contents say which entry opened it and where the surface
4// seated it, what each option asks for, and give something to try it with: an Input that
5// should hold the keys, a toast button for holdToasts, and a close button.
6
7import { DIALOG_PANE, noteCall } from './sites.js'
8import { BUTTON, dim, inline, field, header, section, page } from './style.js'
9import { noteInvalidate } from './diag.js'
10import { redrawFor } from './redraw.js'
11import { guardDrawing } from './press-guard.js'
12import { read, update } from 'claude-code'
13
14// The state this file reads and writes (declared in types/index.d.ts): its echo lines
15const ECHO = { plugin: 'ui-sampler', key: 'echo' }
16
17// Counts the call, and redraws once when a site or surface is new and the main pane lists
18// the site (redraw.js)
19function noteRender($, id, e) {
20  const isNew = noteCall(id, e.surface)
21  if (!redrawFor(id, { isNew }, Date.now())) return
22  noteInvalidate(`${id} の初回描画(新しい surface)`)
23  $.ui.invalidate('ui.render')
24}
25
26// Writes one of this pane's echo lines from a handler (never while drawing)
27function echoTo($, id, text) {
28  return update($, { ...ECHO, id }, () => text)
29}
30
31// Reading an echo while drawing subscribes the pane, so a write redraws it
32async function echoOf($, id) {
33  return (await read($, { ...ECHO, id })) ?? 'まだ何もしていない'
34}
35
36export function registerDialog(on) {
37  // ===== [DialogPane] ui.render { component: 'Pane', requestId: 'ui-sampler-dialog' } =====
38  // The matcher is DIALOG_PANE's value as a literal, so `plugin validate` can list it
39  on('ui.render', { component: 'Pane', requestId: 'ui-sampler-dialog' }, async ($, e) => {
40    noteRender($, 'DialogPane', e)
41    // The guard keeps this drawing's Button closures for [press/再実行] (press-guard.js)
42    const guard = guardDrawing(e.requestId)
43    const ui = guard.wrap($.ui.resolve(e))
44    const { Button, Input } = ui
45    const viewport = e.viewport
46      ? `${e.viewport.columns} 桁 × ${e.viewport.rows} 行(isFullscreen: ${e.viewport.isFullscreen ?? '不明'})`
47      : 'なし(まだ測られていない)'
48
49    return guard.done(page(ui, [
50      header(ui, {
51        title: '[DialogPane] ダイアログ風のパネル',
52        about: '$.ui.open のダイアログ向けのオプションを付けて開いたパネル。どこから開き、どこに置かれたかを示す',
53        nav: [Button({ key: 'dialog-close', label: '閉じる', ...BUTTON.nav, onPress: () => $.ui.close({ id: DIALOG_PANE }) })],
54      }),
55      // Which entry opened it, and where the surface seated it
56      section(ui, 'opened', '開いたところ', [
57        field(ui, 'opened-by', '開いた入口', await echoOf($, 'DialogPane:openedBy')),
58        field(ui, 'opened-result', '$.ui.open の結果', await echoOf($, 'DialogPane:openResult')),
59        field(ui, 'opened-placement', 'e.props.placement', e.props.placement),
60        field(ui, 'opened-title', 'e.props.title', String(e.props.title)),
61        field(ui, 'opened-focused', 'e.props.isFocused', String(e.props.isFocused)),
62        field(ui, 'opened-columns', 'e.props.bodyColumns', String(e.props.bodyColumns)),
63        field(ui, 'opened-surface', 'e.surface', e.surface),
64        field(ui, 'opened-viewport', 'e.viewport', viewport),
65        dim(ui, 'opened-note', 'placement は dock(本体の横)か inline(プロンプト欄の上)のどちらか。型定義には、ダイアログとして重ねて出すよう頼むオプションはない'),
66      ]),
67      section(ui, 'options', '頼んだオプション', [
68        field(ui, 'option-focus', 'focus: true', '開いたときにキー入力をこのパネルへ移すよう頼む(プロンプト欄が空のときだけ)'),
69        field(ui, 'option-escape', 'closeOnEscape: true', 'キー入力を持っている間に Esc を押すと閉じる'),
70        field(ui, 'option-toasts', 'holdToasts: true', 'このパネルが出ている間はトーストを止めておき、閉じてから出す'),
71        field(ui, 'option-rows', 'rows: 12', 'プロンプト欄の上に出るとき、本文に 12 行ほしいと頼む(横に出るときは使われない)'),
72      ]),
73      section(ui, 'try', '試す', [
74        Input({
75          key: 'dialog-input',
76          label: '[DialogPane] 入力',
77          placeholder: '開いてすぐ打てるか試す',
78          submitLabel: '写す',
79          onSubmit: value => echoTo($, 'DialogPane:submit', value),
80        }),
81        field(ui, 'try-submit', 'Enter で確定', await echoOf($, 'DialogPane:submit')),
82        inline(
83          ui,
84          'try-buttons',
85          [
86            Button({
87              key: 'dialog-toast',
88              label: 'トーストを出す(holdToasts)',
89              onPress: () => {
90                $.ui.toast('[DialogPane] holdToasts の間に出したトーストです')
91                return echoTo($, 'DialogPane:toast', 'トーストを出した。閉じる前に見えたかどうかを確かめる')
92              },
93            }),
94          ],
95          1,
96        ),
97        field(ui, 'try-toast', 'トースト', await echoOf($, 'DialogPane:toast')),
98      ]),
99    ]))
100  })
101}
102
hooks/engine-lines.js 205 lines
1// The engine's own lines this mod rewrites or redraws while it is loaded, and the band above
2// the prompt it adds to. Each hook counts its call and keeps the props it received first (so
3// the pane can tell "not raised" from "raised but not drawn", and list what came in), then
4// passes `next(e)` on unchanged when its site is switched off in the pane.
5
6import { DIALOG_OPEN, noteCall, noteProps, formatValue, toggleValue } from './sites.js'
7import { SPACE, BUTTON, dim, inline } from './style.js'
8import { noteInvalidate } from './diag.js'
9import { redrawFor } from './redraw.js'
10import { atom, read, update } from 'claude-code'
11
12// The state this file reads (declared in types/index.d.ts): one switch per site id, which
13// prop [PromptHint], [Spinner] and [CommandOutput] write, and the band's and [DialogPane]'s echo
14// lines
15const TOGGLES = { plugin: 'ui-sampler', key: 'toggles' }
16const ECHO = { plugin: 'ui-sampler', key: 'echo' }
17const promptHintMode = atom({ plugin: 'ui-sampler', key: 'promptHintMode' }, 'hint')
18const spinnerMode = atom({ plugin: 'ui-sampler', key: 'spinnerMode' }, 'word')
19const commandOutputMode = atom({ plugin: 'ui-sampler', key: 'commandOutputMode' }, 'tree')
20
21// Counts the call and keeps the props; redraws the pane only when it shows what changed
22// (redraw.js). The Spinner's props can change many times in a turn, so its
23// redraws are marked as noise for the diag log.
24function noteRender($, id, e) {
25  const isNew = noteCall(id, e.surface)
26  const isChanged = noteProps(id, e)
27  const why = redrawFor(id, { isNew, isChanged }, Date.now())
28  if (!why) return
29  noteInvalidate(`${id} の${why === 'first' ? '初回描画' : ' props が変わった'}`, id === 'Spinner')
30  $.ui.invalidate('ui.render')
31}
32
33// Reading the switch while drawing subscribes the hook, so a press in the pane redraws it
34async function isOn($, id) {
35  return toggleValue(id, await read($, { ...TOGGLES, id }))
36}
37
38// Writes one of the band's echo lines from a handler (never while drawing)
39function echoTo($, id, text) {
40  return update($, { ...ECHO, id }, () => text)
41}
42
43// Reading an echo while drawing subscribes the band, so a write redraws it
44async function echoOf($, id) {
45  return (await read($, { ...ECHO, id })) ?? 'まだ何もしていない'
46}
47
48// ===== [DialogPane] $.ui.open({ id: 'ui-sampler-dialog', ... }) from the band =====
49// One of three entry points (also the main pane's dialogs view button and /ui-sampler-dialog), all with
50// DIALOG_OPEN. The opener is written before the open so the pane's first drawing shows it.
51async function openDialogFromBand($, press) {
52  await echoTo($, 'DialogPane:openedBy', `[AbovePrompt] の帯のボタン(ui.press、surface: ${press.surface})`)
53  const opened = await $.ui.open(DIALOG_OPEN)
54  const result = opened.isPlaced ? 'isPlaced: true' : `isPlaced: false、reason: ${opened.reason}`
55  await echoTo($, 'DialogPane:openResult', result)
56  return echoTo($, 'AbovePrompt:press', `[DialogPane] を開いた(${result})`)
57}
58
59// `name=value` for every prop received, in the order they came
60const propPairs = props =>
61  Object.entries(props)
62    .map(([name, value]) => `${name}=${formatValue(value)}`)
63    .join(' ')
64
65export function registerEngineLines(on) {
66  // ===== [Spinner] ui.render { component: 'Spinner' } =====
67  // The line that animates while a turn runs. The pane chooses what to write: `word`,
68  // `message` or `suffix`; a tree of its own that lists the props received; or that list
69  // written into `word`.
70  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
71    noteRender($, 'Spinner', e)
72    if (!(await isOn($, 'Spinner'))) return next(e)
73    const mode = await read($, spinnerMode)
74    switch (mode) {
75      case 'message':
76        return next({ ...e, props: { ...e.props, message: '[Spinner] message を書き換え' } })
77      case 'suffix':
78        return next({ ...e, props: { ...e.props, suffix: ' [Spinner] suffix を書き換え' } })
79      case 'tree': {
80        const { Text } = $.ui.resolve(e)
81        return Text({
82          color: 'warning',
83          wrap: 'wrap',
84          children: ['[Spinner] 自前のツリー: ' + propPairs(e.props)],
85        })
86      }
87      case 'props':
88        return next({ ...e, props: { ...e.props, word: '[Spinner] ' + propPairs(e.props) } })
89      default:
90        return next({ ...e, props: { ...e.props, word: '[Spinner] word を書き換え' } })
91    }
92  })
93
94  // ===== [SessionMode] ui.render { component: 'SessionMode' } =====
95  // The mode labels in the prompt footer. A tree of our own in their place, which still
96  // shows the labels the engine handed over.
97  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
98    noteRender($, 'SessionMode', e)
99    if (!(await isOn($, 'SessionMode'))) return next(e)
100    const { Text } = $.ui.resolve(e)
101    const original = e.props.modes.length > 0 ? e.props.modes.join(' & ') : 'なし'
102    return Text({ color: 'warning', children: [`[SessionMode] モード表示を描き替え(元: ${original})`] })
103  })
104
105  // ===== [PromptHint] ui.render { component: 'PromptHint' } =====
106  // The dim hint line under the prompt. The pane chooses which prop to write:
107  // `hint` replaces the whole line, `tail` adds text after the engine's own line.
108  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
109    noteRender($, 'PromptHint', e)
110    if (!(await isOn($, 'PromptHint'))) return next(e)
111    const mode = await read($, promptHintMode)
112    if (mode === 'tail') {
113      return next({ ...e, props: { ...e.props, tail: '[PromptHint] tail に足した文字' } })
114    }
115    return next({ ...e, props: { ...e.props, hint: `[PromptHint] hint を書き換え(元: ${e.props.hint})` } })
116  })
117
118  // ===== [AbovePrompt] ui.render { component: 'AbovePrompt' } =====
119  // The band above the prompt. Other mods draw here too, so this asks `next(e)` for theirs
120  // first and puts its own labelled row above it rather than replacing it; while a survey
121  // holds the band it passes.
122  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123    noteRender($, 'AbovePrompt', e)
124    const inner = await next(e)
125    if (e.props.hasSurvey || !(await isOn($, 'AbovePrompt'))) return inner
126    const ui = $.ui.resolve(e)
127    const { Box, Text, Button, Input } = ui
128    // One row: the label, the two buttons, the input, then the echoes cut to what is left of
129    // the width; it wraps only when the band is too narrow for the controls
130    const ours = Box({
131      paddingX: SPACE.page,
132      flexDirection: 'row',
133      columnGap: SPACE.column,
134      rowGap: SPACE.control,
135      alignItems: 'center',
136      flexWrap: 'wrap',
137      width: '100%',
138      children: [
139        Text({ bold: true, children: ['[AbovePrompt]'] }),
140        inline(ui, 'band-buttons', [
141          Button({
142            key: 'band-press',
143            label: '帯のボタン',
144            ...BUTTON.nav,
145            onPress: press => echoTo($, 'AbovePrompt:press', `押された(e.surface: ${press.surface})`),
146          }),
147          Button({
148            key: 'band-dialog',
149            label: '[DialogPane] を開く',
150            ...BUTTON.nav,
151            onPress: press => openDialogFromBand($, press),
152          }),
153        ]),
154        Input({
155          key: 'band-input',
156          label: '帯の入力',
157          placeholder: 'ここに入力して Enter',
158          submitLabel: '写す',
159          onSubmit: value => echoTo($, 'AbovePrompt:submit', value),
160        }),
161        Box({
162          flexGrow: 1,
163          flexShrink: 1,
164          children: [
165            Text({
166              dimColor: true,
167              wrap: 'truncate-end',
168              children: [`ボタン: ${await echoOf($, 'AbovePrompt:press')} / 入力: ${await echoOf($, 'AbovePrompt:submit')}`],
169            }),
170          ],
171        }),
172      ],
173    })
174    if (!inner) return ours
175    return Box({
176      flexDirection: 'column',
177      children: [ours, dim(ui, 'band-others', '[AbovePrompt] この下はほかの mod の帯(next(e) が返したもの)'), inner],
178    })
179  })
180
181  // ===== [CommandOutput] ui.render { component: 'CommandOutput', props: { command: 'ui-sampler' } } =====
182  // The row /ui-sampler leaves in the transcript. command.run answered text starting with
183  // [command.run]. The pane chooses: a tree that puts [CommandOutput] in front of that text,
184  // or a rewrite of `text` the engine draws in its own row. Switched off, the engine draws
185  // the plain [command.run] text alone.
186  // The matcher is COMMAND's value as a literal, so `plugin validate` can list it
187  on('ui.render', { component: 'CommandOutput', props: { command: 'ui-sampler' } }, async ($, e, next) => {
188    noteRender($, 'CommandOutput', e)
189    if (!(await isOn($, 'CommandOutput'))) return next(e)
190    const mode = await read($, commandOutputMode)
191    if (mode === 'text') {
192      return next({ ...e, props: { ...e.props, text: `[CommandOutput] text を書き換え(元: ${e.props.text})` } })
193    }
194    const { Box, Text } = $.ui.resolve(e)
195    return Box({
196      flexDirection: 'row',
197      columnGap: 1,
198      children: [
199        Text({ color: 'success', bold: true, children: ['[CommandOutput] 自前のツリー'] }),
200        Text({ wrap: 'wrap', children: [e.props.text] }),
201      ],
202    })
203  })
204}
205
hooks/transcript.js 143 lines
1// The transcript's rows: the person's and Claude's messages, tool calls, their results and the
2// folded runs of calls, plus the lines the type declarations say only the terminal raises.
3// A row site switched on keeps the engine's row (what `next(e)` returns) and puts one labelled
4// line above it; switched off it passes `next(e)` on unchanged. The terminal-only sites only
5// count their calls and keep their props.
6//
7// Many rows share one site, so these hooks redraw (the pane's counts) only when a site or a
8// surface is new and the pane lists it, never because the props changed: with several rows
9// each would take its turn as "changed", and every redraw draws every row again.
10
11import { noteCall, keepProps, toggleValue } from './sites.js'
12import { noteInvalidate } from './diag.js'
13import { redrawFor } from './redraw.js'
14import { read } from 'claude-code'
15
16// The state this file reads (declared in types/index.d.ts): one switch per site id
17const TOGGLES = { plugin: 'ui-sampler', key: 'toggles' }
18
19// Counts the call and keeps the props; redraws once when the site or the surface is new and
20// the pane lists the site (redraw.js)
21function noteRow($, id, e) {
22  keepProps(id, e)
23  const isNew = noteCall(id, e.surface)
24  if (!redrawFor(id, { isNew }, Date.now())) return
25  noteInvalidate(`${id} の初回描画`)
26  $.ui.invalidate('ui.render')
27}
28
29// Reading the switch while drawing subscribes the row, so a press in the pane redraws it
30async function isOn($, id) {
31  return toggleValue(id, await read($, { ...TOGGLES, id }))
32}
33
34// The engine's row with the labelled lines above it
35function labelled($, e, inner, lines) {
36  const { Box, Text } = $.ui.resolve(e)
37  return Box({
38    flexDirection: 'column',
39    children: [
40      ...lines.map((text, index) => Text({ key: 'label' + index, color: 'warning', wrap: 'wrap', children: [text] })),
41      inner,
42    ],
43  })
44}
45
46/** A tool call's name list for a label: `Read, Read, Bash`, cut after a few. */
47function toolNames(calls) {
48  const names = calls.slice(0, 5).map(call => call.tool)
49  return names.join(', ') + (calls.length > 5 ? ` ほか ${calls.length - 5} 件` : '')
50}
51
52export function registerTranscript(on) {
53  // ===== [UserMessage] ui.render { component: 'UserMessage' } =====
54  // The person's prompt, a background task's notification, a message another agent or session
55  // sent. The label says which, by origin.kind.
56  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
57    noteRow($, 'UserMessage', e)
58    if (!(await isOn($, 'UserMessage'))) return next(e)
59    const inner = await next(e)
60    const { origin, isExpanded } = e.props
61    return labelled($, e, inner, [
62      `[UserMessage] ui-sampler が足した行(origin.kind: ${origin?.kind ?? '(なし)'}、isExpanded: ${isExpanded})`,
63    ])
64  })
65
66  // ===== [AssistantMessage] ui.render { component: 'AssistantMessage' } =====
67  // One text block of a reply; a reply of several blocks gets a label above each
68  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
69    noteRow($, 'AssistantMessage', e)
70    if (!(await isOn($, 'AssistantMessage'))) return next(e)
71    const inner = await next(e)
72    return labelled($, e, inner, [
73      `[AssistantMessage] ui-sampler が足した行(isFirstOfReply: ${e.props.isFirstOfReply}、${e.props.text.length} 文字)`,
74    ])
75  })
76
77  // ===== [ToolUse] ui.render { component: 'ToolUse' } =====
78  // A tool call's row; also each row an expanded [ToolGroup] unfolds into
79  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
80    noteRow($, 'ToolUse', e)
81    if (!(await isOn($, 'ToolUse'))) return next(e)
82    const inner = await next(e)
83    const { tool, isRunning, isErrored, isInterrupted } = e.props
84    return labelled($, e, inner, [
85      `[ToolUse] ui-sampler が足した行(tool: ${tool}、isRunning: ${isRunning}、isErrored: ${isErrored}、isInterrupted: ${isInterrupted})`,
86    ])
87  })
88
89  // ===== [ToolResult] ui.render { component: 'ToolResult' } =====
90  // The result block under a standalone tool row
91  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
92    noteRow($, 'ToolResult', e)
93    if (!(await isOn($, 'ToolResult'))) return next(e)
94    const inner = await next(e)
95    return labelled($, e, inner, [
96      `[ToolResult] ui-sampler が足した行(tool: ${e.props.tool}、isErrored: ${e.props.isErrored})`,
97    ])
98  })
99
100  // ===== [ToolGroup] [ToolGroup/isExpanded] ui.render { component: 'ToolGroup' } =====
101  // The one-line count of a run of calls. Two switches: [ToolGroup] labels the row;
102  // [ToolGroup/isExpanded] hands next(e) isExpanded: true, the one prop whose rewrite the
103  // type declarations say changes the screen, so the run unfolds into [ToolUse] rows.
104  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
105    noteRow($, 'ToolGroup', e)
106    const isLabelOn = await isOn($, 'ToolGroup')
107    const isExpandOn = await isOn($, 'ToolGroup/isExpanded')
108    if (!isLabelOn && !isExpandOn) return next(e)
109    if (isExpandOn) noteCall('ToolGroup/isExpanded', e.surface)
110    const inner = await next(isExpandOn ? { ...e, props: { ...e.props, isExpanded: true } } : e)
111    const { calls, isActive, isExpanded } = e.props
112    const lines = []
113    if (isLabelOn) {
114      lines.push(
115        `[ToolGroup] ui-sampler が足した行(calls: ${calls.length} 件 ${toolNames(calls)}、isActive: ${isActive}、isExpanded: ${isExpanded})`,
116      )
117    }
118    if (isExpandOn) lines.push(`[ToolGroup/isExpanded] isExpanded を true にして next(e) に渡した(元: ${isExpanded})`)
119    return labelled($, e, inner, lines)
120  })
121
122  // ===== [ToolProgress] ui.render { component: 'ToolProgress' } =====
123  // Declared terminal-only: counted, props kept, drawn as the engine draws it
124  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
125    noteRow($, 'ToolProgress', e)
126    return next(e)
127  })
128
129  // ===== [TurnDuration] ui.render { component: 'TurnDuration' } =====
130  // Declared terminal-only: counted, props kept, drawn as the engine draws it
131  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
132    noteRow($, 'TurnDuration', e)
133    return next(e)
134  })
135
136  // ===== [InfoNotice] ui.render { component: 'InfoNotice' } =====
137  // Declared terminal-only: counted, props kept, drawn as the engine draws it
138  on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
139    noteRow($, 'InfoNotice', e)
140    return next(e)
141  })
142}
143
hooks/asks.js 90 lines
1// The two dialogs that ask the person. The AskUserQuestion dialog is a render site:
2// [AskUserQuestion] marks its question text and lets the engine draw it. The permission
3// dialog is drawn by the engine alone (its answer authorises an action); a plugin only adds
4// a line under it, which [$.ui.notice] does from a tool.call hook on Bash.
5
6import { noteCall, noteProps, noteResult, toggleValue } from './sites.js'
7import { noteInvalidate } from './diag.js'
8import { redrawFor, paneLists } from './redraw.js'
9import { read } from 'claude-code'
10
11// The state this file reads (declared in types/index.d.ts): one switch per site id
12const TOGGLES = { plugin: 'ui-sampler', key: 'toggles' }
13
14// Counts the call and keeps the props; redraws the pane only when it shows what changed
15// (redraw.js)
16function noteRender($, id, e) {
17  const isNew = noteCall(id, e.surface)
18  const isChanged = noteProps(id, e)
19  const why = redrawFor(id, { isNew, isChanged }, Date.now())
20  if (!why) return
21  noteInvalidate(`${id} の${why === 'first' ? '初回描画' : ' props が変わった'}`)
22  $.ui.invalidate('ui.render')
23}
24
25// Redraws the pane for a new result line, when the pane lists the site
26function redrawResult($, id) {
27  if (!paneLists(id)) return
28  noteInvalidate(`${id} の結果が変わった`)
29  $.ui.invalidate('ui.render')
30}
31
32// Reading the switch while drawing subscribes the dialog, so a press in the pane redraws it
33async function isOn($, id) {
34  return toggleValue(id, await read($, { ...TOGGLES, id }))
35}
36
37/**
38 * One entry of the tool's `questions` with its question text marked. The label goes on the
39 * text, not on `header`: the tool's schema caps `header` at 12 characters, and a rewrite that
40 * breaks the schema is drawn as the original.
41 */
42function markQuestion(question) {
43  if (question === null || typeof question !== 'object' || typeof question.question !== 'string') return question
44  return { ...question, question: '[AskUserQuestion] ' + question.question }
45}
46
47/** A command cut to one short line for the notice and the pane. */
48function clip(command) {
49  const line = String(command ?? '').replace(/\s+/g, ' ').trim()
50  return line.length > 40 ? line.slice(0, 40) + '…' : line
51}
52
53export function registerAsks(on) {
54  // ===== [AskUserQuestion] ui.render { component: 'AskUserQuestion' } =====
55  // Raised both for Claude's AskUserQuestion calls and for $.ui.ask (the [$.ui.ask]
56  // button in the main pane's dialogs view), which runs as a tool.call of AskUserQuestion
57  on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
58    noteRender($, 'AskUserQuestion', e)
59    if (!(await isOn($, 'AskUserQuestion'))) return next(e)
60    const questions = Array.isArray(e.props.questions) ? e.props.questions.map(markQuestion) : e.props.questions
61    return next({ ...e, props: { ...e.props, questions } })
62  })
63
64  // ===== [$.ui.notice] tool.call { tool: 'Bash' } → $.ui.notice(e.tool_use_id, text) =====
65  // Called before next(e), as the type declarations' example does, so the line is in place
66  // when the permission dialog below opens; the engine removes it when the call resolves.
67  // A Bash call that needs no permission opens no dialog, and the line has nowhere to show.
68  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
69    if (!(await isOn($, '$.ui.notice'))) return next(e)
70    noteCall('$.ui.notice')
71    const command = clip(e.command)
72    let called
73    try {
74      $.ui.notice(e.tool_use_id, `[$.ui.notice] ui-sampler が許可ダイアログの下に足した行です(${command})`)
75      called = `呼んだ(tool_use_id: ${e.tool_use_id}、コマンド: ${command})`
76    } catch (error) {
77      called = `エラー: ${String(error?.message ?? error)}(コマンド: ${command})`
78    }
79    noteResult('$.ui.notice', called + '。実行を待っている')
80    redrawResult($, '$.ui.notice')
81
82    const ran = await next(e)
83    const outcome =
84      ran.deny !== undefined ? `断られた(deny: ${ran.deny})` : ran.isError === true ? 'エラーで終わった' : '実行された'
85    noteResult('$.ui.notice', `${called}。その後の呼び出し: ${outcome}`)
86    redrawResult($, '$.ui.notice')
87    return ran
88  })
89}
90
hooks/events.js 112 lines
1// Engine events that are not drawings: how the slash commands are described in the typeahead
2// and /help, how the /config rows are labelled, the end of a turn, and the dim suggestion in
3// the prompt box. Each hook counts its
4// call and keeps the input it received (the pane lists it in the [詳細] card), then passes
5// `next(e)` on unchanged when its site is switched off in the pane. All are off by default.
6// The $.prompt calls ([$.prompt.suggest] [$.prompt.fill] [$.prompt.read]) are the pane's
7// buttons, in pane.js.
8
9import { noteCall, noteEvent, toggleValue } from './sites.js'
10import { noteInvalidate } from './diag.js'
11import { redrawFor, paneShowsView } from './redraw.js'
12import { holdEvent, mergeHeld, restoreHeld } from './event-log.js'
13import { eventShape } from './value-format.js'
14import { read, update } from 'claude-code'
15
16// The state this file reads and writes (declared in types/index.d.ts): one switch per site
17// id, and the values view's event inputs
18const TOGGLES = { plugin: 'ui-sampler', key: 'toggles' }
19const EVENTS = { plugin: 'ui-sampler', key: 'events' }
20
21// Keeps one event's reduced input for the values view (event-log.js): written now, unless
22// that view is shown and wrote within REDRAW_GAP_MS, when its timer (values.js) writes it.
23// Never throws: the event goes on whatever happens.
24async function keepEvent($, name, shape) {
25  const batch = holdEvent(name, shape, Date.now(), paneShowsView('values'))
26  if (!batch) return
27  try {
28    await update($, EVENTS, current => mergeHeld(current, batch))
29  } catch {
30    restoreHeld(batch)
31  }
32}
33
34// Counts the call and keeps the input; redraws the pane only when it shows what changed
35// (redraw.js)
36function noteEventCall($, id, e) {
37  const isNew = noteCall(id)
38  const isChanged = noteEvent(id, e)
39  const why = redrawFor(id, { isNew, isChanged }, Date.now())
40  if (!why) return
41  noteInvalidate(`${id} の${why === 'first' ? '初回の呼び出し' : '受け取った e が変わった'}`)
42  $.ui.invalidate('ui.render')
43}
44
45async function isOn($, id) {
46  return toggleValue(id, await read($, { ...TOGGLES, id }))
47}
48
49// ===== [command.describe] the description of /ui-sampler and /ui-sampler-dialog =====
50// Shared by the two hooks below, one per command (their matchers are literals so `plugin
51// validate` can list them). The engine caches the answer for the session; the pane's switch
52// asks it again with $.ui.invalidate('command.describe').
53async function describeCommand($, e, next) {
54  noteEventCall($, 'command.describe', e)
55  if (!(await isOn($, 'command.describe'))) return next(e)
56  return next({ ...e, description: '[command.describe] ' + e.description })
57}
58
59// ===== [config.describe] the label of each /config row =====
60// A hook can relabel, re-describe or hide a row, not add one. On, every row's label is led by
61// the site's label. The engine caches the answers; the pane's switch asks again with
62// $.ui.invalidate('config.describe').
63async function describeConfig($, e, next) {
64  noteEventCall($, 'config.describe', e)
65  if (!(await isOn($, 'config.describe'))) return next(e)
66  return next({ ...e, label: '[config.describe] ' + e.label })
67}
68
69/** `3.2 秒`, from milliseconds. */
70const seconds = ms => `${(ms / 1000).toFixed(1)} 秒`
71
72/** A turn's token counts in one phrase, or a note that none came. */
73function usagePhrase(usage) {
74  if (!usage) return 'usage なし'
75  return `入力 ${usage.input_tokens}・出力 ${usage.output_tokens}・キャッシュ読み ${usage.cache_read_input_tokens} トークン、${usage.model}`
76}
77
78export function registerEvents(on) {
79  // ===== [command.describe] on('command.describe', { command: 'ui-sampler' }) =====
80  on('command.describe', { command: 'ui-sampler' }, async ($, e, next) => describeCommand($, e, next))
81
82  // ===== [command.describe] on('command.describe', { command: 'ui-sampler-dialog' }) =====
83  on('command.describe', { command: 'ui-sampler-dialog' }, async ($, e, next) => describeCommand($, e, next))
84
85  // ===== [config.describe] on('config.describe'): every row, so no matcher =====
86  on('config.describe', async ($, e, next) => describeConfig($, e, next))
87
88  // ===== [turn.complete] on('turn.complete') =====
89  // A text other than the answer is shown beneath the answer; the transcript's record keeps
90  // the answer. A subagent's turn (agentId) is passed on as it came: its text is what the
91  // caller of the subagent reads.
92  // It also keeps the input for the values view's [turn.complete] (values.js hooks the other
93  // events; a plugin hooks an event without a matcher once).
94  on('turn.complete', async ($, e, next) => {
95    noteEventCall($, 'turn.complete', e)
96    await keepEvent($, 'turn.complete', eventShape('turn.complete', e))
97    if (e.agentId !== undefined || !(await isOn($, 'turn.complete'))) return next(e)
98    const result = await next(e)
99    const line = `[turn.complete] ui-sampler が足した行(reason: ${e.reason}、${seconds(e.durationMs)}、${usagePhrase(e.usage)})`
100    return { ...result, text: line }
101  })
102
103  // ===== [prompt.suggest] on('prompt.suggest') =====
104  // The engine's guess after a turn, or another plugin's $.prompt.suggest; this mod's own
105  // [$.prompt.suggest] does not come through here
106  on('prompt.suggest', async ($, e, next) => {
107    noteEventCall($, 'prompt.suggest', e)
108    if (!(await isOn($, 'prompt.suggest'))) return next(e)
109    return next({ ...e, text: '[prompt.suggest] ' + e.text })
110  })
111}
112
hooks/values.js 573 lines
1// The values view of the pane (view 'values', the 値 category): what a mod can obtain, one
2// compact row per value with a [詳細] that shows it in full (still reduced, value-format.js).
3// Four sections:
4//   $ の取得系        the getters on $, fetched when the view opens and on [再取得]; the light
5//                     ones again every AUTO_REFRESH_MS while the view is shown, never otherwise.
6//                     [session.repo] reads git, so it waits for its button
7//   イベントの入力    the last input of each engine event below and of every classic.* event,
8//                     kept by hooks that pass every event on unchanged
9//   ボタンで呼ぶもの  calls that cost tokens, an API call, the network or a process, or read
10//                     what may hold secrets: made only from their buttons
11//   state / store     this mod's own $.state and $.store, read only
12//
13// Everything the view shows is in $.state (getters, events, calls), written from presses,
14// timers and event hooks, never while drawing. Reading it while drawing subscribes the view,
15// so a write redraws the pane only while the values view is the one drawn (redraw.js); the
16// event hooks write at most once per REDRAW_GAP_MS while it is, and at once otherwise
17// (event-log.js).
18// Spacing, columns and button roles come from style.js.
19
20import {
21  GETTERS,
22  AUTO_GETTERS,
23  EVENTS,
24  CLASSIC_EVENTS,
25  CLASSIC_NOTE,
26  CALLS,
27  SECTIONS,
28  HOOK_NOTE,
29  AUTO_REFRESH_MS,
30  RUNNING,
31  entryOf,
32  errorEntry,
33  toolsOf,
34  commandsOf,
35  agentsOf,
36  configOf,
37  usageOf,
38  breakdownOf,
39  settingsShape,
40  envShape,
41  messagesShape,
42  listShape,
43  ancestorsShape,
44  httpShape,
45  processShape,
46  modelShape,
47  mcpShape,
48  statShape,
49  eventShape,
50  stepShape,
51  toolCallShape,
52  classicShape,
53  valueKey,
54  differsApartFromClock,
55} from './value-format.js'
56import { CATEGORIES, noteCall } from './sites.js'
57import { SPACE, WIDTH, COLOR, BUTTON, choice, dim, inline, cell, fill, tableRow, header, section, card, page } from './style.js'
58import { noteInvalidate, notePaneRender } from './diag.js'
59import { redrawFor, notePaneShows, paneShowsView } from './redraw.js'
60import { guardDrawing } from './press-guard.js'
61import { holdEvent, takeHeld, mergeHeld, restoreHeld } from './event-log.js'
62import { atom, read, update } from 'claude-code'
63
64// The state this file reads and writes (declared in types/index.d.ts): the pane's view, the
65// row each category view details (this view's under 'values'), the getters' last answers,
66// the events' last inputs, and each call button's last result
67const view = atom({ plugin: 'ui-sampler', key: 'view' }, 'toc')
68const SELECTED = { plugin: 'ui-sampler', key: 'selected' }
69const getters = atom({ plugin: 'ui-sampler', key: 'getters' }, {})
70const events = atom({ plugin: 'ui-sampler', key: 'events' }, {})
71const CALL_RESULTS = { plugin: 'ui-sampler', key: 'calls' }
72
73const CATEGORY = CATEGORIES.find(one => one.id === 'values')
74const ALL_GETTERS = GETTERS.map(getter => getter.id)
75
76/** The one prompt [model.complete] sends. */
77const MODEL_PROMPT = '「はい」とだけ答えてください。'
78/** The one URL [http.fetch] asks for. */
79const FETCH_URL = 'https://example.com/'
80
81// ----- The getters -----
82
83// One function rather than a table of closures: the engine follows $ only into functions
84// declared in this file, called by name. Each case answers the value as it may be shown.
85async function getterValue($, id) {
86  switch (id) {
87    // ===== [plugin.name] [plugin.root] $.plugin.name / $.plugin.root (properties) =====
88    case 'plugin.name':
89      return $.plugin.name
90    case 'plugin.root':
91      return $.plugin.root
92
93    // ===== [session.*] $.session.id() / cwd() / root() / model() / turns() / version() / surfaces() =====
94    case 'session.id':
95      return $.session.id()
96    case 'session.cwd':
97      return $.session.cwd()
98    case 'session.root':
99      return $.session.root()
100    case 'session.model':
101      return $.session.model()
102    case 'session.turns':
103      return $.session.turns()
104    case 'session.version':
105      return $.session.version()
106    case 'session.surfaces':
107      return $.session.surfaces()
108
109    // ===== [session.usage] $.session.usage() (no breakdown: costs nothing) =====
110    case 'session.usage':
111      return usageOf(await $.session.usage())
112
113    // ===== [tool.list] [command.list] [agent.list] [config.list] =====
114    case 'tool.list':
115      return toolsOf(await $.tool.list())
116    case 'command.list':
117      return commandsOf(await $.command.list())
118    case 'agent.list':
119      return agentsOf(await $.agent.list())
120    case 'config.list':
121      return configOf(await $.config.list())
122
123    // ===== [ui.panes] $.ui.panes() =====
124    case 'ui.panes':
125      return $.ui.panes()
126
127    // ===== [clock.now] $.clock.now() =====
128    case 'clock.now':
129      return $.clock.now()
130
131    // ===== [state.get] $.state.get of this mod's own refs, with their versions =====
132    case 'state.get': {
133      const shown = await $.state.get({ plugin: 'ui-sampler', key: 'view' })
134      const hint = await $.state.get({ plugin: 'ui-sampler', key: 'promptHintMode' })
135      const spinner = await $.state.get({ plugin: 'ui-sampler', key: 'spinnerMode' })
136      const output = await $.state.get({ plugin: 'ui-sampler', key: 'commandOutputMode' })
137      return { view: shown, promptHintMode: hint, spinnerMode: spinner, commandOutputMode: output }
138    }
139
140    // ===== [store.keys] [store.get] $.store.keys() / $.store.get(key), read only =====
141    case 'store.keys':
142      return $.store.keys()
143    case 'store.get': {
144      const values = {}
145      for (const key of (await $.store.keys()).slice(0, 10)) values[key] = await $.store.get(key)
146      return values
147    }
148
149    default:
150      throw new Error('未対応: ' + id)
151  }
152}
153
154/**
155 * Fetches the getters `ids` and writes their answers (from a press or a timer, never while
156 * drawing). `isAuto`: the timer's fetch, written only when something other than the clock
157 * changed, so the view is not redrawn every period for nothing.
158 */
159async function fetchGetters($, ids, isAuto) {
160  const fetched = {}
161  for (const id of ids) {
162    try {
163      fetched[id] = entryOf(await getterValue($, id))
164    } catch (error) {
165      fetched[id] = errorEntry(error)
166    }
167  }
168  const before = await read($, getters)
169  if (isAuto && !differsApartFromClock(before, { ...before, ...fetched })) return
170  await update($, getters, current => ({ ...current, ...fetched }))
171}
172
173// The timer of the light getters, while the values view is shown
174let ticker
175
176// Starts the timer once, and fetches every getter: called by a drawing of the values view
177// that follows a drawing of another view (or none), i.e. when the view opens. Timers are not
178// state writes, so a drawing may start them; the fetch itself runs from the timer.
179function onOpen($) {
180  try {
181    $.clock.after(0, () => fetchGetters($, ALL_GETTERS, false).catch(() => {}))
182    if (!ticker) ticker = $.clock.every(AUTO_REFRESH_MS, () => tick($).catch(() => {}))
183  } catch {
184    // A host that refuses timers: [再取得] still fetches
185  }
186}
187
188// One period: stops once the values view is no longer drawn (another view, or the pane
189// closed), else writes what the event hooks held back (event-log.js) and fetches the light
190// getters
191async function tick($) {
192  if (!paneShowsView('values')) {
193    ticker?.cancel()
194    ticker = undefined
195    return
196  }
197  await writeHeld($)
198  await fetchGetters($, AUTO_GETTERS, true)
199}
200
201// ----- The event inputs -----
202
203// Keeps one event's reduced input for this view (event-log.js): written now, unless this view
204// is shown and wrote within REDRAW_GAP_MS, when the view's timer writes it. Never throws: an
205// event goes on whatever happens. register.js and events.js keep one of their own each.
206async function keepEvent($, name, shape) {
207  const batch = holdEvent(name, shape, Date.now(), paneShowsView('values'))
208  if (!batch) return
209  try {
210    await update($, events, current => mergeHeld(current, batch))
211  } catch {
212    // Refused (before the session binds, say): held again for the next write
213    restoreHeld(batch)
214  }
215}
216
217// Writes what the event hooks held back while this view was shown (from its timer)
218async function writeHeld($) {
219  const batch = takeHeld(Date.now())
220  if (!batch) return
221  try {
222    await update($, events, current => mergeHeld(current, batch))
223  } catch {
224    restoreHeld(batch)
225  }
226}
227
228// ----- The call buttons -----
229
230// One function rather than a table of closures, as getterValue. Each case answers the value
231// as it may be shown: counts, lengths and key names where the value could hold secrets.
232async function callValue($, id) {
233  switch (id) {
234    // ===== [session.repo] $.session.repo() (reads git) =====
235    case 'session.repo':
236      return $.session.repo()
237
238    // ===== [fs.stat] $.fs.stat('.') =====
239    case 'fs.stat':
240      return statShape(await $.fs.stat('.'))
241
242    // ===== [fs.list] $.fs.list() =====
243    case 'fs.list':
244      return listShape(await $.fs.list())
245
246    // ===== [fs.exists] $.fs.exists(path) =====
247    case 'fs.exists':
248      return {
249        'CLAUDE.md': await $.fs.exists('CLAUDE.md'),
250        'README.md': await $.fs.exists('README.md'),
251        '.git': await $.fs.exists('.git'),
252      }
253
254    // ===== [fs.ancestors] $.fs.ancestors({ names: ['CLAUDE.md'] }) =====
255    case 'fs.ancestors':
256      return ancestorsShape(await $.fs.ancestors({ names: ['CLAUDE.md'] }))
257
258    // ===== [settings.read] $.settings.read() (key names only) =====
259    case 'settings.read':
260      return settingsShape(await $.settings.read())
261
262    // ===== [env.get] $.env.get(name), each name a literal (set or unset, and the length) =====
263    case 'env.get':
264      return envShape({
265        CLAUDE_CODE_ENTRYPOINT: await $.env.get('CLAUDE_CODE_ENTRYPOINT'),
266        CLAUDE_CODE_EXECPATH: await $.env.get('CLAUDE_CODE_EXECPATH'),
267        CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
268        CLAUDE_CODE_USE_BEDROCK: await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
269        CLAUDE_CODE_USE_VERTEX: await $.env.get('CLAUDE_CODE_USE_VERTEX'),
270        ANTHROPIC_MODEL: await $.env.get('ANTHROPIC_MODEL'),
271        ANTHROPIC_BASE_URL: await $.env.get('ANTHROPIC_BASE_URL'),
272        ANTHROPIC_API_KEY: await $.env.get('ANTHROPIC_API_KEY'),
273      })
274
275    // ===== [session.messages] $.session.messages() (count and last role only) =====
276    case 'session.messages':
277      return messagesShape(await $.session.messages())
278
279    // ===== [session.authorize] $.session.authorize() (kind or null; never the handle) =====
280    case 'session.authorize': {
281      const authorization = await $.session.authorize()
282      return authorization ? { kind: authorization.kind } : null
283    }
284
285    // ===== [session.usage/summary] $.session.usage({ breakdown: 'summary' }) =====
286    case 'session.usage/summary':
287      return breakdownOf(await $.session.usage({ breakdown: 'summary' }))
288
289    // ===== [session.usage/full] $.session.usage({ breakdown: 'full' }) (an API call) =====
290    case 'session.usage/full':
291      return breakdownOf(await $.session.usage({ breakdown: 'full' }))
292
293    // ===== [process.run] $.process.run(['git', 'rev-parse', '--short', 'HEAD']) =====
294    case 'process.run':
295      return processShape(await $.process.run(['git', 'rev-parse', '--short', 'HEAD']))
296
297    // ===== [model.complete] $.model.complete({ model: 'haiku', prompt, maxTokens }) (tokens) =====
298    case 'model.complete':
299      return modelShape(await $.model.complete({ model: 'haiku', prompt: MODEL_PROMPT, maxTokens: 20 }))
300
301    // ===== [http.fetch] $.http.fetch(FETCH_URL) (the network) =====
302    case 'http.fetch':
303      return httpShape(await $.http.fetch(FETCH_URL))
304
305    // ===== [mcp.call] not called: the MCP servers and their tools, from $.tool.list() =====
306    case 'mcp.call':
307      return mcpShape(await $.tool.list())
308
309    default:
310      throw new Error('未対応: ' + id)
311  }
312}
313
314// Runs one call from its button: says it runs, then writes what it answered or why it failed
315async function runCall($, id) {
316  await update($, { ...CALL_RESULTS, id }, () => RUNNING)
317  let entry
318  try {
319    entry = entryOf(await callValue($, id))
320  } catch (error) {
321    entry = errorEntry(error)
322  }
323  await update($, { ...CALL_RESULTS, id }, () => entry)
324}
325
326// ----- The view -----
327
328// Counts the call, and redraws once when a site or surface is new and the main pane lists
329// the site (redraw.js)
330function noteRender($, id, e) {
331  const isNew = noteCall(id, e.surface)
332  if (!redrawFor(id, { isNew }, Date.now())) return
333  noteInvalidate(`${id} の初回描画(新しい surface)`)
334  $.ui.invalidate('ui.render')
335}
336
337// Switches the pane's view from a press handler (never while drawing), and brings the new
338// view's top into sight
339async function go($, next) {
340  await update($, view, () => next)
341  await $.ui.scroll({ in: 'ui-sampler', to: 'start' })
342}
343
344// Picks the row to detail, or clears the pick when it is pressed again (never while drawing).
345// Cleared is '' ($.state.set takes no undefined), which names no row.
346function select($, rowId) {
347  return update($, { ...SELECTED, id: 'values' }, value => (value === rowId ? '' : rowId))
348}
349
350/**
351 * One value's row: its label, [詳細], then its text (and the call's button above the text),
352 * with the [詳細] card under it when picked. `row`: { id, label, entry, empty, note, count,
353 * action }. Pure but for the press handlers it is handed.
354 */
355function valueRow(ui, row, picked, onDetail) {
356  const { Box, Text, Button } = ui
357  const isPicked = picked === row.id
358  const entry = row.entry
359  const text = entry ? (row.count ? `(${row.count} 回)` : '') + entry.short : row.empty
360  const value = Box({
361    key: valueKey('value', row.id),
362    width: '100%',
363    children: [Text({ wrap: 'wrap', dimColor: !entry, color: entry?.isError ? COLOR.warn : undefined, children: [text] })],
364  })
365  const line = tableRow(
366    ui,
367    valueKey('line', row.id),
368    [
369      cell(ui, WIDTH.value, [Text({ children: [row.label] })]),
370      cell(ui, WIDTH.detail, [
371        Button({ key: valueKey('vdetail', row.id), label: '詳細', ...choice(isPicked), onPress: () => onDetail(row.id) }),
372      ]),
373      fill(ui, row.action ? [inline(ui, valueKey('action', row.id), [row.action]), value] : [value], 'column'),
374    ],
375    'flex-start',
376  )
377  const detail = isPicked
378    ? card(ui, valueKey('card', row.id), [
379        Text({ bold: true, children: [`${row.label} の詳細`] }),
380        dim(ui, valueKey('note', row.id), row.note),
381        Box({ key: valueKey('full', row.id), width: '100%', children: [Text({ wrap: 'wrap', children: [entry ? entry.full : row.empty] })] }),
382      ])
383    : null
384  return Box({ key: valueKey('entry', row.id), flexDirection: 'column', rowGap: SPACE.row, width: '100%', children: [line, detail] })
385}
386
387// ===== [Pane/values] the values view =====
388// `guard` (press-guard.js) keeps this drawing's Button closures for [press/再実行]
389async function drawValues($, e, guard) {
390  const ui = guard.wrap($.ui.resolve(e))
391  const { Text, Button } = ui
392
393  // Reading these subscribes the view: a write redraws it while it is shown
394  const picked = await read($, { ...SELECTED, id: 'values' })
395  const fetched = await read($, getters)
396  const received = await read($, events)
397  const results = {}
398  for (const call of CALLS) results[call.id] = await read($, { ...CALL_RESULTS, id: call.id })
399
400  const onDetail = rowId => select($, rowId)
401  const rows = { getters: [], events: [], calls: [], stores: [] }
402
403  // ----- $ の取得系 and state / store: the getters, then [session.repo] -----
404  for (const getter of GETTERS) {
405    rows[getter.section].push(
406      valueRow(
407        ui,
408        {
409          id: 'g:' + getter.id,
410          label: `[${getter.id}]${getter.isAuto ? ' ●' : ''}`,
411          entry: fetched[getter.id],
412          empty: 'まだ取得していない',
413          note: `${getter.note}。${HOOK_NOTE}`,
414        },
415        picked,
416        onDetail,
417      ),
418    )
419  }
420
421  // ----- ボタンで呼ぶもの: one row per call, its button in the row -----
422  for (const call of CALLS) {
423    rows[call.section].push(
424      valueRow(
425        ui,
426        {
427          id: 'c:' + call.id,
428          label: `[${call.id}]`,
429          entry: results[call.id],
430          empty: 'まだ呼んでいない',
431          note: `${call.note}。${HOOK_NOTE}`,
432          action: Button({ key: valueKey('call', call.id), label: call.button, onPress: () => runCall($, call.id) }),
433        },
434        picked,
435        onDetail,
436      ),
437    )
438  }
439
440  // ----- イベントの入力: the engine events, then every classic event received -----
441  for (const event of EVENTS) {
442    const entry = received[event.id]
443    rows.events.push(
444      valueRow(
445        ui,
446        { id: 'e:' + event.id, label: `[${event.id}]`, entry, count: entry?.count, empty: 'まだ受け取っていない', note: event.note },
447        picked,
448        onDetail,
449      ),
450    )
451  }
452  const classicNames = Object.keys(received).filter(name => name.startsWith('classic.'))
453  const known = CLASSIC_EVENTS.map(name => 'classic.' + name)
454  classicNames.sort((a, b) => known.indexOf(a) - known.indexOf(b))
455  rows.events.push(Text({ key: 'classic-heading', bold: true, children: [`classic.*(${classicNames.length} 種類を受け取った)`] }))
456  for (const name of classicNames) {
457    const entry = received[name]
458    rows.events.push(
459      valueRow(ui, { id: 'e:' + name, label: `[${name}]`, entry, count: entry?.count, empty: '', note: CLASSIC_NOTE }, picked, onDetail),
460    )
461  }
462  const missing = known.filter(name => !classicNames.includes(name)).map(name => name.slice('classic.'.length))
463  if (missing.length > 0) rows.events.push(dim(ui, 'classic-missing', 'まだ受け取っていない classic: ' + missing.join(', ')))
464
465  return page(ui, [
466    header(ui, {
467      title: '[Pane] ' + CATEGORY.label,
468      about: CATEGORY.about,
469      nav: [
470        Button({ key: 'contents', label: '目次へ', ...BUTTON.nav, onPress: () => go($, 'toc') }),
471        Button({ key: 'refetch', label: '再取得', ...BUTTON.nav, onPress: () => fetchGetters($, ALL_GETTERS, false) }),
472      ],
473      note: HOOK_NOTE,
474    }),
475    ...SECTIONS.map(one =>
476      section(ui, 'values-' + one.id, one.title, [dim(ui, `values-${one.id}-about`, one.about), ...rows[one.id]], SPACE.control),
477    ),
478  ])
479}
480
481export function registerValues(on) {
482  // ===== [Pane/values] ui.render { component: 'Pane', requestId: 'ui-sampler' }, view 'values' =====
483  // The same matcher as [Pane] (pane.js); registered before it and before [Pane/samples], so
484  // this is the outermost link and passes every other view on with next(e)
485  on('ui.render', { component: 'Pane', requestId: 'ui-sampler' }, async ($, e, next) => {
486    if ((await read($, view)) !== 'values') return next(e)
487    notePaneRender(e)
488    noteRender($, 'Pane', e)
489    if (!paneShowsView('values')) onOpen($)
490    // This view's counts are in $.state, which redraws it; no other hook needs to (redraw.js)
491    notePaneShows('values', undefined)
492    const guard = guardDrawing(e.requestId)
493    return guard.done(await drawValues($, e, guard))
494  })
495
496  // [session.start] is kept by register.js's hook, [turn.complete] by events.js's: a plugin
497  // hooks an event without a matcher once
498
499  // ===== [session.end] on('session.end') =====
500  on('session.end', async ($, e, next) => {
501    await keepEvent($, 'session.end', eventShape('session.end', e))
502    return next(e)
503  })
504
505  // ===== [session.measure] on('session.measure') =====
506  on('session.measure', async ($, e, next) => {
507    await keepEvent($, 'session.measure', eventShape('session.measure', e))
508    return next(e)
509  })
510
511  // ===== [session.attach] on('session.attach') =====
512  on('session.attach', async ($, e, next) => {
513    await keepEvent($, 'session.attach', eventShape('session.attach', e))
514    return next(e)
515  })
516
517  // ===== [session.detach] on('session.detach') =====
518  on('session.detach', async ($, e, next) => {
519    await keepEvent($, 'session.detach', eventShape('session.detach', e))
520    return next(e)
521  })
522
523  // ===== [session.receive] on('session.receive'): the text's length only =====
524  on('session.receive', async ($, e, next) => {
525    await keepEvent($, 'session.receive', eventShape('session.receive', e))
526    return next(e)
527  })
528
529  // ===== [session.compact] on('session.compact'): counts only =====
530  on('session.compact', async ($, e, next) => {
531    await keepEvent($, 'session.compact', eventShape('session.compact', e))
532    return next(e)
533  })
534
535  // ===== [prompt.submit] on('prompt.submit'): the text's length only =====
536  on('prompt.submit', async ($, e, next) => {
537    await keepEvent($, 'prompt.submit', eventShape('prompt.submit', e))
538    return next(e)
539  })
540
541  // ===== [turn.start] on('turn.start') =====
542  on('turn.start', async ($, e, next) => {
543    await keepEvent($, 'turn.start', eventShape('turn.start', e))
544    return next(e)
545  })
546
547  // ===== [turn.step] on('turn.step'): a stream, passed through whole with yield* =====
548  on('turn.step', async function* ($, e, next) {
549    const result = yield* next(e)
550    await keepEvent($, 'turn.step', stepShape(e, result))
551    return result
552  })
553
554  // ===== [tool.call] on('tool.call'): the tool, its input's key names, the outcome =====
555  on('tool.call', async ($, e, next) => {
556    const result = await next(e)
557    await keepEvent($, 'tool.call', toolCallShape(e, result))
558    return result
559  })
560
561  // ===== [plugin.register] on('plugin.register') =====
562  on('plugin.register', async ($, e, next) => {
563    await keepEvent($, 'plugin.register', eventShape('plugin.register', e))
564    return next(e)
565  })
566
567  // ===== [classic.*] on('classic.*'): every classic event, its common fields =====
568  on('classic.*', async ($, e, next) => {
569    await keepEvent($, next.event, classicShape(next.event, e))
570    return next(e)
571  })
572}
573
hooks/event-log.js 58 lines
1// The event inputs the values view lists, held between the event hooks and their $.state
2// write. A hook reduces its input (value-format.js), hands it to holdEvent, and writes the
3// batch it gets back, if any, into `{ plugin: 'ui-sampler', key: 'events' }` with mergeHeld.
4//
5// The write redraws the pane only while the values view is drawn (it reads that state), so
6// while it is, a batch is handed back at most once per REDRAW_GAP_MS; the rest waits for the
7// view's timer (values.js), which takes it with takeHeld. While the view is not drawn, every
8// input is handed back at once.
9//
10// Pure: no $ here, so the hook files keep every $ call themselves. The hooks of one event live
11// in different files (session.start in register.js, turn.complete in events.js, the rest in
12// values.js), since a plugin hooks an event without a matcher once.
13
14import { entryOf } from './value-format.js'
15import { REDRAW_GAP_MS } from './redraw.js'
16
17// What came since the last write, by event name: the entry, when, and how many calls
18const held = new Map()
19let lastWrite = -Infinity
20
21/**
22 * Holds one event's reduced input. Returns the batch to write now (everything held), or
23 * undefined when the values view is shown (`isShown`) and the last write was less than
24 * REDRAW_GAP_MS before `now`.
25 */
26export function holdEvent(name, shape, now, isShown) {
27  const before = held.get(name)
28  held.set(name, { entry: entryOf(shape), at: now, added: (before?.added ?? 0) + 1 })
29  if (isShown && now - lastWrite < REDRAW_GAP_MS) return undefined
30  return takeHeld(now)
31}
32
33/** Everything held, as a batch to write, or undefined when nothing is. */
34export function takeHeld(now) {
35  if (held.size === 0) return undefined
36  const batch = new Map(held)
37  held.clear()
38  lastWrite = now
39  return batch
40}
41
42/** The state's next value: `current` with the batch's entries, their counts added up. */
43export function mergeHeld(current, batch) {
44  const next = { ...current }
45  for (const [name, one] of batch) {
46    next[name] = { ...one.entry, at: one.at, count: (current?.[name]?.count ?? 0) + one.added }
47  }
48  return next
49}
50
51/** Puts back a batch whose write failed, under anything that came since. */
52export function restoreHeld(batch) {
53  for (const [name, one] of batch) {
54    const since = held.get(name)
55    held.set(name, since ? { ...since, added: since.added + one.added } : one)
56  }
57}
58
hooks/value-format.js 551 lines
1// The values view's tables and formatting (view 'values', drawn by values.js): which values
2// the view lists in each section, the note each one's [詳細] shows, and the pure functions
3// that cut a value down to what the view may show. Values that can hold secrets (the
4// settings, the environment, the prompt's text, the transcript) are reduced here to key
5// names, counts and lengths before anything is kept or drawn.
6//
7// Pure: no $ here, so values.js keeps every $ call itself.
8
9/** The longest string a row shows. */
10export const TEXT_LIMIT = 80
11/** The longest JSON a row shows for an object. */
12export const JSON_LIMIT = 200
13/** The longest JSON a [詳細] shows. */
14export const FULL_LIMIT = 4000
15/** How many names a row shows of a list. */
16const NAMES_SHOWN = 3
17
18/** How often the light getters are fetched again while the values view is shown. */
19export const AUTO_REFRESH_MS = 5000
20
21/**
22 * The note under every [詳細] of the getters: what a getter answers is what the chain of
23 * hooks answered, and another plugin can hook it.
24 */
25export const HOOK_NOTE =
26  '$ の呼び出しはどれもフックの連鎖を通るので、ほかのプラグインが答えを書き換えられる。ここに出るのは、この mod に届いた答え'
27
28/**
29 * The getters fetched on opening the view and on [再取得]. `isAuto`: also fetched every
30 * AUTO_REFRESH_MS while the view is shown (cheap, and may change during a session).
31 * `section` is where the row is listed: 'getters' or 'stores'. values.js's getterValue()
32 * makes each call, by id.
33 */
34export const GETTERS = [
35  { id: 'plugin.name', section: 'getters', isAuto: false, note: 'plugin.json の name。$.plugin.name はプロパティで、呼び出しではない' },
36  { id: 'plugin.root', section: 'getters', isAuto: false, note: 'plugin.json のあるフォルダ(絶対パス)。$.plugin.root もプロパティ' },
37  { id: 'session.id', section: 'getters', isAuto: false, note: 'セッションの id(記録ファイルの名前)' },
38  { id: 'session.cwd', section: 'getters', isAuto: true, note: 'セッションが動いているフォルダ(絶対パス)' },
39  { id: 'session.root', section: 'getters', isAuto: true, note: 'プロジェクトのルート。/cd などで動く。シェルの cd では動かない' },
40  { id: 'session.model', section: 'getters', isAuto: true, note: '本体のモデル。/model に出る名前' },
41  { id: 'session.turns', section: 'getters', isAuto: true, note: 'このセッションで送ったプロンプトの数' },
42  { id: 'session.version', section: 'getters', isAuto: false, note: 'エンジンの version、元のリリース base、ビルド日時 builtAt' },
43  { id: 'session.surfaces', section: 'getters', isAuto: true, note: 'このセッションが描いている surface の一覧' },
44  {
45    id: 'session.usage',
46    section: 'getters',
47    isAuto: true,
48    note: '引数なしの $.session.usage()。ステータス行と同じ数字(コンテキストの使用率、レート制限、費用)。費用はかからない。内訳つきは下の「ボタンで呼ぶもの」',
49  },
50  { id: 'tool.list', section: 'getters', isAuto: false, note: 'モデルが今呼べるツール(組み込みと MCP)。[詳細] では説明を 80 文字で切る' },
51  { id: 'command.list', section: 'getters', isAuto: false, note: '今使えるスラッシュコマンド(組み込み・プラグイン・MCP)。[詳細] では説明を 80 文字で切る' },
52  { id: 'agent.list', section: 'getters', isAuto: true, note: 'このセッションのサブエージェント(モデルが起こしたものも、プラグインが起こしたものも)' },
53  {
54    id: 'config.list',
55    section: 'getters',
56    isAuto: false,
57    note: '/config のメニューの行。文字列の値は中身を出さず文字数だけ(プラグインの userConfig に秘密が入りうるため)',
58  },
59  { id: 'ui.panes', section: 'getters', isAuto: true, note: 'この mod が開いているパネル' },
60  {
61    id: 'clock.now',
62    section: 'getters',
63    isAuto: true,
64    note: '取得した時刻(エポックからのミリ秒)。自動の取得では、ほかの値が変わったときだけ描き直すので、この値だけでは表示が進まない',
65  },
66  {
67    id: 'state.get',
68    section: 'stores',
69    isAuto: true,
70    note: 'この mod 自身の $.state(view、promptHintMode、spinnerMode、commandOutputMode)の値と version。どのプラグインもほかのプラグインの値を読めるが、書けるのは持ち主だけ',
71  },
72  { id: 'store.keys', section: 'stores', isAuto: true, note: 'この mod の $.store のキー。ui-sampler は $.store に書かないので、ふつうは空' },
73  { id: 'store.get', section: 'stores', isAuto: true, note: '$.store の各キーの値(読むだけ。書き込みはしない)。最初の 10 キーまで' },
74]
75
76/** The getters fetched every AUTO_REFRESH_MS while the view is shown. */
77export const AUTO_GETTERS = GETTERS.filter(getter => getter.isAuto).map(getter => getter.id)
78
79/**
80 * The engine events whose last input values.js keeps, in the order the view lists them. The
81 * classic (settings) hook events follow, each under its own name (CLASSIC_EVENTS).
82 */
83export const EVENTS = [
84  { id: 'session.start', note: 'セッションの開始(有効化やワーカーの再起動でも来る)。cwd、surface、isInteractive' },
85  { id: 'session.end', note: 'セッションの終わり。reason、sessionId、resume' },
86  { id: 'session.measure', note: 'ターンの後や、レート制限が 1 ポイント動いたときの使用量。changed が動いた項目' },
87  { id: 'session.attach', note: 'surface(クライアント)がつながった' },
88  { id: 'session.detach', note: 'surface(クライアント)が離れた' },
89  { id: 'session.receive', note: 'ほかから届いたメッセージ。本文は文字数だけ' },
90  { id: 'session.compact', note: '会話の圧縮。メッセージは数だけ、instructions は文字数だけ' },
91  { id: 'prompt.submit', note: '送られたプロンプト。本文は文字数だけ、添付と context は数だけ' },
92  { id: 'turn.start', note: 'ターンの始まり。本文は文字数だけ' },
93  {
94    id: 'turn.step',
95    note: 'モデルへの 1 回の要求(ストリーム)。入力は turnId、index、model、effort、messageCount。結果は stopReason、ツール呼び出しの数、回答の文字数、usage',
96  },
97  { id: 'turn.complete', note: 'ターンの終わり。回答は文字数だけ。usage はトークン数' },
98  { id: 'tool.call', note: 'ツールの呼び出し。入力はキー名だけ。結果は deny か、isError と text の文字数' },
99  { id: 'plugin.register', note: 'プラグインの読み込み(最後の 1 つ)。uses はキー名だけ' },
100]
101
102/**
103 * Every classic hook event of the type declarations (ClassicHookEvent, 2.1.286), so the view
104 * can say which never arrived. One `classic.*` hook keeps them all.
105 */
106export const CLASSIC_EVENTS = [
107  'SessionStart',
108  'Setup',
109  'SessionEnd',
110  'UserPromptSubmit',
111  'UserPromptExpansion',
112  'PreToolUse',
113  'PostToolUse',
114  'PostToolUseFailure',
115  'PostToolBatch',
116  'PermissionRequest',
117  'PermissionDenied',
118  'Notification',
119  'MessageDisplay',
120  'Stop',
121  'StopFailure',
122  'SubagentStart',
123  'SubagentStop',
124  'PreCompact',
125  'PostCompact',
126  'PreModelSwitch',
127  'PostModelSwitch',
128  'ConfigChange',
129  'CwdChanged',
130  'DirectoryAdded',
131  'FileChanged',
132  'InstructionsLoaded',
133  'Elicitation',
134  'ElicitationResult',
135  'TaskCreated',
136  'TaskCompleted',
137  'TeammateIdle',
138  'WorktreeCreate',
139  'WorktreeRemove',
140]
141
142/** The note of every classic event's [詳細]. */
143export const CLASSIC_NOTE =
144  '設定ファイルのフック(classic)と同じイベント。共通の項目(session_id、transcript_path、cwd、permission_mode など)だけを出し、ほかの項目はキー名だけ(プロンプトやツールの中身を出さないため)'
145
146/**
147 * The calls made only from a button. `cost` marks what a press spends: 'tokens' (a model
148 * call), 'api' (an API call), 'network', 'process' (a command run on the host), or none.
149 * `section`: 'getters' for [session.repo] (it reads git), else 'calls'. values.js's
150 * callValue() makes each call, by id.
151 */
152export const CALLS = [
153  { id: 'session.repo', section: 'getters', button: '取得する(git を読む)', cost: 'process', note: 'セッションのフォルダの git リポジトリ(root、remote、internal、name)。呼ぶたびに作業コピーを読むので、自動では取らない' },
154  { id: 'fs.stat', section: 'calls', button: '調べる', note: "$.fs.stat('.'):作業フォルダの種類・大きさ・更新時刻。中身は読まない" },
155  { id: 'fs.list', section: 'calls', button: '一覧を取る', note: '$.fs.list():作業フォルダの項目。名前と種類の数だけ(中身は読まない)' },
156  { id: 'fs.exists', section: 'calls', button: '確かめる', note: '$.fs.exists:作業フォルダに CLAUDE.md、README.md、.git があるか' },
157  {
158    id: 'fs.ancestors',
159    section: 'calls',
160    button: '探す',
161    note: "$.fs.ancestors({ names: ['CLAUDE.md'] }):上のフォルダの CLAUDE.md。見つかったフォルダと中身の文字数だけ(中身は出さない)",
162  },
163  {
164    id: 'settings.read',
165    section: 'calls',
166    button: 'キー名を読む',
167    note: '$.settings.read():すべての設定元を重ねた設定。秘密が入りうる(env やヘルパーのコマンドもそのまま来る)ので、キー名と、その値の型・件数だけ',
168  },
169  {
170    id: 'env.get',
171    section: 'calls',
172    button: '有無を見る',
173    note: '$.env.get:Claude Code に関わる環境変数がいくつか。値は出さず、設定されているかと文字数だけ。名前はソースに文字列で書いたものしか読めない',
174  },
175  { id: 'session.messages', section: 'calls', button: '数える', note: '$.session.messages():会話のメッセージの数と、最後のメッセージの role だけ(本文は出さない)' },
176  {
177    id: 'session.authorize',
178    section: 'calls',
179    button: '種類を見る',
180    note: '$.session.authorize():セッションの資格情報の種類(bearer / api-key)か null。資格情報そのものはプラグインに来ない。handle も出さない',
181  },
182  {
183    id: 'session.usage/summary',
184    section: 'calls',
185    button: '内訳を取る(手元で見積もる)',
186    note: "$.session.usage({ breakdown: 'summary' }):コンテキストの内訳を手元で見積もる。分類ごとのトークン数と、メモリファイル・MCP ツール・エージェントの数",
187  },
188  {
189    id: 'session.usage/full',
190    section: 'calls',
191    button: '内訳を取る(API を呼ぶ)',
192    cost: 'api',
193    note: "$.session.usage({ breakdown: 'full' }):/context と同じく、分類ごとにトークン数を数える API を呼ぶ(費用がかかる場合がある)",
194  },
195  {
196    id: 'process.run',
197    section: 'calls',
198    button: 'git を実行する',
199    cost: 'process',
200    note: "$.process.run(['git', 'rev-parse', '--short', 'HEAD']):今のコミットの短いハッシュ。exitCode、stdout、stderr の文字数",
201  },
202  {
203    id: 'model.complete',
204    section: 'calls',
205    button: '呼ぶ(トークンを使う)',
206    cost: 'tokens',
207    note: "$.model.complete({ model: 'haiku', prompt, maxTokens: 20 }):短い問いを 1 回送る。トークンを使う。答えの文字と usage を出す",
208  },
209  {
210    id: 'http.fetch',
211    section: 'calls',
212    button: '取りに行く(ネットワーク)',
213    cost: 'network',
214    note: "$.http.fetch('https://example.com/'):ホストを通して取りに行く。status、ok、ヘッダーの数、本文の文字数だけ",
215  },
216  {
217    id: 'mcp.call',
218    section: 'calls',
219    button: 'サーバーとツールを見る(呼ばない)',
220    note: '$.mcp.call は呼ばない:どの MCP ツールに副作用がないかは名前からは分からないため。代わりに $.tool.list() から MCP のツールを拾い、サーバーごとのツールの数を出す',
221  },
222]
223
224/** The view's sections, in order: their keys, headings and one line under each heading. */
225export const SECTIONS = [
226  { id: 'getters', title: '$ の取得系', about: '開いたときと「再取得」で取る。● の付いた値は、この画面を開いている間 5 秒ごとに取り直し、時刻のほかに変わった値があるときだけ描き直す' },
227  { id: 'events', title: 'イベントの入力(最後に受け取ったもの)', about: 'フックが受け取った e の最後の 1 回。どれも e を変えずに次へ渡している' },
228  { id: 'calls', title: 'ボタンで呼ぶもの(費用・副作用あり)', about: '押したときだけ呼ぶ。(トークンを使う)(API を呼ぶ)(ネットワーク)の付いたボタンは費用や通信が発生する' },
229  { id: 'stores', title: 'state / store', about: 'この mod 自身の $.state と $.store を読む(書き込みはしない)' },
230]
231
232// ----- Formatting -----
233
234/** A string cut to `limit` characters, with its full length when cut. */
235export function cut(text, limit) {
236  if (text.length <= limit) return text
237  return `${text.slice(0, limit)}…(全 ${text.length} 文字)`
238}
239
240// The name a list item is known by in a row: a string itself, or its name, id or key
241function nameOf(item) {
242  if (typeof item === 'string') return item
243  if (item && typeof item === 'object') {
244    for (const field of ['name', 'id', 'key', 'kind', 'dir']) {
245      if (typeof item[field] === 'string') return item[field]
246    }
247  }
248  return cut(JSON.stringify(item) ?? String(item), 20)
249}
250
251/**
252 * One value as a row shows it: a string cut to TEXT_LIMIT, a list as `n 件` and its first
253 * names, an object as JSON cut to JSON_LIMIT.
254 */
255export function formatShort(value) {
256  if (value === undefined) return '(なし)'
257  if (value === null) return 'null'
258  if (typeof value === 'string') return cut(value, TEXT_LIMIT)
259  if (typeof value !== 'object') return String(value)
260  if (Array.isArray(value)) {
261    if (value.length === 0) return '0 件'
262    const names = value.slice(0, NAMES_SHOWN).map(nameOf).join(', ')
263    return `${value.length} 件: ${names}${value.length > NAMES_SHOWN ? ' …' : ''}`
264  }
265  return cut(JSON.stringify(value), JSON_LIMIT)
266}
267
268/** One value as its [詳細] shows it: indented JSON cut to FULL_LIMIT. */
269export function formatFull(value) {
270  if (value === undefined) return '(なし)'
271  return cut(JSON.stringify(value, null, 2) ?? String(value), FULL_LIMIT)
272}
273
274/** What the view keeps of one value: the row's text and the [詳細]'s. */
275export function entryOf(value) {
276  return { short: formatShort(value), full: formatFull(value) }
277}
278
279/** What the view keeps of a call that failed. */
280export function errorEntry(error) {
281  const text = String(error?.message ?? error)
282  return { short: 'エラー: ' + cut(text, TEXT_LIMIT), full: text, isError: true }
283}
284
285/** The entry a call button shows while its call runs. */
286export const RUNNING = { short: '実行中…', full: '実行中…' }
287
288// ----- Reducing values to what may be shown -----
289
290/** A tool or command list: names and kinds, descriptions cut. */
291export function toolsOf(list) {
292  return list.map(tool => ({ name: tool.name, mcp: tool.mcp, description: cut(tool.description ?? '', TEXT_LIMIT) }))
293}
294
295export function commandsOf(list) {
296  return list.map(command => ({
297    name: command.name,
298    source: command.source,
299    plugin: command.plugin,
300    description: cut(command.description ?? '', TEXT_LIMIT),
301  }))
302}
303
304export function agentsOf(list) {
305  return list.map(agent => ({ id: agent.id, type: agent.type, status: agent.status, description: cut(agent.description ?? '', TEXT_LIMIT) }))
306}
307
308/** /config's rows: a text value as its length only. */
309export function configOf(rows) {
310  return rows.map(row => ({
311    key: row.key,
312    kind: row.kind,
313    value: typeof row.value === 'string' ? `(文字列 ${row.value.length} 文字)` : row.value,
314    isLocked: row.isLocked,
315    provider: row.provider?.kind ?? row.provider,
316  }))
317}
318
319/** $.session.usage() without a breakdown: as it came, less any breakdown. */
320export function usageOf(usage) {
321  if (!usage || typeof usage !== 'object') return usage
322  const { breakdown, ...context } = usage.context ?? {}
323  return { ...usage, context }
324}
325
326/** A usage with a breakdown: the breakdown's figures and counts, no file contents. */
327export function breakdownOf(usage) {
328  const breakdown = usage?.context?.breakdown
329  if (!breakdown) return { context: usageOf(usage)?.context, breakdown: '(なし)' }
330  return {
331    context: usageOf(usage).context,
332    breakdown: {
333      totalTokens: breakdown.totalTokens,
334      maxTokens: breakdown.maxTokens,
335      percentage: breakdown.percentage,
336      model: breakdown.model,
337      categories: (breakdown.categories ?? []).map(category => ({ name: category.name, tokens: category.tokens })),
338      memoryFiles: (breakdown.memoryFiles ?? []).length,
339      mcpTools: (breakdown.mcpTools ?? []).length,
340      agents: (breakdown.agents ?? []).length,
341      gridRows: (breakdown.gridRows ?? []).length,
342      apiUsage: breakdown.apiUsage ?? null,
343    },
344  }
345}
346
347/** The settings: each top-level key with its value's type and size, never the value. */
348export function settingsShape(settings) {
349  const shape = {}
350  for (const [key, value] of Object.entries(settings ?? {})) shape[key] = shapeOf(value)
351  return { keys: Object.keys(shape).length, shape }
352}
353
354function shapeOf(value) {
355  if (value === null) return 'null'
356  if (Array.isArray(value)) return `配列 ${value.length} 件`
357  if (typeof value === 'object') return `オブジェクト ${Object.keys(value).length} キー`
358  if (typeof value === 'string') return `文字列 ${value.length} 文字`
359  return typeof value
360}
361
362/** Environment variables by name: set with its length, or unset. Never the value. */
363export function envShape(values) {
364  const shape = {}
365  for (const [name, value] of Object.entries(values)) {
366    shape[name] = value === undefined ? '未設定' : `設定あり(${value.length} 文字)`
367  }
368  return shape
369}
370
371/** The transcript: how many messages, and the last one's role. */
372export function messagesShape(found) {
373  if (!Array.isArray(found)) return { deny: found?.deny }
374  return { count: found.length, lastRole: found.at(-1)?.role ?? '(なし)' }
375}
376
377/** A directory listing: how many of each kind, and the names. */
378export function listShape(entries) {
379  const kinds = {}
380  for (const entry of entries) kinds[entry.kind] = (kinds[entry.kind] ?? 0) + 1
381  return { count: entries.length, kinds, names: entries.map(entry => entry.name) }
382}
383
384/** CLAUDE.md files above: their folders and lengths, never their text. */
385export function ancestorsShape(found) {
386  return found.map(file => ({ dir: file.dir, file: file.name, length: (file.content ?? '').length }))
387}
388
389/** An HTTP response: its status and sizes, not its body. */
390export function httpShape(response) {
391  return {
392    status: response.status,
393    ok: response.ok,
394    headers: Object.keys(response.headers ?? {}).length,
395    length: (response.text ?? '').length,
396  }
397}
398
399/** A finished process: its exit code, its stdout (a short hash here), stderr's length. */
400export function processShape(result) {
401  return { exitCode: result.exitCode, stdout: cut((result.stdout ?? '').trim(), TEXT_LIMIT), stderr: (result.stderr ?? '').length }
402}
403
404/** A model completion: the reply cut, its usage, or why there is none. */
405export function modelShape(result) {
406  if (!result.isAnswered) return { isAnswered: false, reason: result.reason, status: result.status, error: result.error }
407  return { isAnswered: true, text: cut(result.text, TEXT_LIMIT), usage: result.usage }
408}
409
410/** MCP servers from the tool list: tool names `mcp__<server>__<tool>`, counted by server. */
411export function mcpShape(tools) {
412  const servers = {}
413  for (const tool of tools) {
414    if (!tool.mcp) continue
415    const match = /^mcp__(.+?)__(.+)$/.exec(tool.name)
416    const server = match ? match[1] : '(不明)'
417    servers[server] = (servers[server] ?? 0) + 1
418  }
419  return { servers: Object.keys(servers).length, tools: servers }
420}
421
422/** What a session's stat shows: kind, size, time, link; realPath is not asked. */
423export function statShape(stat) {
424  return { kind: stat.kind, size: stat.size, mtimeMs: stat.mtimeMs, isLink: stat.isLink }
425}
426
427// ----- Event inputs -----
428
429/** The keys of an object, or none. */
430const keysOf = value => (value && typeof value === 'object' ? Object.keys(value) : [])
431
432/**
433 * One engine event's input reduced to what the view may show: counts and lengths for text a
434 * person wrote or the model answered, key names for a tool's input.
435 */
436export function eventShape(name, e) {
437  switch (name) {
438    case 'session.start':
439      return { cwd: e.cwd, surface: e.surface, isInteractive: e.isInteractive }
440    case 'session.end':
441      return { reason: e.reason, sessionId: e.sessionId, resume: e.resume }
442    case 'session.measure':
443      return {
444        context: { tokens: e.context?.tokens, window: e.context?.window, percent: e.context?.percent },
445        rateLimits: (e.rateLimits ?? []).map(limit => ({ kind: limit.kind, percentUsed: limit.percentUsed, resetsAt: limit.resetsAt })),
446        cost: e.cost,
447        changed: e.changed,
448      }
449    case 'session.attach':
450      return { surface: e.surface, clientId: e.clientId, viewport: e.viewport }
451    case 'session.detach':
452      return { surface: e.surface, clientId: e.clientId, reason: e.reason }
453    case 'session.receive':
454      return { origin: e.origin?.kind, textLength: (e.text ?? '').length, event: e.event, agentId: e.agentId }
455    case 'session.compact':
456      return {
457        trigger: e.trigger,
458        agentId: e.agentId,
459        instructionsLength: e.instructions === undefined ? undefined : e.instructions.length,
460        messages: (e.messages ?? []).length,
461      }
462    case 'prompt.submit':
463      return {
464        textLength: (e.text ?? '').length,
465        attachments: (e.attachments ?? []).length,
466        context: (e.context ?? []).length,
467        turnId: e.turnId,
468        wait: e.wait,
469        origin: e.origin?.kind,
470      }
471    case 'turn.start':
472      return { turnId: e.turnId, textLength: (e.text ?? '').length }
473    case 'turn.complete':
474      return {
475        turnId: e.turnId,
476        agentId: e.agentId,
477        reason: e.reason,
478        isAborted: e.isAborted,
479        durationMs: e.durationMs,
480        answerLength: (e.answer ?? '').length,
481        usage: e.usage,
482      }
483    case 'plugin.register':
484      return { name: e.name, tier: e.tier, version: e.version, provenance: e.provenance, uses: keysOf(e.uses) }
485    default:
486      return { keys: keysOf(e) }
487  }
488}
489
490/** A turn.step: its input, and what the step returned. */
491export function stepShape(e, result) {
492  return {
493    turnId: e.turnId,
494    index: e.index,
495    model: e.model,
496    effort: e.effort,
497    messageCount: e.messageCount,
498    result: result
499      ? {
500          stopReason: result.stopReason,
501          toolUses: (result.toolUses ?? []).length,
502          answerLength: (result.answer ?? '').length,
503          usage: result.usage,
504        }
505      : '(なし)',
506  }
507}
508
509/** A tool call: the tool, its input's key names, and the outcome's kind and size. */
510export function toolCallShape(e, result) {
511  const input = Object.keys(e).filter(key => key !== 'tool' && key !== 'tool_use_id' && key !== 'agentId')
512  let outcome
513  if (!result || typeof result !== 'object') outcome = '(なし)'
514  else if (typeof result.deny === 'string') outcome = { deny: cut(result.deny, TEXT_LIMIT) }
515  else outcome = { isError: result.isError ?? false, textLength: typeof result.text === 'string' ? result.text.length : undefined }
516  return { tool: e.tool, agentId: e.agentId, inputKeys: input, result: outcome }
517}
518
519/** The fields every classic event shares (BaseHookInput); the rest by key name only. */
520const CLASSIC_COMMON = ['session_id', 'transcript_path', 'cwd', 'prompt_id', 'permission_mode', 'agent_id', 'agent_type']
521
522/**
523 * A classic event's input: its common fields, and the names of the rest. PreToolUse's `e`
524 * is the tool call's envelope instead: the tool and its input's key names.
525 */
526export function classicShape(name, e) {
527  if (name === 'classic.PreToolUse') {
528    const { tool, inputKeys } = toolCallShape(e, undefined)
529    return { tool, inputKeys }
530  }
531  const shape = {}
532  for (const field of CLASSIC_COMMON) if (e[field] !== undefined) shape[field] = e[field]
533  if (e.effort?.level !== undefined) shape.effort = e.effort.level
534  shape.otherKeys = Object.keys(e).filter(key => key !== 'hook_event_name' && key !== 'effort' && !CLASSIC_COMMON.includes(key))
535  return shape
536}
537
538// ----- Keys -----
539
540/** An element key from a prefix and a value's id; element keys allow a plain set of characters. */
541export const valueKey = (prefix, id) => prefix + '-' + id.replace(/[^A-Za-z0-9_-]/g, '_')
542
543/**
544 * Whether two getter snapshots differ apart from the clock: the auto refresh writes, and so
545 * redraws the view, only when one does.
546 */
547export function differsApartFromClock(before, after) {
548  const strip = values => JSON.stringify({ ...values, 'clock.now': undefined })
549  return strip(before ?? {}) !== strip(after)
550}
551