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

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 の答え |
標準の表示や会話の行を書き換える場所は、パネルの切り替えでオン・オフできます。 切り替えはそのセッションの間だけ有効で、新しいセッションでは初期値に戻ります。
https://example.com/ に 1 回アクセスするui-sampler-sample.txt を 1 つ作ります。Claude Code 2.1.286(デスクトップアプリ同梱)、2.1.287(ターミナル、validate とエンジンのテストのみ)
hooks/register.js 116 lines1// 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}
116hooks/sites.js 888 lines1// 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}
888hooks/pane.js 837 lines1// 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}
837hooks/elements.js 612 lines1// 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}
612hooks/dialog.js 102 lines1// 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}
102hooks/engine-lines.js 205 lines1// 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}
205hooks/transcript.js 143 lines1// 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}
143hooks/asks.js 90 lines1// 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}
90hooks/events.js 112 lines1// 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}
112hooks/values.js 573 lines1// 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}
573hooks/event-log.js 58 lines1// 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}
58hooks/value-format.js 551 lines1// 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