A whiteboard Claude writes to: titled Markdown cards (steps, running jobs, links) that stay put instead of scrolling away in the chat

Claude が書き込む作業ボードです。手順、動かしている処理、URL、決めたことなど、チャットで流れてしまう情報を、カードにして画面の右に残します。「さっき言っていた手順をもう一回」と聞き直す手間と、そのためのトークンを減らします。
インストール方法は、リポジトリの README を見てください。
Claude に頼みます。
ボードに今日の作業手順を書いて
ボードに今開いているプレビューの URL を残して
ボードの手順 3 は?
ボードの手順 3 を済みにして
ボードのあれを消して
ボードを整理して
頼まなくても、Claude が自分でボードに書くことがあります。カードの書き換えにボタンはありません。
入力欄の上の帯に「ボード 3件」と [開く] が出ます。押すと右にパネルが開き、カードが追加した順に並びます。カードごとに、タイトル、id、最後に書いた時刻、本文が出ます。/whiteboard でも開けます。
常に見ていたいカード(今の手順の全体像、URL の一覧など)は、Claude に頼んで「固定」にできます。固定したカードは上に並びます。
カードは、そのセッションに保存され、アプリを閉じて開き直しても残ります。
本文は Markdown です(見出し、箇条書き、リンク、コード、表)。
`mermaid の囲みに、基本的な flowchart / graph と sequenceDiagram を書くと、パネルで図になります。subgraph などほかの書き方は、コードブロックのまま表示されます。ターミナルでは、いつもコードブロックです。
新しいセッションでカードが 0 件のとき、帯に「引き継げます」と出て、パネルに「前のセッションから引き継ぐ」の枠が出ます。候補は、更新が新しい順に最大 5 件で、日時、フォルダ名(プロジェクトの一番上のフォルダの名前)、枚数、会話の題(なければ最初に打った言葉)、カードのタイトルで見分けられます。
[引き継ぐ] を押すと、そのボードのカードが今のボードに写ります。
handover をオフにすると保存しません)Claude は次のツール(mcp__whiteboard__ で始まります)でボードを操作します。初めて使うときに、許可を求められることがあります。
| ツール | 入力 | 内容 |
|---|---|---|
set_card | id, title, body, pin(省略できる) | カードを追加する。同じ id があれば上書きする。pin で固定する・外す |
edit_card | id, find, replace, all(省略できる) | 本文の一部だけを置き換える。find は本文の中で 1 箇所にだけ一致する長さにする(all: true なら全部) |
append_card | id, text | 本文の末尾に足す |
remove_card | id | カードを 1 枚消す |
clear | なし | カードを全部消す |
list_cards | なし、または titles_only / id / query | ボードを読む。引数なしは全文。titles_only は目次、id はそのカード 1 枚、query は語で探す |
ボードの内容を聞かれたときは、まず titles_only か query で探し、必要なカードだけ id で読みます。このプラグインには、使い方を Claude に教えるスキル whiteboard も入っています。
プラグインの設定(/config)で変えられます。
| 設定 | 既定 | 内容 |
|---|---|---|
handover | オン | カードが 0 件のとき、前のセッションのボードの一覧と [引き継ぐ] を出す。オフにしても、引き継いだあとの元のボードの読み取り専用は保たれる |
guide | off | 新しいセッションの Claude に、ボードがあることを伝える。short: システムプロンプトに毎回同じ短い 1 節(約 120 文字)を足す。full: short に加えて、入力に guideKeywords の語が含まれるときだけ、ツールの使い分けの要点を足す(5 ターンに 1 回まで) |
guideKeywords | ボード,whiteboard | full のきっかけの語(カンマ区切り) |
| 項目 | 上限 |
|---|---|
| カードの枚数 | 20 枚 |
| タイトル | 60 文字 |
| 本文 | 4000 文字 |
| id | 40 文字(英数字と _ -) |
超えると Claude にエラーが返り、Claude が短くするか、済んだカードを消してからやり直します。
Claude Code v2.1.289 から v2.1.293 のデスクトップアプリ(Code タブ)で動作を確認しています。
hooks/register.js 740 lines1// A whiteboard Claude writes to: titled Markdown cards (steps, running jobs, links) that stay put instead of scrolling away in the chat
2//
3// register.js every hook and every $ call: the six tools Claude calls (set_card, edit_card, append_card, remove_card,
4// clear, list_cards), the cards' load and save, the hand-over of another session's
5// board (the list, [引き継ぐ], the seal), the pane and the band, /whiteboard
6// board.js the limits, what each tool does to the cards and answers, the band's fit (pure)
7// boards.js the meta, the other sessions' boards, the hand-over's plan and words, the archive file (pure)
8// shelf.js the store's keys turned into that list (pure)
9// store.js the queue (pure)
10// mermaid.js the mermaid fences drawn as pictures (pure)
11// pane.js what the pane and the band draw (pure)
12// guide.js the fixed text that tells a new session the board exists, and when to repeat the tool lines (pure)
13// press-guard.js runs a pane press again that did not reach its Button (pure)
14//
15// The cards live in $.state for the drawings to read (a write redraws them) and in $.store,
16// keyed by the session id (`board:<id>`, with `meta:<id>` beside it: when and in which folder it
17// was written, what the session began with), so they outlive the app: the same session opened
18// again has them, and a new session's pane offers them for a hand-over. The store is written
19// first; a write it refuses (a store over its size limit) leaves the board as it was and the tool
20// answers an error.
21//
22// The rule for the cards: the store is the truth and $.state is a copy for the drawings. What is
23// read to be written over (the tools, a hand-over) is read from the store, inside `exclusive`. It
24// is never read from $.state: one dispatch (a tool call, a press) reads $.state as it stood at its
25// first read and keeps that, so a press that read $.state and then waited its turn in `exclusive`
26// would write over what a tool wrote meanwhile. For the same reason a press does not read $.state
27// for the cards before it enters `exclusive`. The state's `owner` says whose cards `cards` holds
28// (after /clear the process goes on under another session id), and the drawings do not take cards
29// of another owner.
30//
31// A hand-over copies the cards of another session's board into this one, then seals the other
32// board: a value under `seal:<its id>`. The cards under its `board:` key are never touched. A
33// sealed board is read-only: the tools that write answer a refusal (checked against the store at
34// every call; a seal that cannot be read refuses too), list_cards reads with a first line that
35// says so, and the pane has a button that lifts the seal. The seal holds whether or not the
36// setting `handover` is on.
37//
38// The engine follows $ into the functions of the file it is in and no further, so everything
39// that takes $ is here.
40
41import { LIMITS, storeKey, sanitizeCards, applySet, applyEdit, applyAppend, applyRemove, applyClear, applyList } from './board.js'
42import {
43 setConfig,
44 getConfig,
45 metaKey,
46 sealKey,
47 readMeta,
48 readSeal,
49 mergeMeta,
50 cwdNameOf,
51 shortId,
52 promptLine,
53 planHandover,
54 cardsSig,
55 handoverText,
56 sealedDenyText,
57 sealUnreadableText,
58 SELF_SEALED,
59 sealedListLine,
60 archiveName,
61 archivePath,
62 archiveMarkdown,
63 isStale,
64} from './boards.js'
65import { NO_OTHERS, GONE, OLD_LIST, ALREADY, isMine, isSame, scanEntries, othersOf, droppedText, keptText } from './shelf.js'
66import { makeQueue } from './store.js'
67import { guideModeOf, keywordsOf, withGuide, makeGuideTurns } from './guide.js'
68import { PANE_ID, TITLE, widthOf, drawPane, drawBand } from './pane.js'
69import { GRACE_MS, guardDrawing, beginPress, hasStarted, takeOver, endPress } from './press-guard.js'
70
71// The state this file writes (declared in types/index.d.ts)
72const CARDS = { plugin: 'whiteboard', key: 'cards' }
73const OWNER = { plugin: 'whiteboard', key: 'owner' }
74const OTHERS = { plugin: 'whiteboard', key: 'others' }
75const HANDOVER = { plugin: 'whiteboard', key: 'handover' }
76const VIEW = { plugin: 'whiteboard', key: 'view' }
77
78const NO_HANDOVER = { sealed: null, from: null, last: null }
79const NO_VIEW = { pick: '', shown: false, confirm: null }
80
81// Deleting other sessions' boards ([消す]), the file written before a delete (archiveDir) and the
82// automatic clean-up (autoCleanDays) are built but switched off in this version: with false none of
83// them runs and the pane draws no clean-up list. To bring them back, set true and restore the
84// `userConfig` entries `archiveDir` (string, default "") and `autoCleanDays` (number, default 0)
85// in plugin.json.
86const SHELF_ADMIN_ENABLED = false
87
88const TOOLS = {
89 set_card: {
90 description:
91 'ボード(人が画面の右のパネルで見る作業メモ)に、カードを追加するか上書きする。会話のログに埋もれて流れてしまう情報を残すために使う: ' +
92 '進行中の手順やチェックリスト、並行して動かしている処理の状況、このセッションに関わる URL、決めたこと。' +
93 '同じ id のカードは上書きされ(位置は変わらない)、状況が変わったら同じ id で更新する。1 カード 1 話題にして、本文は要点だけにする。' +
94 '本文の一部だけを変えるときは、全文を書き直さず edit_card(一部の置き換え)か append_card(末尾に足す)を使う。' +
95 '用が済んだカードは remove_card で消す。会話にもう書いたことを、ただ写すためには使わない。' +
96 'body は Markdown。```mermaid の基本的な flowchart / graph(と、参加者と矢印だけの sequenceDiagram)は図になる。それ以外の図(mermaid の別の種類、ASCII など)は、コードブロックとして読める形で表示される(描画はされない)。' +
97 '関係や流れは、箇条書きや表でも伝わる。' +
98 'pin: true は、人が常に見ていたいカード(今の手順の全体像、URL の一覧など)に使う。使いすぎない。固定したカードは一覧の上に並ぶ。' +
99 `上限: ${LIMITS.cards} 枚、title ${LIMITS.title} 文字、body ${LIMITS.body} 文字、id は英数字と _ - の ${LIMITS.id} 文字まで。` +
100 '会話の圧縮などで内容を思い出せないときは list_cards で読み返す。',
101 inputSchema: {
102 type: 'object',
103 properties: {
104 id: { type: 'string', description: `カードの名前。短い英数字と _ -(例: plan, urls, jobs)。${LIMITS.id} 文字まで` },
105 title: { type: 'string', description: `カードの見出し。${LIMITS.title} 文字まで` },
106 body: { type: 'string', description: `本文(Markdown)。${LIMITS.body} 文字まで。空でもよい` },
107 pin: { type: 'boolean', description: 'true で先頭に固定、false で固定を外す。省略すると今のまま(新しいカードは固定なし)' },
108 },
109 required: ['id', 'title', 'body'],
110 },
111 writes: true,
112 apply: (cards, e, now) => applySet(cards, e, now),
113 },
114 edit_card: {
115 description:
116 'ボードのカードの本文の一部だけを書き換える。「手順の 1 項目を済みにする」「リンクの 1 つを直す」のように、数行しか変えないときに使う(全文を書き直すより軽い)。' +
117 'find(本文の中にある文字列。改行を含んでよい)を replace に置き換える。find は、本文の中にちょうど 1 箇所だけ一致する長さにする' +
118 '(0 箇所なら見つからなかった、2 箇所以上なら曖昧だとして断られる)。同じ文字列をすべて置き換えるなら all: true。replace を空にすると find の部分を消す。' +
119 'title、固定、カードの位置は変わらない。カードが無ければ set_card で作る。全体を書き直すときは set_card。' +
120 `置き換えたあとの本文が ${LIMITS.body} 文字を超える場合も断られる。`,
121 inputSchema: {
122 type: 'object',
123 properties: {
124 id: { type: 'string', description: '書き換えるカードの id' },
125 find: { type: 'string', description: '本文の中で置き換える文字列(空でない、改行を含んでよい)。1 箇所だけに一致する長さにする' },
126 replace: { type: 'string', description: '置き換える文字列。空にすると find の部分を消す' },
127 all: { type: 'boolean', description: 'true で、find に一致する箇所をすべて置き換える。省略すると、一致が 1 箇所のときだけ置き換える' },
128 },
129 required: ['id', 'find', 'replace'],
130 },
131 writes: true,
132 apply: (cards, e, now) => applyEdit(cards, e, now),
133 },
134 append_card: {
135 description:
136 'ボードのカードの本文の末尾に、文を足す。「URL を 1 つ足す」「経過を 1 行足す」のように、一部だけ変えるときに使う(全文を書き直すより軽い)。' +
137 '本文が空でなければ、間に改行を 1 つ入れて、新しい行として足す。title、固定、カードの位置は変わらない。' +
138 `カードが無ければ set_card で作る。全体を書き直すときは set_card。足したあとの本文が ${LIMITS.body} 文字を超える場合は断られる。`,
139 inputSchema: {
140 type: 'object',
141 properties: {
142 id: { type: 'string', description: '足すカードの id' },
143 text: { type: 'string', description: '本文の末尾に足す文(Markdown)' },
144 },
145 required: ['id', 'text'],
146 },
147 writes: true,
148 apply: (cards, e, now) => applyAppend(cards, e, now),
149 },
150 remove_card: {
151 description: 'ボードのカードを id で消す。済んだ手順、終わった処理、古くなった URL のカードは、残さず消す。id が無ければ、その旨を返す。',
152 inputSchema: { type: 'object', properties: { id: { type: 'string', description: '消すカードの id' } }, required: ['id'] },
153 writes: true,
154 apply: (cards, e) => applyRemove(cards, e),
155 },
156 clear: {
157 description: 'ボードのカードを全部消す。人から「ボードを空にして」と頼まれたときや、作業が丸ごと終わったときに使う。1 枚だけ消すなら remove_card。',
158 inputSchema: { type: 'object', properties: {} },
159 writes: true,
160 apply: (cards) => applyClear(cards),
161 },
162 list_cards: {
163 description:
164 'ボードのカードを読む。引数なしは全カードの id・title・body を全文で返すので重い。' +
165 'まず titles_only: true(目次: id・title・本文の文字数)か query(検索)で探し、必要なカードだけ id で読む。' +
166 '利用者が「ボードの手順 3 は?」のようにボードの内容を尋ねたときも、この順で探す。' +
167 '会話の圧縮のあとなどに、何を書いたか思い出すためにも使う。固定したカードが先に並び、固定のカードには pinned: true が付く。' +
168 'id が最優先で、あれば他は無視する(そのカード 1 枚の id・title・body)。' +
169 'query は空白で分けた語をすべて含むカードを返す(id・title・body のどれか、大文字小文字は区別せず、日本語は部分一致)。各カードに、一致した行の番号(本文の 1 始まり。title か id だけの一致は 0)と、その行の先頭 120 文字を最大 5 件付ける。titles_only も一緒にあれば、その一致の行は付けず title だけにする。',
170 inputSchema: {
171 type: 'object',
172 properties: {
173 titles_only: { type: 'boolean', description: 'true で各カードの id・title・chars(本文の文字数)だけを返す。目次として軽く読む' },
174 id: { type: 'string', description: 'このカード 1 枚の id・title・body だけを返す' },
175 query: { type: 'string', description: '検索語。空白で分けた語をすべて含むカードを返す(大文字小文字は区別しない、日本語は部分一致)' },
176 },
177 },
178 writes: false,
179 apply: (cards, e) => applyList(cards, e),
180 },
181}
182
183// The cards have a queue (the tools, a hand-over, the start's load); the other sessions' list has
184// one of its own, so a tool never waits on it; the small state values (`handover`, `view`) have a third
185const exclusive = makeQueue()
186const exclusiveOthers = makeQueue()
187const exclusiveState = makeQueue()
188
189const messageOf = (error) => String(error?.message ?? error)
190const isText = (s) => typeof s === 'string' && s !== ''
191
192// What this load has heard of each session (by id) to write into its meta at the next save: the
193// first request the person made and the latest title
194const noted = new Map()
195
196// The setting `handover` (on unless false) and what it turns on: reading the other boards
197const handoverOn = () => getConfig().handover
198const wantsOthers = () => handoverOn() || SHELF_ADMIN_ENABLED
199
200// The cards of the session `id` as the store has them: the truth (see the top of this file)
201async function storedCards($, id) {
202 return sanitizeCards(await $.store.get(storeKey(id)))
203}
204
205// The cards of this session, from the store; $.state is brought in line when it differs (cards or
206// owner), for the drawings. Call it inside `exclusive`, not from a press before it waits there.
207async function loadCards($) {
208 const id = await $.session.id()
209 const cards = await storedCards($, id)
210 if (!isSame((await $.state.get(CARDS)).value, cards)) await $.state.set(CARDS, cards)
211 if ((await $.state.get(OWNER)).value !== id) await $.state.set(OWNER, id)
212 return cards
213}
214
215// Writes the cards to the store and then to $.state; the drawings read the state. The meta goes
216// with them, laid over the one there (and is deleted with an empty board's key, which also ends
217// the record of where the cards came from); `extra` adds to the meta. A meta the store refuses is
218// let go, since the list copes with a board that has none. The seal is not part of the meta and is
219// never touched here.
220async function saveCards($, cards, extra = {}) {
221 const id = await $.session.id()
222 const key = storeKey(id)
223 if (cards.length === 0) {
224 await $.store.delete(key)
225 try {
226 await $.store.delete(metaKey(id))
227 } catch {}
228 await patchHandover($, { from: null })
229 } else {
230 await $.store.set(key, cards)
231 try {
232 const prev = readMeta(await $.store.get(metaKey(id)))
233 const note = noted.get(id)
234 const patch = { updatedAt: await $.clock.now(), cwdName: await folderName($), count: cards.length, firstPrompt: note?.firstPrompt, title: note?.title, ...extra }
235 await $.store.set(metaKey(id), mergeMeta(prev, patch))
236 } catch {}
237 }
238 await $.state.set(CARDS, cards)
239 await $.state.set(OWNER, id)
240}
241
242// The folder a board is shown under: the last folder name of the session root (not of the working
243// folder, which moves into subfolders), so one project keeps one name. Stored as `cwdName`.
244async function folderName($) {
245 return cwdNameOf(await $.session.root())
246}
247
248// ---- The small state values
249
250const objectOr = (value, fallback) => (value != null && typeof value === 'object' && !Array.isArray(value) ? value : fallback)
251
252// The hand-over's state as $.state holds it; a value of an older shape is filled with the defaults
253async function readHandover($) {
254 const { value } = await $.state.get(HANDOVER)
255 const v = objectOr(value, {})
256 return { sealed: objectOr(v.sealed, null), from: objectOr(v.from, null), last: objectOr(v.last, null) }
257}
258
259async function readView($) {
260 const { value } = await $.state.get(VIEW)
261 const v = objectOr(value, {})
262 return { pick: typeof v.pick === 'string' ? v.pick : '', shown: v.shown === true, confirm: objectOr(v.confirm, null) }
263}
264
265// Writes a state value only when it differs from what is there (a write redraws the drawings).
266// The engine wants the reference spelled out at each call, so one function for each value.
267async function setHandover($, next) {
268 const { value } = await $.state.get(HANDOVER)
269 if (!isSame(value, next)) await $.state.set(HANDOVER, next)
270}
271async function setView($, next) {
272 const { value } = await $.state.get(VIEW)
273 if (!isSame(value, next)) await $.state.set(VIEW, next)
274}
275async function setOthers($, next) {
276 const { value } = await $.state.get(OTHERS)
277 if (!isSame(value, next)) await $.state.set(OTHERS, next)
278}
279
280// A change to one of the two small values
281function patchHandover($, patch) {
282 return exclusiveState(async () => setHandover($, { ...(await readHandover($)), ...patch }))
283}
284function patchView($, patch) {
285 return exclusiveState(async () => setView($, { ...(await readView($)), ...patch }))
286}
287
288// The cards for a drawing: the state's, when it holds this session's. Cards of another owner (the
289// process went on under a new session id after /clear) are not drawn: the store's are read instead
290// (a drawing may read the store, but may not write the state).
291async function cardsOf($) {
292 const { value } = await $.state.get(CARDS)
293 const id = await $.session.id()
294 if (Array.isArray(value) && (await $.state.get(OWNER)).value === id) return value
295 try {
296 return await storedCards($, id)
297 } catch {
298 return []
299 }
300}
301
302// The time, with the clock's own call; the system's if that fails (a drawing must not fail on it)
303async function nowOf($) {
304 try {
305 return await $.clock.now()
306 } catch {
307 return Date.now()
308 }
309}
310
311// ---- The seal
312
313// The seal of the board of the session `sid` (the `seal:` value, else a 0.9.0 meta's `handedOver`)
314// and its meta: { sealed, meta }. It throws when the store cannot be read: no caller takes that
315// for "not sealed".
316async function readSealState($, sid) {
317 const seal = readSeal(await $.store.get(sealKey(sid)))
318 const meta = readMeta(await $.store.get(metaKey(sid)))
319 return { sealed: seal ?? meta?.handedOver ?? null, meta }
320}
321
322// Takes the seal and the record of where the cards came from from this session's store into the
323// state. The record is kept while the meta has none (a meta the store refused).
324async function syncOwn($) {
325 const { sealed, meta } = await readSealState($, await $.session.id())
326 await patchHandover($, { sealed, ...(meta?.handedFrom ? { from: meta.handedFrom } : {}) })
327}
328
329// [このセッションで書けるように戻す]: lifts the seal; the other session's cards stay. In the cards'
330// queue, so that no tool call is half-way through while it goes.
331function unseal($) {
332 return exclusive(async () => {
333 let patch
334 try {
335 const id = await $.session.id()
336 const { meta } = await readSealState($, id)
337 await $.store.delete(sealKey(id))
338 // A 0.9.0 seal sits in the meta
339 if (meta?.handedOver) await $.store.set(metaKey(id), mergeMeta(meta, { handedOver: null }))
340 patch = { sealed: null, last: { text: '書けるように戻しました。このボードは、また引き継ぎの候補に出ます' } }
341 } catch (error) {
342 patch = { last: { text: `書けるように戻せませんでした: ${messageOf(error)}` } }
343 }
344 await patchHandover($, patch)
345 })
346}
347
348// ---- The other sessions' boards
349
350// Every other session's board in the store: { entries, bytes }
351async function scanStore($) {
352 const keys = await $.store.keys()
353 const values = new Map()
354 for (const key of keys) if (isMine(key)) values.set(key, await $.store.get(key))
355 return scanEntries(await $.session.id(), keys, values)
356}
357
358// Reads the other boards from the store into $.state (`others`), with `notice` as the line the
359// frame shows above the list ('' for none); written only if it differs from what is there
360function refreshOthers($, notice) {
361 if (!wantsOthers()) return Promise.resolve()
362 return exclusiveOthers(async () => {
363 const options = { me: await $.session.id(), cwdName: await folderName($), notice, handover: handoverOn(), admin: SHELF_ADMIN_ENABLED }
364 const next = othersOf(await scanStore($), options)
365 await setOthers($, next)
366 })
367}
368
369// The other boards as the state holds them (a value of an older shape is filled with the defaults)
370async function readOthers($) {
371 if (!wantsOthers()) return NO_OTHERS
372 const { value } = await $.state.get(OTHERS)
373 if (value == null || typeof value !== 'object' || !Array.isArray(value.boards)) return NO_OTHERS
374 // A row of an older shape (no title list) is left out rather than drawn wrong
375 return { ...NO_OTHERS, ...value, boards: value.boards.filter((b) => b != null && Array.isArray(b.allTitles)) }
376}
377
378// The board of the short id a button carries, as the lists hold it
379async function listed($, sid8) {
380 const others = await readOthers($)
381 return others.boards.find((b) => b.sid8 === sid8) ?? others.cleanup.find((b) => b.sid8 === sid8)
382}
383
384// ---- The hand-over
385
386// What the confirmation step keeps of a plan: the cards it names, not the cards themselves
387const slim = ({ added, duplicates, overflow }) => ({ added, duplicates, overflow })
388
389// Copies the cards of the board `row` in after this session's, then seals it (the `seal:` key of
390// the source; its cards and meta are not touched). With cards on this board, a first call
391// (`confirm` null) only stages the confirmation; the call that comes from [この内容で引き継ぐ]
392// carries the staged `confirm` and goes on if the boards are as they were, else stages it again.
393// This session's own seal and cards are read inside `exclusive`, from the store. Answers the line
394// for the frame ('' when there is none).
395async function handOver($, row, confirm) {
396 const me = await $.session.id()
397 const source = sanitizeCards(await $.store.get(storeKey(row.sid)))
398 if (source.length === 0) return GONE
399 const taken = (await readSealState($, row.sid)).sealed
400 if (taken && taken.to !== me) return ALREADY(taken)
401 const now = await $.clock.now()
402 const cwdName = await folderName($)
403 return exclusive(async () => {
404 // This board may have been sealed since the pane last looked: nothing is written to it then
405 const own = (await readSealState($, me)).sealed
406 if (own) {
407 await patchHandover($, { sealed: own, last: { text: SELF_SEALED } })
408 await patchView($, { confirm: null })
409 return SELF_SEALED
410 }
411 const current = await storedCards($, me)
412 const plan = planHandover(current, source, LIMITS.cards)
413 const sig = cardsSig(current) + '|' + cardsSig(source)
414 const confirmed = confirm !== null && confirm.sig === sig
415 if ((current.length > 0 && !confirmed) || plan.added.length === 0) {
416 // shown: the step is seen even when the board had no cards at the press and got one since
417 await patchView($, { shown: true, confirm: { sid8: row.sid8, sig, plan: slim(plan), recounted: confirm !== null && !confirmed } })
418 return ''
419 }
420 // The copy first, then the seal: a seal without a copy is never left behind
421 const handedFrom = { sid: row.sid, sid8: row.sid8, cwdName: row.cwdName, updatedAt: row.updatedAt, count: row.count, at: now }
422 await saveCards($, plan.cards, { handedFrom })
423 let text = handoverText(plan, row, now)
424 try {
425 await $.store.set(sealKey(row.sid), { to: me, toSid8: shortId(me), toCwdName: cwdName, at: now })
426 // Another session may have taken the same board over at the same moment
427 const after = (await readSealState($, row.sid)).sealed
428 if (after && after.to !== me) {
429 text += `同じボードを、ほぼ同時に別のセッション(${after.toCwdName} · ID ${after.toSid8})も引き継ぎました。どちらにも写しがあります`
430 }
431 } catch (error) {
432 text = `カードは写しましたが、元のボードを読み取り専用にできませんでした(${messageOf(error)})。元のボードは、この一覧に残ります`
433 }
434 await patchView($, NO_VIEW)
435 await patchHandover($, { from: handedFrom, last: { text } })
436 return ''
437 })
438}
439
440// [引き継ぐ] and [この内容で引き継ぐ]: the board of `sid8` as the list holds it
441async function take($, sid8, confirm) {
442 let notice
443 try {
444 const row = await listed($, sid8)
445 notice = !row || row.sid === (await $.session.id()) ? OLD_LIST : await handOver($, row, confirm)
446 } catch (error) {
447 notice = `引き継げませんでした: ${messageOf(error)}`
448 }
449 await refreshOthers($, notice).catch(() => {})
450}
451
452async function takeConfirm($) {
453 const { confirm } = await readView($)
454 if (confirm !== null) await take($, confirm.sid8, confirm)
455}
456
457// [中身を見る] / [閉じる]: one board is open at a time
458async function peek($, sid8) {
459 const view = await readView($)
460 await patchView($, { pick: view.pick === sid8 ? '' : sid8, confirm: null })
461}
462
463// [表示] / [隠す] on a board that has cards
464async function toggleShown($) {
465 const view = await readView($)
466 await patchView($, { shown: !view.shown, pick: '', confirm: null })
467}
468
469// ---- The clean-up (switched off in this version, SHELF_ADMIN_ENABLED)
470
471// A board's file when archiveDir is set: { shown: the file's name } once written, { shown: '' }
472// when no folder is set, { shown, error } when it could not be written
473async function archive($, { sid, updatedAt, cwdName, cards }) {
474 const dir = getConfig().archiveDir
475 if (dir === '') return { shown: '' }
476 const name = archiveName(sid, updatedAt)
477 try {
478 await $.fs.write(archivePath(dir, await $.session.root(), name), archiveMarkdown({ sid, updatedAt, cwdName, cards }))
479 return { shown: name }
480 } catch (error) {
481 return { shown: name, error: messageOf(error) }
482 }
483}
484
485// Deletes a board's two keys, after its file when archiveDir is set; nothing is deleted if the
486// file could not be written
487async function removeBoard($, row, stored) {
488 const written = await archive($, { sid: row.sid, updatedAt: row.updatedAt, cwdName: row.cwdName, cards: sanitizeCards(stored) })
489 if (written.error !== undefined) return written
490 await $.store.delete(storeKey(row.sid))
491 try {
492 await $.store.delete(metaKey(row.sid))
493 await $.store.delete(sealKey(row.sid))
494 } catch {}
495 return written
496}
497
498// [消す]: deletes another session's board, writing its file first when archiveDir is set
499async function dropBoard($, sid8) {
500 let notice
501 try {
502 const row = await listed($, sid8)
503 if (!row || row.sid === (await $.session.id())) notice = OLD_LIST
504 else {
505 const stored = await $.store.get(storeKey(row.sid))
506 if (stored === undefined) notice = GONE
507 else {
508 const done = await removeBoard($, row, stored)
509 notice = done.error !== undefined ? keptText(done.shown, done.error) : droppedText(row, done.shown)
510 }
511 }
512 } catch (error) {
513 notice = `消せませんでした: ${messageOf(error)}`
514 }
515 await refreshOthers($, notice).catch(() => {})
516}
517
518// At the session's start, with autoCleanDays set: the other sessions' boards last written more
519// than that many days ago are deleted, their files written first when archiveDir is set (one that
520// cannot be written stays). This session's board is never touched, nor one whose time is unknown.
521// A failure is a line in the debug log.
522async function autoClean($) {
523 if (!SHELF_ADMIN_ENABLED) return
524 const days = getConfig().autoCleanDays
525 if (days < 1) return
526 const now = await $.clock.now()
527 const { entries } = await scanStore($)
528 for (const row of entries) {
529 if (!isStale(row.updatedAt, now, days)) continue
530 try {
531 const stored = await $.store.get(storeKey(row.sid))
532 if (stored === undefined) continue
533 const done = await removeBoard($, row, stored)
534 if (done.error !== undefined) $.ui.log(`whiteboard: 古いボード ${row.sid8} を書き出せなかったので消していません: ${done.error}`, { to: 'debug' })
535 } catch (error) {
536 $.ui.log(`whiteboard: 古いボード ${row.sid8} を消せませんでした: ${messageOf(error)}`, { to: 'debug' })
537 }
538 }
539}
540
541// ---- What the drawings read, and the entrances
542
543// Opens the pane: /whiteboard and the band's button. The seal and the other sessions' boards are
544// read again first (and the line about the last action is let go, the pane's view starts afresh),
545// so the pane is as the store has it now.
546async function openPane($) {
547 try {
548 await syncOwn($)
549 } catch {}
550 try {
551 await refreshOthers($, '')
552 } catch {}
553 try {
554 await patchHandover($, { last: null })
555 await patchView($, NO_VIEW)
556 } catch {}
557 return $.ui.open({ id: PANE_ID, title: TITLE })
558}
559
560// What a tool call does. Every call looks at the seal in the store first: a write to a sealed
561// board is refused before it touches the cards, list_cards reads on and says the board is sealed.
562// A seal that cannot be read is not "no seal": a write is refused then (list_cards reads on).
563async function runTool($, tool, e) {
564 try {
565 const outcome = await exclusive(async () => {
566 let sealed = null
567 let isKnown = true
568 try {
569 sealed = (await readSealState($, await $.session.id())).sealed
570 } catch (error) {
571 if (tool.writes) return { error: sealUnreadableText(messageOf(error)) }
572 isKnown = false
573 }
574 if (isKnown) await patchHandover($, { sealed })
575 if (sealed && tool.writes) return { error: sealedDenyText(sealed) }
576 const before = await loadCards($)
577 const change = tool.apply(before, e, await $.clock.now())
578 if (!change.error && change.cards !== before) await saveCards($, change.cards)
579 return sealed && !change.error ? { ...change, text: sealedListLine(sealed) + '\n' + change.text } : change
580 })
581 return outcome.error ? { deny: outcome.error } : { result: outcome.text }
582 } catch (error) {
583 return { deny: `${tool.writes ? 'ボードを保存できませんでした' : 'ボードを読めませんでした'}: ${messageOf(error)}` }
584 }
585}
586
587// The person's own words and the session's title, kept for the meta (the first request once).
588// Nothing is kept while the setting `handover` is off: they are for the list of boards to take over.
589async function noteRequest($, e) {
590 if (!handoverOn()) return
591 try {
592 const id = await $.session.id()
593 const note = noted.get(id) ?? {}
594 if (isText(e.session_title)) note.title = promptLine(e.session_title)
595 if ((e.source === undefined || e.source === 'user') && note.firstPrompt === undefined) {
596 const line = promptLine(e.prompt)
597 if (line !== '') note.firstPrompt = line
598 }
599 noted.set(id, note)
600 } catch {}
601}
602
603export function register(on, options) {
604 setConfig(options)
605 noted.clear()
606 const guideMode = guideModeOf(options)
607 // The turns' count lives in this load only (a hot reload starts it again)
608 const guideTurns = makeGuideTurns(keywordsOf(options))
609
610 // Fires on the session's start, and again after a hot reload
611 on('session.start', async ($, e, next) => {
612 for (const [name, tool] of Object.entries(TOOLS)) {
613 await $.tool.register({ name, description: tool.description, inputSchema: tool.inputSchema })
614 }
615 await $.command.register({ name: 'whiteboard', description: 'ボードを開く' })
616 try {
617 await exclusive(() => loadCards($))
618 } catch {}
619 // The seal and the record of where the cards came from, which the store keeps
620 try {
621 await syncOwn($)
622 } catch {}
623 // Old boards of other sessions (autoCleanDays, switched off in this version)
624 try {
625 await autoClean($)
626 } catch (error) {
627 $.ui.log(`whiteboard: 古いボードの掃除に失敗しました: ${messageOf(error)}`, { to: 'debug' })
628 }
629 // With the board empty, the boards that may be taken over, once (the band says so); with
630 // cards, they are read when the pane is opened (the clean-up list is read at the start too)
631 try {
632 if (SHELF_ADMIN_ENABLED || (await cardsOf($)).length === 0) await refreshOthers($, '')
633 } catch {}
634 return next(e)
635 })
636
637 // One hook per tool, the matcher written out so that validate can list it
638 on('tool.call', { tool: 'mcp__whiteboard__set_card' }, ($, e) => runTool($, TOOLS.set_card, e))
639 on('tool.call', { tool: 'mcp__whiteboard__edit_card' }, ($, e) => runTool($, TOOLS.edit_card, e))
640 on('tool.call', { tool: 'mcp__whiteboard__append_card' }, ($, e) => runTool($, TOOLS.append_card, e))
641 on('tool.call', { tool: 'mcp__whiteboard__remove_card' }, ($, e) => runTool($, TOOLS.remove_card, e))
642 on('tool.call', { tool: 'mcp__whiteboard__clear' }, ($, e) => runTool($, TOOLS.clear, e))
643 on('tool.call', { tool: 'mcp__whiteboard__list_cards' }, ($, e) => runTool($, TOOLS.list_cards, e))
644
645 // The guide: one fixed section at the end of the system prompt, the same text on every call
646 // (nothing of the cards in it), so the prompt cache is not spent. Off: nothing is added.
647 on('prompt.compose', async ($, e, next) => withGuide(await next(e), e.traits, guideMode))
648
649 // The person's first request and the session's title are kept for the meta (the list tells
650 // boards apart by them). full: when the person's message holds a keyword, the lines on which
651 // tool to use are handed to the model with it (additionalContext); the message itself is not
652 // changed. Not more than once in a few turns. Only the person's own messages count.
653 on('classic.UserPromptSubmit', async ($, e, next) => {
654 await noteRequest($, e)
655 const result = await next(e)
656 if (guideMode !== 'full' || (e.source !== undefined && e.source !== 'user')) return result
657 const lines = guideTurns(e.prompt)
658 if (lines === '') return result
659 return { ...result, additionalContext: [...(result?.additionalContext ?? []), lines] }
660 })
661
662 // /whiteboard opens the pane (a helper: the band's button is the way in)
663 on('command.run', { command: 'whiteboard' }, async ($) => {
664 const opened = await openPane($)
665 return { text: opened?.isPlaced === false ? 'ボードを開きました(まだ表示されていません)' : 'ボードを開きました' }
666 })
667
668 on('ui.render', { component: 'Pane', requestId: 'whiteboard' }, async ($, e) => {
669 // The Buttons' presses are guarded (press-guard.js): the first press on an unfocused pane can be lost
670 const guard = guardDrawing(e.requestId)
671 const ui = guard.wrap($.ui.resolve(e))
672 const data = {
673 cards: await cardsOf($),
674 others: await readOthers($),
675 handover: await readHandover($),
676 view: await readView($),
677 enabled: handoverOn(),
678 admin: SHELF_ADMIN_ENABLED,
679 now: await nowOf($),
680 }
681 const actions = {
682 peek: (sid8) => peek($, sid8),
683 take: (sid8) => take($, sid8, null),
684 takeConfirm: () => takeConfirm($),
685 takeCancel: () => patchView($, { confirm: null }),
686 toggleShown: () => toggleShown($),
687 unseal: () => unseal($),
688 dropBoard: (sid8) => dropBoard($, sid8),
689 }
690 return guard.done(drawPane(ui, data, { surface: e.surface, columns: widthOf(e) }, actions))
691 })
692
693 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
694 const rest = await next(e)
695 if (e.props.hasSurvey) return rest
696 const cards = await cardsOf($)
697 const sealed = (await readHandover($)).sealed !== null
698 const offered = !sealed && cards.length === 0 && handoverOn() && (await readOthers($)).boards.length > 0
699 const hint = sealed ? '読み取り専用' : offered ? '引き継げます' : ''
700 const ui = $.ui.resolve(e)
701 const mine = drawBand(ui, e.surface, widthOf(e), cards.length, hint, () => openPane($))
702 if (!rest) return mine
703 return ui.Box({ flexDirection: 'column', children: [mine, rest] })
704 })
705
706 // A press on this mod's elements (the pane and the band). On the pane (press-guard.js) it also
707 // checks that the press reached the Button's onPress; when the chain settled, threw, or stayed
708 // silent for GRACE_MS without it, the press runs once with the latest drawing's closure under
709 // the same key.
710 on('ui.press', { plugin: 'whiteboard' }, async ($, e, next) => {
711 const record = beginPress(e)
712 const chain = next(e).then(
713 (value) => ({ kind: 'value', value }),
714 (error) => ({ kind: 'error', error }),
715 )
716 let outcome
717 if (record) {
718 const timer = new AbortController()
719 const grace = $.clock.sleep(GRACE_MS, { signal: timer.signal }).then(
720 () => ({ kind: 'grace' }),
721 () => new Promise(() => {}),
722 )
723 outcome = await Promise.race([chain, grace])
724 timer.abort()
725 if (outcome.kind === 'grace' && hasStarted(record)) outcome = await chain
726 } else {
727 outcome = await chain
728 }
729 if (!record || hasStarted(record)) {
730 if (record) endPress(e, record)
731 if (outcome.kind === 'error') throw outcome.error
732 return outcome.value
733 }
734 try {
735 await takeOver(record, e)
736 } catch {}
737 return { element: e.element }
738 })
739}
740hooks/board.js 264 lines1// The board's rules, kept apart from the host calls: what a card is, the limits, how the six
2// tools change a list of cards and what they answer, and how the band fits a narrow line.
3// Pure: no $ here, so register.js keeps every $ call itself.
4
5/** What the board holds at most; a call over a limit is refused with the reason. */
6export const LIMITS = {
7 cards: 20,
8 id: 40,
9 title: 60,
10 body: 4000,
11}
12
13/** Ids are short and plain, so Claude can name a card again without guessing at its spelling. */
14export const ID_PATTERN = /^[A-Za-z0-9_-]+$/
15
16/** The $.store key a session's cards are kept under. */
17export const storeKey = (sessionId) => 'board:' + sessionId
18
19const isCard = (c) =>
20 c != null &&
21 typeof c === 'object' &&
22 typeof c.id === 'string' &&
23 c.id.length > 0 &&
24 c.id.length <= LIMITS.id &&
25 ID_PATTERN.test(c.id) &&
26 typeof c.title === 'string' &&
27 typeof c.body === 'string' &&
28 typeof c.updatedAt === 'number'
29
30/**
31 * The cards of a stored value: the well-formed ones, each id once, up to the limit; [] for anything
32 * else. `pinned` is kept only as true; any other value is dropped (the card stays, unpinned).
33 */
34export function sanitizeCards(value) {
35 if (!Array.isArray(value)) return []
36 const seen = new Set()
37 const cards = []
38 for (const c of value) {
39 if (!isCard(c) || seen.has(c.id)) continue
40 seen.add(c.id)
41 cards.push({ id: c.id, title: c.title, body: c.body, updatedAt: c.updatedAt, ...(c.pinned === true ? { pinned: true } : {}) })
42 if (cards.length >= LIMITS.cards) break
43 }
44 return cards
45}
46
47// ---- The tools: each takes the cards and the call's input, and answers { cards, text } (the
48// new list and the short reply) or { error } (the call is refused with this text). A call that
49// changes nothing answers the same `cards` array it was given.
50
51/** The id of an input, or the reason it cannot be one. */
52function idOf(input) {
53 const id = typeof input?.id === 'string' ? input.id.trim() : ''
54 if (id === '') return { error: 'id が空です。短い英数字の id を指定してください(例: plan, urls)' }
55 if (id.length > LIMITS.id || !ID_PATTERN.test(id)) {
56 return { error: `id は英数字と _ - だけで ${LIMITS.id} 文字までです(受け取ったのは ${id.length} 文字)。例: plan, urls` }
57 }
58 return { id }
59}
60
61/** The cards as the pane shows them: the pinned ones first, then the others; each group in the order added. */
62export const orderedCards = (cards) => [...cards.filter((c) => c.pinned === true), ...cards.filter((c) => c.pinned !== true)]
63
64/** set_card: adds a card, or overwrites the one with the same id in place. `pin` fixes it to the top (true), frees it (false), or leaves it as it was (absent). */
65export function applySet(cards, input, now) {
66 const { id, error } = idOf(input)
67 if (error) return { error }
68 const title = typeof input.title === 'string' ? input.title.trim() : ''
69 if (title === '') return { error: 'title が空です。カードの見出しを指定してください' }
70 if (title.length > LIMITS.title) return { error: `title は ${LIMITS.title} 文字までです(受け取ったのは ${title.length} 文字)。短くしてください` }
71 const body = typeof input.body === 'string' ? input.body : ''
72 if (body.length > LIMITS.body) {
73 return { error: `body は ${LIMITS.body} 文字までです(受け取ったのは ${body.length} 文字)。要点に絞るか、カードを分けてください` }
74 }
75 if (input.pin !== undefined && typeof input.pin !== 'boolean') return { error: 'pin は true か false で指定してください(省略すると今のまま)' }
76 const index = cards.findIndex((c) => c.id === id)
77 const was = index >= 0 && cards[index].pinned === true
78 const is = input.pin === undefined ? was : input.pin
79 const card = { id, title, body, updatedAt: now, ...(is ? { pinned: true } : {}) }
80 const pinNote = is === was ? '' : is ? '。固定しました' : '。固定を外しました'
81 if (index < 0) {
82 if (cards.length >= LIMITS.cards) {
83 return { error: `カードは ${LIMITS.cards} 枚までで、いっぱいです。済んだカードを remove_card で消してから追加してください` }
84 }
85 return { cards: [...cards, card], text: `追加しました: ${id}(全 ${cards.length + 1} 件)${pinNote}` }
86 }
87 return { cards: cards.map((c, i) => (i === index ? card : c)), text: `上書きしました: ${id}(全 ${cards.length} 件)${pinNote}` }
88}
89
90/** The card's body with a new text, or the reason it cannot be: the body over the limit is refused with the excess. */
91function withBody(cards, index, body, now) {
92 if (body.length > LIMITS.body) {
93 return { error: `本文が上限の ${LIMITS.body} 文字を ${body.length - LIMITS.body} 文字超えます(${body.length} 文字になります)。要点に絞るか、カードを分けてください` }
94 }
95 // Title, pin and position stay; only the body and the time change
96 return { cards: cards.map((c, i) => (i === index ? { ...c, body, updatedAt: now } : c)) }
97}
98
99const noCardText = (id) => `${id} というカードはありません。set_card で作ってください`
100
101/**
102 * edit_card: replaces `find` in the card's body with `replace`. Like an editor's replace: it
103 * must match in exactly one place, unless `all` is true (every place). A `find` that is nowhere
104 * in the body, or in several places without `all`, is refused. `replace` may be empty (deletes).
105 */
106export function applyEdit(cards, input, now) {
107 const { id, error } = idOf(input)
108 if (error) return { error }
109 if (typeof input.find !== 'string' || input.find === '') return { error: 'find が空です。本文の中で置き換えたい文字列を指定してください' }
110 if (typeof input.replace !== 'string') return { error: 'replace は文字列で指定してください(空にすると find の部分を消します)' }
111 if (input.all !== undefined && typeof input.all !== 'boolean') return { error: 'all は true か false で指定してください' }
112 const index = cards.findIndex((c) => c.id === id)
113 if (index < 0) return { cards, text: noCardText(id) }
114 const parts = cards[index].body.split(input.find)
115 const found = parts.length - 1
116 if (found === 0) return { error: `${id} の本文に find が見つかりませんでした。list_cards の id で本文を読んで、一字一句合わせてください(改行も含めて)` }
117 if (found > 1 && input.all !== true) {
118 return { error: `find が ${id} の本文に ${found} 箇所あります。すべて置き換えるなら all: true、1 箇所だけなら前後も含めて一意になる find にしてください` }
119 }
120 const body = (input.all === true ? parts : [parts[0], parts.slice(1).join(input.find)]).join(input.replace)
121 const changed = withBody(cards, index, body, now)
122 if (changed.error) return changed
123 return { cards: changed.cards, text: `編集しました: ${id}(${input.all === true ? found : 1} 箇所)` }
124}
125
126/**
127 * append_card: adds `text` to the end of the card's body, on a line of its own (one newline
128 * between, none when the body is empty or already ends with one).
129 */
130export function applyAppend(cards, input, now) {
131 const { id, error } = idOf(input)
132 if (error) return { error }
133 if (typeof input.text !== 'string' || input.text === '') return { error: 'text が空です。足したい文を指定してください' }
134 const index = cards.findIndex((c) => c.id === id)
135 if (index < 0) return { cards, text: noCardText(id) }
136 const old = cards[index].body
137 const body = old + (old === '' || old.endsWith('\n') ? '' : '\n') + input.text
138 const changed = withBody(cards, index, body, now)
139 if (changed.error) return changed
140 return { cards: changed.cards, text: `追記しました: ${id}(本文 ${body.length} 文字)` }
141}
142
143/** remove_card: removes the card with this id; says so when there is none. */
144export function applyRemove(cards, input) {
145 const { id, error } = idOf(input)
146 if (error) return { error }
147 if (!cards.some((c) => c.id === id)) return { cards, text: `${id} というカードはありません` }
148 const rest = cards.filter((c) => c.id !== id)
149 return { cards: rest, text: `削除しました: ${id}(残り ${rest.length} 件)` }
150}
151
152/** clear: removes every card. */
153export function applyClear(cards) {
154 if (cards.length === 0) return { cards, text: 'ボードはすでに空です' }
155 return { cards: [], text: `全部で ${cards.length} 件を削除しました` }
156}
157
158/** What list_cards' search shows of a match: at most this many lines a card, each cut to this many characters. */
159export const SEARCH = { matches: 5, text: 120 }
160
161const cut = (line, max) => {
162 const chars = [...String(line)]
163 return chars.length > max ? chars.slice(0, max - 1).join('') + '…' : String(line)
164}
165const pinnedPart = (c) => (c.pinned === true ? { pinned: true } : {})
166
167/** The matches of a card for these (lower-cased) terms: the title or id first (line 0), then the body lines holding any term. */
168function matchesOf(card, terms) {
169 const out = []
170 const holds = (text) => terms.some((t) => text.toLowerCase().includes(t))
171 if (holds(card.title) || holds(card.id)) out.push({ line: 0, text: cut(card.title, SEARCH.text) })
172 const lines = card.body.split(/\r?\n/)
173 for (let i = 0; i < lines.length && out.length < SEARCH.matches; i++) {
174 if (holds(lines[i])) out.push({ line: i + 1, text: cut(lines[i], SEARCH.text) })
175 }
176 return out.slice(0, SEARCH.matches)
177}
178
179/**
180 * list_cards, in the order the pane shows the cards (the pinned first). With no argument, every
181 * card's id, title and body as JSON (a body may hold any line, so no line format is safe); a
182 * pinned card adds `pinned: true`. Arguments narrow it, the first that applies winning:
183 * id that one card, { id, title, body, pinned? }
184 * query the cards holding every one of its words (split on blanks; in the id, title or body,
185 * any case, part of a word will do), { id, title, pinned?, matches: [{ line, text }] }
186 * (without `matches` when titles_only is there too)
187 * titles_only { id, title, chars (the body's length), pinned? } of every card
188 */
189export function applyList(cards, input = {}) {
190 const wrong = (name, type) => ({ error: `${name} は${type}で指定してください` })
191 if (input.id !== undefined && typeof input.id !== 'string') return wrong('id', '文字列')
192 if (input.query !== undefined && typeof input.query !== 'string') return wrong('query', '文字列')
193 if (input.titles_only !== undefined && typeof input.titles_only !== 'boolean') return wrong('titles_only', ' true か false')
194 const id = typeof input.id === 'string' ? input.id.trim() : ''
195 if (id !== '') {
196 const card = cards.find((c) => c.id === id)
197 if (!card) return { cards, text: `${id} というカードはありません` }
198 return { cards, text: JSON.stringify({ id: card.id, title: card.title, body: card.body, ...pinnedPart(card) }) }
199 }
200 if (cards.length === 0) return { cards, text: 'ボードにカードはありません' }
201 const ordered = orderedCards(cards)
202 const terms = typeof input.query === 'string' ? input.query.split(/\s+/).filter((t) => t !== '') : []
203 if (terms.length > 0) {
204 const lower = terms.map((t) => t.toLowerCase())
205 const found = ordered.filter((c) => {
206 const text = (c.id + '\n' + c.title + '\n' + c.body).toLowerCase()
207 return lower.every((t) => text.includes(t))
208 })
209 if (found.length === 0) return { cards, text: `見つかりませんでした: ${terms.join(' ')}` }
210 const rows = found.map((c) => ({ id: c.id, title: c.title, ...pinnedPart(c), ...(input.titles_only === true ? {} : { matches: matchesOf(c, lower) }) }))
211 return { cards, text: JSON.stringify(rows) }
212 }
213 if (input.titles_only === true) {
214 return { cards, text: JSON.stringify(ordered.map((c) => ({ id: c.id, title: c.title, chars: c.body.length, ...pinnedPart(c) }))) }
215 }
216 return { cards, text: JSON.stringify(ordered.map((c) => ({ id: c.id, title: c.title, body: c.body, ...pinnedPart(c) }))) }
217}
218
219// ---- The band
220
221/** Character cells a text takes: two for a wide (CJK, full-width) character, one otherwise. */
222export function cells(text) {
223 let n = 0
224 for (const ch of String(text)) {
225 const c = ch.codePointAt(0)
226 n += c >= 0x1100 && (c <= 0x115f || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)) ? 2 : 1
227 }
228 return n
229}
230
231/** The cells a Button takes: its label in brackets on a terminal, inside a frame elsewhere (an estimate). */
232export const buttonCells = (surface, label) => cells(label) + (surface === 'terminal' ? 2 : 4)
233
234/** The line's label, "ボード 3件" (and "ボード 0件" while the board is empty). */
235export const countLabel = (count) => `ボード ${count}件`
236
237// The line's last columns, which the terminal may draw over
238const RESERVED_COLUMNS = 2
239
240/** The band's label with its hint: "ボード 0件 · 引き継げます" ("ボード 3件" with none). */
241export const bandLabel = (count, hint = '') => countLabel(count) + (hint !== '' ? ` · ${hint}` : '')
242
243/**
244 * What the band draws in `columns`: { hasLabel, label, buttonLabel }. The wide form is the label
245 * (the card count, and the hint after it when there is one) and a [開く] button after it. When
246 * that does not fit, the button alone, carrying the count only (the hint is let go). With no
247 * width known (0, absent), the wide form. The same at any count, 0 included.
248 */
249export function fitBand({ surface, columns, count, hint = '' }) {
250 const label = bandLabel(count, hint)
251 const wide = { hasLabel: true, label, buttonLabel: '開く' }
252 if (!(typeof columns === 'number' && columns > 0)) return wide
253 const room = columns - RESERVED_COLUMNS
254 if (cells(label) + 1 + buttonCells(surface, wide.buttonLabel) <= room) return wide
255 const short = countLabel(count)
256 return { hasLabel: false, label: short, buttonLabel: short }
257}
258
259/** The local time of a card's last write, `14:05`. */
260export function clockOf(ms) {
261 const d = new Date(ms)
262 return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
263}
264hooks/boards.js 352 lines1// The boards other sessions left in $.store, and what is done with them (pure: no $ here, shelf.js
2// and store.js keep the calls).
3//
4// A session's board is `board:<sessionId>` (the cards) beside `meta:<sessionId>`, written together:
5// { updatedAt, cwdName, count } always
6// firstPrompt, title what the session began with (to tell boards apart)
7// handedFrom: { sid, sid8, cwdName, updatedAt, count, at }
8// set on the board that took cards over
9// and, only once the board was taken over, `seal:<sessionId>`:
10// { to, toSid8, toCwdName, at } the board is sealed (read-only); the cards under
11// `board:` are never touched
12// The seal has a key of its own because the meta is read, laid over and written back by the session
13// that owns the board at every save: a seal kept in it could be written back away. Only the session
14// that took the board over writes the seal key, and only [このセッションで書けるように戻す] deletes it.
15// 0.9.0 kept the seal in the meta as `handedOver`; that is still read as a seal (never written).
16// A new session's pane lists the other boards, so it can copy one over; the copied board stays,
17// sealed. Two options (off in this version) keep the store from filling with old boards:
18// `archiveDir` (write a board to a Markdown file before it is deleted) and `autoCleanDays`.
19
20import { LIMITS, sanitizeCards, orderedCards, clockOf } from './board.js'
21
22// The engine clips a long text and appends this marker
23const CLIP_MARKER = /\.\.\. \[\+\d+ chars\]\s*$/
24
25/** One line of a text: blanks folded, the engine's "... [+N chars]" marker made …, cut to `max` characters with a trailing …. */
26function clip(value, max = 60) {
27 const line = (typeof value === 'string' ? value : '').replace(CLIP_MARKER, '…').replace(/\s+/g, ' ').trim()
28 const chars = [...line]
29 return chars.length > max ? chars.slice(0, max - 1).join('') + '…' : line
30}
31
32/** What the list shows and keeps at most. */
33export const SHELF_LIMITS = {
34 boards: 5,
35 titles: 3,
36 title: 60,
37 /** The titles an opened row lists. */
38 openTitles: 20,
39 /** The characters of the first request that are kept. */
40 prompt: 60,
41 /** The rows of the clean-up list. */
42 cleanup: 20,
43}
44
45/** The word for a folder that is not known (a board saved before the meta existed). */
46export const UNKNOWN_FOLDER = '不明'
47
48const DAY_MS = 24 * 60 * 60 * 1000
49const MIB = 1024 * 1024
50
51/** The store holds 4 MiB in all; the pane warns from half of it. */
52export const WARN_BYTES = 2 * MIB
53const STORE_MIB = 4
54
55// ---- The options
56
57let config = { handover: true, archiveDir: '', autoCleanDays: 0 }
58
59/**
60 * Takes register()'s options. `handover` is on unless it is false. A value of the wrong type for
61 * the others is off: no archive folder, no clean.
62 */
63export function setConfig(options = {}) {
64 const days = options?.autoCleanDays
65 config = {
66 handover: options?.handover !== false,
67 archiveDir: typeof options?.archiveDir === 'string' ? options.archiveDir.trim() : '',
68 autoCleanDays: typeof days === 'number' && Number.isFinite(days) && days >= 1 ? Math.floor(days) : 0,
69 }
70 return config
71}
72
73export const getConfig = () => config
74
75/** The line under the clean-up list's heading: the two options, on or off. */
76export const settingsLine = (c = config) =>
77 `自動掃除: ${c.autoCleanDays >= 1 ? `${c.autoCleanDays} 日より古いものを消す` : 'オフ'} · 書き出し: ${c.archiveDir !== '' ? 'オン' : 'オフ'}`
78
79// ---- The keys and the meta
80
81export const BOARD_PREFIX = 'board:'
82export const metaKey = (sessionId) => 'meta:' + sessionId
83export const SEAL_PREFIX = 'seal:'
84export const META_PREFIX = 'meta:'
85export const sealKey = (sessionId) => SEAL_PREFIX + sessionId
86
87/** The session id of a `board:` key, or null for any other key. */
88export const sessionOfKey = (key) => (typeof key === 'string' && key.startsWith(BOARD_PREFIX) && key.length > BOARD_PREFIX.length ? key.slice(BOARD_PREFIX.length) : null)
89
90/** The first 8 plain characters of a session id: the buttons' keys and the archive's file name. */
91export const shortId = (sessionId) => String(sessionId ?? '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 8) || 'session'
92
93/** The last folder of a path, nothing above it; UNKNOWN_FOLDER when there is none. */
94export function cwdNameOf(path) {
95 const parts = String(path ?? '')
96 .split(/[\\/]+/)
97 .filter((p) => p !== '')
98 return parts.at(-1) ?? UNKNOWN_FOLDER
99}
100
101/** A request cut to the length the meta keeps: blanks and line breaks as one blank, `…` at the cut. */
102export const promptLine = (text, max = SHELF_LIMITS.prompt) => clip(text, max)
103
104const isNum = (n) => typeof n === 'number' && Number.isFinite(n)
105const isText = (s) => typeof s === 'string' && s !== ''
106
107/** A stored seal (the `seal:` value, or a 0.9.0 meta's `handedOver`) when it is sound ({ to, toSid8, toCwdName, at }), else null. */
108export function readSeal(value) {
109 if (value == null || typeof value !== 'object' || !isText(value.to) || !isNum(value.at)) return null
110 return { to: value.to, toSid8: isText(value.toSid8) ? value.toSid8 : shortId(value.to), toCwdName: isText(value.toCwdName) ? value.toCwdName : UNKNOWN_FOLDER, at: value.at }
111}
112
113/** A stored `handedFrom` when it is sound ({ sid, sid8, cwdName, updatedAt, count, at }), else null. */
114function readHandedFrom(value) {
115 if (value == null || typeof value !== 'object' || !isText(value.sid) || !isNum(value.at)) return null
116 return {
117 sid: value.sid,
118 sid8: isText(value.sid8) ? value.sid8 : shortId(value.sid),
119 cwdName: isText(value.cwdName) ? value.cwdName : UNKNOWN_FOLDER,
120 updatedAt: isNum(value.updatedAt) ? value.updatedAt : null,
121 count: isNum(value.count) ? value.count : null,
122 at: value.at,
123 }
124}
125
126/**
127 * A stored `meta:` value when it is sound, else null: { updatedAt, cwdName, count } and, when
128 * they are there and well-formed, firstPrompt, title, handedFrom and, in a meta 0.9.0 wrote, the
129 * seal as handedOver. A field it does not know, or one of the wrong type, is let go.
130 */
131export function readMeta(value) {
132 if (value == null || typeof value !== 'object') return null
133 if (!isNum(value.updatedAt)) return null
134 const handedOver = readSeal(value.handedOver)
135 const handedFrom = readHandedFrom(value.handedFrom)
136 return {
137 updatedAt: value.updatedAt,
138 cwdName: typeof value.cwdName === 'string' && value.cwdName !== '' ? value.cwdName : UNKNOWN_FOLDER,
139 count: typeof value.count === 'number' ? value.count : null,
140 ...(isText(value.firstPrompt) ? { firstPrompt: value.firstPrompt } : {}),
141 ...(isText(value.title) ? { title: value.title } : {}),
142 ...(handedOver ? { handedOver } : {}),
143 ...(handedFrom ? { handedFrom } : {}),
144 }
145}
146
147/**
148 * The meta after a change: `prev` (a meta as readMeta answers it, or null) with `patch` over it.
149 * A patch value that is undefined changes nothing, and null removes the field. A first request
150 * already there stays: it is written once.
151 */
152export function mergeMeta(prev, patch) {
153 const out = { ...(prev ?? {}) }
154 for (const [key, value] of Object.entries(patch ?? {})) {
155 if (value === undefined) continue
156 if (value === null) delete out[key]
157 else out[key] = value
158 }
159 if (isText(prev?.firstPrompt)) out.firstPrompt = prev.firstPrompt
160 if (out.count === null) delete out.count
161 return out
162}
163
164// ---- The time
165
166const dayStart = (ms) => {
167 const d = new Date(ms)
168 return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
169}
170
171/** How many calendar days (local) `ms` lies before `now`: 0 today, 1 yesterday, negative for the future. */
172const daysBefore = (ms, now) => Math.round((dayStart(now) - dayStart(ms)) / DAY_MS)
173
174/** `10/08 23:01`, local. */
175export function stampOf(ms) {
176 const d = new Date(ms)
177 return String(d.getMonth() + 1).padStart(2, '0') + '/' + String(d.getDate()).padStart(2, '0') + ' ' + clockOf(ms)
178}
179
180/** `2026-10-08 23:01`, local. */
181export const fullStampOf = (ms) => new Date(ms).getFullYear() + '-' + stampOf(ms).replace('/', '-')
182
183/** `今日 14:05`, `昨日 14:05`, else `10/08 14:05` (local), as `now` sees the day. */
184export function dayStampOf(ms, now) {
185 const days = daysBefore(ms, now)
186 if (days === 0) return '今日 ' + clockOf(ms)
187 if (days === 1) return '昨日 ' + clockOf(ms)
188 return stampOf(ms)
189}
190
191/** A card's time: `14:05` on the day of `now`, else `10/08 14:05` (an imported card is from another day). */
192export const cardStampOf = (ms, now) => (typeof now === 'number' && daysBefore(ms, now) !== 0 ? stampOf(ms) : clockOf(ms))
193
194// ---- The list
195
196/**
197 * One other session's board as the list shows it: { sid, sid8, updatedAt, cwdName, count, titles,
198 * allTitles, pinnedCount, firstPrompt, title, handedOver }. Without a sound meta the time is that
199 * of the newest card (null if there is none) and the folder is unknown; the count is always the
200 * cards the board holds. `titles` are the first three in the pane's order, `allTitles` up to 20
201 * ({ title, pinned }). `handedOver` is the seal: the `seal:` value, else a 0.9.0 meta's.
202 */
203export function summarizeBoard(sid, boardValue, metaStored, sealStored) {
204 const cards = sanitizeCards(boardValue)
205 const meta = readMeta(metaStored)
206 const newest = cards.reduce((m, c) => Math.max(m, c.updatedAt), -Infinity)
207 const ordered = orderedCards(cards)
208 return {
209 sid,
210 sid8: shortId(sid),
211 updatedAt: meta?.updatedAt ?? (cards.length > 0 ? newest : null),
212 cwdName: meta?.cwdName ?? UNKNOWN_FOLDER,
213 count: cards.length,
214 titles: ordered.slice(0, SHELF_LIMITS.titles).map((c) => clip(c.title, SHELF_LIMITS.title)),
215 allTitles: ordered.slice(0, SHELF_LIMITS.openTitles).map((c) => ({ title: clip(c.title, SHELF_LIMITS.title), pinned: c.pinned === true })),
216 pinnedCount: cards.filter((c) => c.pinned === true).length,
217 firstPrompt: meta?.firstPrompt ?? null,
218 title: meta?.title ?? null,
219 handedOver: readSeal(sealStored) ?? meta?.handedOver ?? null,
220 }
221}
222
223const byNewest = (a, b) => (b.updatedAt ?? -Infinity) - (a.updatedAt ?? -Infinity)
224
225/**
226 * The boards a new session may take over, in the order the pane lists them: not this session's
227 * own, not empty, not sealed; those of the same folder first, then the rest, each newest first
228 * (one with no time last). { boards: the first `limit`, more: how many are left out }.
229 */
230export function candidatesOf(entries, { me, cwdName, limit = SHELF_LIMITS.boards } = {}) {
231 const open = entries.filter((e) => e.sid !== me && e.count > 0 && !e.handedOver)
232 const same = (e) => cwdName !== undefined && cwdName !== UNKNOWN_FOLDER && e.cwdName === cwdName
233 const sorted = [...open.filter(same).sort(byNewest), ...open.filter((e) => !same(e)).sort(byNewest)]
234 return { boards: sorted.slice(0, limit), more: Math.max(0, sorted.length - limit) }
235}
236
237/** The rows of the clean-up list: every other session's board, sealed ones too (`sealed`), newest first. */
238export function cleanupOf(entries, { me, limit = SHELF_LIMITS.cleanup } = {}) {
239 return entries
240 .filter((e) => e.sid !== me)
241 .sort(byNewest)
242 .slice(0, limit)
243 .map((e) => ({ sid: e.sid, sid8: e.sid8, updatedAt: e.updatedAt, cwdName: e.cwdName, count: e.count, sealed: e.handedOver !== null }))
244}
245
246/** The warning about the store's size: only from half of the 4 MiB, else ''. */
247export function storeWarning(bytes) {
248 if (!(bytes > WARN_BYTES)) return ''
249 const mb = Math.round((bytes / MIB) * 10) / 10
250 return `保存できる量の半分を超えています(${mb.toFixed(1)} MB / ${STORE_MIB} MB)。これ以上増えると、ボードを保存できなくなります`
251}
252
253// ---- Taking a board over
254
255const entryOf = (c) => ({ id: c.id, title: c.title, pinned: c.pinned === true })
256
257/**
258 * The cards of `source` put after those of `current`: a card whose id is there already is skipped
259 * (the present one stays), and only as many go in as the limit leaves room for; `pinned` and
260 * `updatedAt` are carried. { cards, added, duplicates, overflow }, the last three as lists of
261 * { id, title, pinned }.
262 */
263export function planHandover(current, source, limit = LIMITS.cards) {
264 const have = new Set(current.map((c) => c.id))
265 const cards = [...current]
266 const added = []
267 const duplicates = []
268 const overflow = []
269 for (const card of source) {
270 if (have.has(card.id)) duplicates.push(entryOf(card))
271 else if (cards.length >= limit) overflow.push(entryOf(card))
272 else {
273 have.add(card.id)
274 cards.push({ ...card })
275 added.push(entryOf(card))
276 }
277 }
278 return { cards, added, duplicates, overflow }
279}
280
281/** The sign of a list of cards (ids, times, pins): it differs when anything on the board changed. */
282export const cardsSig = (cards) => cards.map((c) => c.id + ':' + c.updatedAt + ':' + (c.pinned === true ? 1 : 0)).join(',')
283
284/** The words for a skipped / not-fitting count after a hand-over: `(1 枚は同じ id があるので飛ばしました、…)` or ''. */
285function skippedText({ duplicates, overflow }) {
286 const skipped = []
287 if (duplicates.length > 0) skipped.push(`${duplicates.length} 枚は同じ id があるので飛ばしました`)
288 if (overflow.length > 0) skipped.push(`${overflow.length} 枚は上限(${LIMITS.cards} 枚)に達したので入りませんでした`)
289 return skipped.length > 0 ? `(${skipped.join('、')})` : ''
290}
291
292/** Where a board came from, in a few words: `今日 14:05 · my-project`. */
293export const originText = (row, now) => `${typeof row.updatedAt === 'number' ? dayStampOf(row.updatedAt, now) : '時刻不明'} · ${row.cwdName}`
294
295/** The one line the pane shows after a hand-over. */
296export function handoverText(plan, row, now) {
297 return `${plan.added.length} 枚を引き継ぎました(${originText(row, now)} のボードから)。元のボードは読み取り専用になりました。` + skippedText(plan)
298}
299
300/** Where a sealed board went: `別のセッション(my-project · ID b7de12ab)`. */
301const sealedTo = (sealed) => `別のセッション(${sealed.toCwdName} · ID ${sealed.toSid8})`
302
303/** What a write to a sealed board is refused with (read by Claude). */
304export const sealedDenyText = (sealed) =>
305 `このボードは読み取り専用です。${stampOf(sealed.at)} に、${sealedTo(sealed)}へ引き継がれました。` +
306 'カードの追加・書き換え・削除はできません(list_cards で読むことはできます)。続きは引き継ぎ先のセッションで書くよう、利用者に伝えてください。'
307
308/** What a write is refused with when the seal could not be read (read by Claude): a board that may be sealed is not written. */
309export const sealUnreadableText = (message) =>
310 `ボードの状態(読み取り専用かどうか)を読めなかったので、書き込みを止めました(${message})。もう一度試してください。何度やっても同じなら、そのことを利用者に伝えてください。`
311
312/** The line for the frame when [引き継ぐ] is pressed on a board that is read-only itself. */
313export const SELF_SEALED = 'このボードは読み取り専用なので、引き継げません。先に [このセッションで書けるように戻す] を押してください'
314
315/** The first line of list_cards on a sealed board. */
316export const sealedListLine = (sealed) => `(このボードは読み取り専用です。${stampOf(sealed.at)} に${sealedTo(sealed)}へ引き継がれました)`
317
318// ---- The archive
319
320const isAbsolute = (p) => /^([A-Za-z]:)?[\\/]/.test(p)
321const trimDir = (dir) => String(dir).trim().replace(/\\/g, '/').replace(/\/+$/, '')
322
323/** `whiteboard-20261008-1a2b3c4d.md`: the day the board was last written (local) and the session's short id. */
324export function archiveName(sid, updatedAt) {
325 const d = new Date(typeof updatedAt === 'number' ? updatedAt : 0)
326 const day = String(d.getFullYear()) + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0')
327 return `whiteboard-${day}-${shortId(sid)}.md`
328}
329
330/** Where $.fs.write puts a board's file: under `root` (the project) unless `dir` is absolute. */
331export function archivePath(dir, root, name) {
332 const d = trimDir(dir) || '.'
333 const shown = d === '.' ? name : `${d}/${name}`
334 return isAbsolute(d) || !root ? shown : `${String(root).replace(/[\\/]+$/, '')}/${shown}`
335}
336
337const oneLine = (s) => String(s).replace(/\s+/g, ' ').trim()
338
339/** A board as Markdown: a heading, when it was written and where, then each card with its title, id, time and body. */
340export function archiveMarkdown({ sid, updatedAt, cwdName, cards }) {
341 const ordered = orderedCards(cards)
342 const lines = [`# whiteboard のボード ${shortId(sid)}`, '', `- 更新: ${typeof updatedAt === 'number' ? fullStampOf(updatedAt) : '不明'}`, `- フォルダ: ${cwdName}`, `- カード: ${cards.length} 枚`]
343 for (const c of ordered) {
344 lines.push('', `## ${oneLine(c.title)}`, '', `${c.id} · ${fullStampOf(c.updatedAt)}${c.pinned === true ? ' · 固定' : ''}`)
345 if (c.body.trim() !== '') lines.push('', c.body.replace(/\s+$/, ''))
346 }
347 return lines.join('\n') + '\n'
348}
349
350/** Whether a board last written at `updatedAt` is older than `days` days at `now` (exactly `days` is not). */
351export const isStale = (updatedAt, now, days) => typeof updatedAt === 'number' && days >= 1 && now - updatedAt > days * DAY_MS
352hooks/shelf.js 66 lines1// The other sessions' boards as register.js finds them in $.store (pure: no $ here).
2//
3// register.js reads the store's keys and the values under the `board:`, `meta:` and `seal:` ones; this
4// turns them into what the pane holds (the `others` value of $.state: the boards a new session
5// may take over, and the clean-up list), and words what the pane says after an action.
6
7import { sessionOfKey, summarizeBoard, candidatesOf, cleanupOf, metaKey, sealKey, BOARD_PREFIX, META_PREFIX, SEAL_PREFIX } from './boards.js'
8
9/** The `others` value when nothing is known yet. */
10export const NO_OTHERS = { boards: [], more: 0, cleanup: [], bytes: 0, notice: '' }
11
12/** What the pane says when the board it was drawn from is not there any more. */
13export const GONE = 'このボードは、もうありません(消されたか、片付けられました)'
14/** What the pane says when a button's board is not in the list the state holds (the list is old). */
15export const OLD_LIST = 'この一覧は古くなっています。もう一度開き直してください'
16/** What the pane says when the board was taken over by another session in the meantime. */
17export const ALREADY = (sealed) => `このボードは、すでに別のセッション(${sealed.toCwdName} · ID ${sealed.toSid8})に引き継がれています`
18
19/** Whether two state values are the same (compared as JSON), so a write is made only when the value changed. */
20export const isSame = (a, b) => JSON.stringify(a) === JSON.stringify(b)
21
22const sizeOf = (value) => (value === undefined ? 0 : JSON.stringify(value).length)
23
24/** Whether a key is one of the mod's own (a board, a meta or a seal). */
25export const isMine = (key) => typeof key === 'string' && (key.startsWith(BOARD_PREFIX) || key.startsWith(META_PREFIX) || key.startsWith(SEAL_PREFIX))
26
27/**
28 * Every other session's board from the store's `keys` and a Map of their `values` (by key):
29 * { entries: the summaries, bytes: the JSON length of all the other sessions' board, meta and seal
30 * keys }. The session `me` is left out; a meta or a seal whose board is gone still counts for its size.
31 */
32export function scanEntries(me, keys, values) {
33 const entries = []
34 const seen = new Set()
35 let bytes = 0
36 for (const key of keys) {
37 const sid = sessionOfKey(key)
38 if (sid === null || sid === me) continue
39 seen.add(sid)
40 bytes += sizeOf(values.get(key)) + sizeOf(values.get(metaKey(sid))) + sizeOf(values.get(sealKey(sid)))
41 entries.push(summarizeBoard(sid, values.get(key), values.get(metaKey(sid)), values.get(sealKey(sid))))
42 }
43 for (const key of keys) {
44 if (typeof key !== 'string') continue
45 const prefix = key.startsWith(META_PREFIX) ? META_PREFIX : key.startsWith(SEAL_PREFIX) ? SEAL_PREFIX : null
46 if (prefix !== null && key.slice(prefix.length) !== me && !seen.has(key.slice(prefix.length))) bytes += sizeOf(values.get(key))
47 }
48 return { entries, bytes }
49}
50
51/**
52 * The `others` value for a scan: the boards that may be taken over (the first few, how many more
53 * there are), the clean-up list (only with `admin`), the size, and the line about the last action.
54 * `handover` false leaves the boards out.
55 */
56export function othersOf(scanned, { me, cwdName, notice = '', handover = true, admin = false } = {}) {
57 const { boards, more } = handover ? candidatesOf(scanned.entries, { me, cwdName }) : { boards: [], more: 0 }
58 return { boards, more, cleanup: admin ? cleanupOf(scanned.entries, { me }) : [], bytes: scanned.bytes, notice }
59}
60
61/** The line after [消す]. */
62export const droppedText = (row, shown) => `消しました: ${row.cwdName}(${row.count} 枚)` + (shown !== '' ? `。${shown} に書き出しました` : '')
63
64/** The line after [消す] when the file could not be written and so nothing was deleted. */
65export const keptText = (shown, error) => `書き出せなかったので、消していません(${shown}): ${error}`
66hooks/store.js 16 lines1// The queue that keeps a read-change-write of the store or of $.state from overlapping another's
2// (pure: no $ here; the calls are register.js's).
3
4/** A queue: the calls run one at a time, in the order they came; one that fails does not stop the next. */
5export function makeQueue() {
6 let queue = Promise.resolve()
7 return (fn) => {
8 const run = queue.then(fn)
9 queue = run.then(
10 () => undefined,
11 () => undefined,
12 )
13 return run
14 }
15}
16hooks/guide.js 76 lines1// The guide that tells a Claude in a fresh session the board exists (pure: no $ here; register.js
2// hooks it up).
3//
4// short one fixed section added to the system prompt (prompt.compose). The text never holds a
5// card, a count, a time or anything else of the session, so the prompt cache is not
6// spent: it is the same string on every call.
7// full short, plus a few lines on which tool to use for what, handed to the model with the
8// person's message (additionalContext of the classic UserPromptSubmit; the message
9// itself is not touched) on a turn whose message holds one of the keywords, and at
10// most once in GUIDE_EVERY turns.
11// off nothing added.
12
13/** The id of the system prompt section. */
14export const GUIDE_ID = 'whiteboard:guide'
15
16/** The section's text: fixed. Keep it to a sentence or two. */
17export const GUIDE_SHORT =
18 '作業ボード(mcp__whiteboard__*)があります。流れる手順・URL・状況はカードに残し、読むなら list_cards を titles_only か query で。使い方は ToolSearch で whiteboard を引く。'
19
20/** The few lines of `full`: which tool for what, and how to pin. Fixed too. */
21export const GUIDE_FULL =
22 'ボードの使い分け: 全文を書き直すのは set_card。一部だけ変えるときは edit_card(本文中の find を replace に置き換える。一致が 1 箇所のときだけ)、末尾に足すだけなら append_card。' +
23 '読むときは list_cards を titles_only か query で呼び、必要なカードだけ id で読む。' +
24 '人が常に見ていたいカード(今の手順の全体像、URL の一覧)だけ pin: true にし、使いすぎない。済んだカードは remove_card で消す。'
25
26/** The most turns `full` waits before it adds the lines again: once in this many. */
27export const GUIDE_EVERY = 5
28
29export const GUIDE_MODES = ['off', 'short', 'full']
30
31/** The setting `guide`: one of the three; anything else is `short`. */
32export const guideModeOf = (options) => (GUIDE_MODES.includes(options?.guide) ? options.guide : 'short')
33
34/** The setting `guideKeywords`, "a,b" (a full-width comma or 、 splits too): the words, blanks dropped, lower-cased. */
35export function keywordsOf(options) {
36 const raw = typeof options?.guideKeywords === 'string' ? options.guideKeywords : 'ボード,whiteboard'
37 return raw
38 .split(/[,,、]/)
39 .map((w) => w.trim().toLowerCase())
40 .filter((w) => w !== '')
41}
42
43/** The section, built afresh (the same text every time). */
44export const guideSection = () => ({ id: GUIDE_ID, text: GUIDE_SHORT, scope: 'session' })
45
46/**
47 * Appends the section to the end of `result.sections` (the list `next(e)` answered); the list
48 * is left alone when the mode is off, the prompt is the one-line `bare` one, or the section is
49 * already there.
50 */
51export function withGuide(result, traits, mode) {
52 const sections = Array.isArray(result?.sections) ? result.sections : null
53 if (mode === 'off' || sections === null) return result
54 if (Array.isArray(traits) && traits.includes('bare')) return result
55 if (sections.some((s) => s?.id === GUIDE_ID)) return result
56 return { ...result, sections: [...sections, guideSection()] }
57}
58
59/**
60 * The turns' counter of `full`. `next(prompt)` is called for each message the person sends and
61 * answers the lines to hand over, or '' for none: the message must hold a keyword (any case),
62 * and the lines must not have been given in the last GUIDE_EVERY - 1 turns before it.
63 */
64export function makeGuideTurns(keywords) {
65 let turn = 0
66 let last = null
67 return (prompt) => {
68 turn++
69 const text = typeof prompt === 'string' ? prompt.toLowerCase() : ''
70 if (!keywords.some((w) => text.includes(w))) return ''
71 if (last !== null && turn - last < GUIDE_EVERY) return ''
72 last = turn
73 return GUIDE_FULL
74 }
75}
76hooks/pane.js 356 lines1// What the pane and the band draw (pure: no $ here; register.js reads the state, hands the values
2// over with the actions the Buttons run, and registers the hooks).
3//
4// The pane holds the cards, the pinned ones first and each group in the order they were added: a
5// title, a dim line with the id and the time of the last write, and the body as Markdown (handed
6// over as written, except that a mermaid fence mermaid.js can draw is an Svg). The cards have no
7// buttons: the person reads, Claude writes. With none, a note says so.
8//
9// Around the cards, the hand-over of an earlier session's board:
10// - a board that was taken over (sealed) says it is read-only above its cards, with a button
11// that makes it writable again;
12// - the line about the last hand-over, and where the cards came from;
13// - while the board is empty and other sessions' boards are there, the frame "前のセッション
14// から引き継ぐ": one board opened, several as closed rows ([中身を見る] opens one); [引き継ぐ]
15// copies its cards in, after a confirmation when the board already has cards;
16// - with cards: a dim line with [表示] that opens the same frame.
17// Last, a line when the store is over half full.
18//
19// The band is one short line, the card count (and a hint: boards to take over, or read-only) with
20// a button that opens the pane, drawn at 0 cards too.
21//
22// Nothing here calls $.ui.invalidate or draws on a timer (in the desktop app every redraw
23// rebuilds every mod's drawing, other mods' open panes included).
24
25import { LIMITS, fitBand, orderedCards } from './board.js'
26import { mermaidSvg, splitFences } from './mermaid.js'
27import { getConfig, settingsLine, stampOf, dayStampOf, cardStampOf, storeWarning, originText } from './boards.js'
28
29export const PANE_ID = 'whiteboard'
30export const TITLE = 'ボード'
31
32// Element keys allow a plain set of characters; a card's id is checked to, but not trusted to
33const keyOf = (prefix, id) => prefix + '-' + String(id).replace(/[^A-Za-z0-9_-]/g, '_')
34
35// The cells across a drawing: the site's own width, else what the surface measured; null when
36// neither is known
37export function widthOf(e) {
38 const n = e.props?.bodyColumns
39 if (typeof n === 'number' && n > 0) return n
40 const v = e.viewport?.columns
41 return typeof v === 'number' && v > 0 ? v : null
42}
43
44// A card's body. The Markdown element shows a mermaid fence as a code block, so on a surface with
45// Svg the fences mermaid.js can draw become pictures, the rest of the body staying Markdown as
46// written (a fence it cannot draw too). With none drawn the body is one Markdown, untouched.
47function drawBody(ui, card, view) {
48 const { Box, Markdown, Svg } = ui
49 const whole = () => Markdown({ key: keyOf('body', card.id), text: card.body })
50 if (view.surface === 'terminal' || typeof Svg !== 'function' || !/mermaid/i.test(card.body)) return whole()
51 const items = []
52 let drawn = 0
53 const addText = (text) => {
54 if (items.at(-1)?.text !== undefined) items[items.length - 1].text += '\n' + text
55 else items.push({ text })
56 }
57 for (const piece of splitFences(card.body)) {
58 const picture = piece.type === 'mermaid' ? mermaidSvg(piece.source) : null
59 if (picture) {
60 items.push({ picture })
61 drawn++
62 } else {
63 addText(piece.type === 'mermaid' ? piece.raw : piece.text)
64 }
65 }
66 if (drawn === 0) return whole()
67 // About the cells of the pane less its borders and padding, at 8 pixels a cell
68 const room = view.columns ? Math.max(160, (view.columns - 8) * 8) : 560
69 const children = items.flatMap((item, n) => {
70 if (item.text !== undefined) return item.text.trim() === '' ? [] : [Markdown({ key: keyOf('body', card.id) + '-' + n, text: item.text })]
71 const { picture } = item
72 const scale = Math.min(1, room / picture.width)
73 return [
74 Box({
75 key: keyOf('diagram', card.id) + '-' + n,
76 children: [Svg({ source: picture.source, alt: picture.alt, width: Math.round(picture.width * scale), height: Math.round(picture.height * scale) })],
77 }),
78 ]
79 })
80 return Box({ key: keyOf('body', card.id), flexDirection: 'column', rowGap: 1, children })
81}
82
83function drawCard(ui, card, view) {
84 const { Box, Text } = ui
85 const children = [
86 Box({
87 key: keyOf('head', card.id),
88 flexDirection: 'row',
89 flexWrap: 'wrap',
90 columnGap: 2,
91 children: [
92 Text({ bold: true, wrap: 'wrap', children: [card.title] }),
93 ...(card.pinned === true ? [Text({ dimColor: true, children: ['固定'] })] : []),
94 Text({ dimColor: true, children: [`${card.id} · ${cardStampOf(card.updatedAt, view.now)}`] }),
95 ],
96 }),
97 ]
98 if (card.body.trim() !== '') children.push(drawBody(ui, card, view))
99 return Box({ key: keyOf('card', card.id), flexDirection: 'column', width: '100%', borderStyle: 'round', borderDimColor: true, paddingX: 1, children })
100}
101
102// The cards' blocks: a note and a box each, or what to ask for while there are none
103function drawCards(ui, cards, view) {
104 const { Box, Text } = ui
105 if (cards.length === 0) {
106 return [
107 Box({ key: 'empty', flexDirection: 'column', children: [
108 Text({ wrap: 'wrap', children: ['まだ何も書かれていません'] }),
109 Text({ dimColor: true, wrap: 'wrap', children: ['Claude に「ボードに手順を書いて」のように頼むと、ここにカードとして残ります'] }),
110 ] }),
111 ]
112 }
113 return [
114 Box({ key: 'about', children: [Text({ dimColor: true, wrap: 'wrap', children: [`${cards.length} 件 · 書き換えは Claude に頼んでください`] })] }),
115 ...orderedCards(cards).map((card) => drawCard(ui, card, view)),
116 ]
117}
118
119// ---- The hand-over
120
121const boxed = (key, children) => ({ key, flexDirection: 'column', rowGap: 1, width: '100%', borderStyle: 'round', borderDimColor: true, paddingX: 1, children })
122
123// A board that was taken over by another session: read-only here
124function drawSealed(ui, sealed, actions) {
125 const { Box, Text, Button } = ui
126 return Box(
127 boxed('sealed', [
128 Text({ bold: true, children: ['読み取り専用'] }),
129 Text({
130 wrap: 'wrap',
131 children: [`このボードは ${stampOf(sealed.at)} に、別のセッション(${sealed.toCwdName} · ID ${sealed.toSid8})へ引き継ぎました。ここでは読むだけで、Claude も書き込めません。`],
132 }),
133 Box({ key: 'sealed-buttons', flexDirection: 'row', children: [Button({ key: 'unseal', label: 'このセッションで書けるように戻す', variant: 'secondary', onPress: () => actions.unseal() })] }),
134 Text({ dimColor: true, wrap: 'wrap', children: ['戻しても、引き継ぎ先のカードはそのまま残ります。'] }),
135 ]),
136 )
137}
138
139// The line about the last hand-over (until the pane is opened again)
140function drawLast(ui, last) {
141 const { Box, Text } = ui
142 return Box(boxed('handover-last', [Text({ wrap: 'wrap', children: [last.text] })]))
143}
144
145// Where the cards on this board came from, for as long as the board keeps the record
146function drawFrom(ui, from, now) {
147 const { Box, Text } = ui
148 const when = typeof from.updatedAt === 'number' ? dayStampOf(from.updatedAt, now) : '時刻不明'
149 return Box({
150 key: 'handover-from',
151 children: [Text({ dimColor: true, wrap: 'wrap', children: [`引き継ぎ元: ${when} · ${from.cwdName} · ID ${from.sid8}(元のボードは、そのセッションを開くと読めます)`] })],
152 })
153}
154
155// The first line of a board's block: when, which folder, how many cards, the id's start
156function drawBoardHead(ui, b, now) {
157 const { Box, Text } = ui
158 return Box({
159 key: keyOf('cand-head', b.sid8),
160 flexDirection: 'row',
161 flexWrap: 'wrap',
162 columnGap: 2,
163 children: [
164 Text({ dimColor: true, children: [typeof b.updatedAt === 'number' ? dayStampOf(b.updatedAt, now) : '時刻不明'] }),
165 Text({ bold: true, wrap: 'wrap', children: [b.cwdName] }),
166 Text({ dimColor: true, children: [`${b.count} 枚`] }),
167 Text({ dimColor: true, children: [`ID ${b.sid8}`] }),
168 ],
169 })
170}
171
172// The second line: what the session was about (its title, else the first thing typed in it)
173const aboutOf = (b) => (b.title !== null ? b.title : b.firstPrompt !== null ? `「${b.firstPrompt}」` : '最初に打った言葉: 不明')
174
175function drawBoardRow(ui, b, { open, single, hasCards, now }, actions) {
176 const { Box, Text, Button } = ui
177 const children = [drawBoardHead(ui, b, now), Box({ key: keyOf('cand-about', b.sid8), children: [Text({ dimColor: true, wrap: 'wrap', children: [aboutOf(b)] })] })]
178 if (open) {
179 children.push(
180 Box({
181 key: keyOf('cand-titles', b.sid8),
182 flexDirection: 'column',
183 children: b.allTitles.map((t) => Text({ wrap: 'wrap', children: [`・${t.title}${t.pinned ? '(固定)' : ''}`] })),
184 }),
185 Box({
186 key: keyOf('cand-note', b.sid8),
187 flexDirection: 'column',
188 children: [
189 Text({
190 dimColor: true,
191 wrap: 'wrap',
192 children: [
193 hasCards
194 ? '今のボードのカードの後ろに足します。入る枚数は、押したあとの確認で分かります。'
195 : `このボードに ${b.count} 枚が入ります${b.pinnedCount > 0 ? `(固定の ${b.pinnedCount} 枚も固定のまま)` : ''}。`,
196 ],
197 }),
198 Text({ dimColor: true, wrap: 'wrap', children: ['元のボードは読み取り専用になり、この一覧から消えます。'] }),
199 ],
200 }),
201 )
202 } else {
203 const rest = b.count - b.titles.length
204 children.push(
205 Box({ key: keyOf('cand-titles', b.sid8), children: [Text({ dimColor: true, wrap: 'wrap', children: [b.titles.join(' / ') + (rest > 0 ? ` ほか ${rest} 枚` : '')] })] }),
206 )
207 }
208 const take = Button({ key: 'take-' + b.sid8, label: '引き継ぐ', variant: 'secondary', onPress: () => actions.take(b.sid8) })
209 const buttons = single ? [take] : [Button({ key: 'peek-' + b.sid8, label: open ? '閉じる' : '中身を見る', variant: 'secondary', onPress: () => actions.peek(b.sid8) }), take]
210 children.push(Box({ key: keyOf('cand-buttons', b.sid8), flexDirection: 'row', columnGap: 1, children: buttons }))
211 return Box({ key: keyOf('cand', b.sid8), flexDirection: 'column', children })
212}
213
214// The step before a hand-over whose outcome is not plain: what goes in, what is skipped, what does not fit
215function drawConfirm(ui, b, confirm, now, actions) {
216 const { Box, Text, Button } = ui
217 const { added, duplicates, overflow } = confirm.plan
218 const entries = (list, key) => Box({ key, flexDirection: 'column', children: list.map((c) => Text({ wrap: 'wrap', children: [` ・${c.title}(${c.id})`] })) })
219 const pinned = added.filter((c) => c.pinned).length
220 const children = [Text({ bold: true, children: ['引き継ぐ前に確認してください'] })]
221 if (confirm.recounted) children.push(Text({ wrap: 'wrap', children: ['ボードが変わったので、数え直しました'] }))
222 children.push(Text({ wrap: 'wrap', children: [`${originText(b, now)} · ${b.count} 枚 · ID ${b.sid8} から`] }))
223 if (added.length > 0) {
224 children.push(Text({ wrap: 'wrap', children: [`入る: ${added.length} 枚`] }))
225 if (pinned > 0) children.push(Text({ dimColor: true, wrap: 'wrap', children: [`固定の ${pinned} 枚も固定のまま入ります`] }))
226 }
227 if (duplicates.length > 0) {
228 children.push(Text({ wrap: 'wrap', children: [`飛ばす: ${duplicates.length} 枚(今のボードに同じ id のカードがあるため。今のカードはそのまま)`] }), entries(duplicates, 'confirm-skipped'))
229 }
230 if (overflow.length > 0) {
231 children.push(Text({ wrap: 'wrap', children: [`入らない: ${overflow.length} 枚(カードは ${LIMITS.cards} 枚まで)`] }), entries(overflow, 'confirm-overflow'))
232 children.push(Text({ dimColor: true, wrap: 'wrap', children: ['入らなかったカードは元のボードに残り、そのセッションを開けば読めます。'] }))
233 }
234 const buttons = []
235 if (added.length > 0) {
236 buttons.push(Button({ key: 'take-confirm', label: 'この内容で引き継ぐ', variant: 'secondary', onPress: () => actions.takeConfirm() }))
237 } else {
238 // Nothing would go in: say why, and leave only the way out
239 const why =
240 overflow.length > 0
241 ? `今のボードは ${LIMITS.cards} 枚でいっぱいなので、1 枚も入りません。済んだカードを消してから引き継いでください`
242 : '入るカードが 1 枚もありません(すべて今のボードと同じ id です)'
243 children.push(Text({ wrap: 'wrap', children: [why] }))
244 }
245 buttons.push(Button({ key: 'take-cancel', label: 'やめる', variant: 'secondary', onPress: () => actions.takeCancel() }))
246 children.push(Box({ key: 'confirm-buttons', flexDirection: 'row', columnGap: 1, children: buttons }))
247 return Box(boxed('confirm', children))
248}
249
250// The frame "前のセッションから引き継ぐ": the boards that may be taken over
251function drawHandover(ui, others, hasCards, view, now, actions) {
252 const { Box, Text } = ui
253 const single = others.boards.length === 1
254 const rows = others.boards.flatMap((b) => {
255 const row = drawBoardRow(ui, b, { open: single || view.pick === b.sid8, single, hasCards, now }, actions)
256 return view.confirm !== null && view.confirm.sid8 === b.sid8 ? [row, drawConfirm(ui, b, view.confirm, now, actions)] : [row]
257 })
258 return Box(
259 boxed('handover', [
260 Text({ bold: true, children: ['前のセッションから引き継ぐ'] }),
261 Text({ dimColor: true, wrap: 'wrap', children: ['前のセッションのボードのカードを、このボードに写します。元のボードは消えずに残り、読み取り専用になります。'] }),
262 ...(others.notice !== '' ? [Box({ key: 'handover-notice', children: [Text({ wrap: 'wrap', children: [others.notice] })] })] : []),
263 ...rows,
264 ...(others.more > 0 ? [Box({ key: 'handover-more', children: [Text({ dimColor: true, children: [`ほか ${others.more} 件`] })] })] : []),
265 ]),
266 )
267}
268
269// With cards on the board: one dim line and a button that opens the frame
270function drawEntry(ui, others, view, actions) {
271 const { Box, Text, Button } = ui
272 return Box({
273 key: 'handover-entry',
274 flexDirection: 'row',
275 flexWrap: 'wrap',
276 columnGap: 2,
277 alignItems: 'center',
278 children: [
279 Text({ dimColor: true, wrap: 'wrap', children: [`前のセッションのボードを引き継ぐこともできます(${others.boards.length + others.more} 件)`] }),
280 Button({ key: 'show-handover', label: view.shown ? '隠す' : '表示', variant: 'secondary', onPress: () => actions.toggleShown() }),
281 ],
282 })
283}
284
285// The clean-up list (switched off in this version): every other board, sealed ones marked, each
286// with [消す]; the options, and the line about the last action
287function drawCleanup(ui, others, actions) {
288 const { Box, Text, Button } = ui
289 const rows = others.cleanup.map((b) =>
290 Box({
291 key: keyOf('clean', b.sid8),
292 flexDirection: 'column',
293 children: [
294 Box({
295 key: keyOf('clean-head', b.sid8),
296 flexDirection: 'row',
297 flexWrap: 'wrap',
298 columnGap: 2,
299 children: [
300 Text({ dimColor: true, children: [typeof b.updatedAt === 'number' ? stampOf(b.updatedAt) : '時刻不明'] }),
301 Text({ bold: true, wrap: 'wrap', children: [b.cwdName] }),
302 Text({ dimColor: true, children: [`${b.count} 枚`] }),
303 ...(b.sealed ? [Text({ dimColor: true, children: ['引き継ぎ済み'] })] : []),
304 ],
305 }),
306 Box({ key: keyOf('clean-buttons', b.sid8), flexDirection: 'row', children: [Button({ key: 'drop-' + b.sid8, label: '消す', variant: 'secondary', onPress: () => actions.dropBoard(b.sid8) })] }),
307 ],
308 }),
309 )
310 return Box(
311 boxed('cleanup', [
312 Text({ bold: true, children: ['古いボードの片付け'] }),
313 Box({ key: 'cleanup-settings', children: [Text({ dimColor: true, wrap: 'wrap', children: [settingsLine(getConfig())] })] }),
314 ...(others.notice !== '' ? [Box({ key: 'cleanup-notice', children: [Text({ wrap: 'wrap', children: [others.notice] })] })] : []),
315 ...rows,
316 ]),
317 )
318}
319
320/**
321 * The pane's tree. `data` is what register.js read from $.state: { cards, others, handover:
322 * { sealed, from, last }, view: { pick, shown, confirm }, enabled (the setting `handover`), admin
323 * (the clean-up list), now }; `where` is { surface, columns }; `actions` the Buttons' work.
324 */
325export function drawPane(ui, data, where, actions) {
326 const { cards, others, handover, view, enabled, admin, now } = data
327 const { Box, Text } = ui
328 const sealed = handover.sealed !== null
329 const offered = enabled && !sealed
330 const warning = storeWarning(others.bytes + JSON.stringify(cards).length)
331 const children = [
332 ...(sealed ? [drawSealed(ui, handover.sealed, actions)] : []),
333 ...(handover.last !== null ? [drawLast(ui, handover.last)] : []),
334 ...(handover.from !== null ? [drawFrom(ui, handover.from, now)] : []),
335 ...drawCards(ui, cards, { ...where, now }),
336 ...(offered && cards.length === 0 && (others.boards.length > 0 || others.notice !== '') ? [drawHandover(ui, others, false, view, now, actions)] : []),
337 ...(offered && cards.length > 0 && (others.boards.length > 0 || view.shown) ? [drawEntry(ui, others, view, actions)] : []),
338 ...(offered && cards.length > 0 && view.shown ? [drawHandover(ui, others, true, view, now, actions)] : []),
339 ...(warning !== '' ? [Box({ key: 'store-warning', children: [Text({ dimColor: true, wrap: 'wrap', children: [warning] })] })] : []),
340 ...(admin && (others.cleanup.length > 0 || others.notice !== '') ? [drawCleanup(ui, others, actions)] : []),
341 ]
342 return ui.Box({ key: 'whiteboard', flexDirection: 'column', rowGap: 1, paddingX: 1, width: '100%', children })
343}
344
345/** The band's tree; `hint` is '' or the few words after the count; `onOpen` is what [開く] does. */
346export function drawBand(ui, surface, columns, count, hint, onOpen) {
347 const { Box, Text, Button } = ui
348 const fit = fitBand({ surface, columns, count, hint })
349 const children = []
350 if (fit.hasLabel) children.push(Box({ key: 'board-label', flexShrink: 0, children: [Text({ children: [fit.label] })] }))
351 children.push(
352 Box({ key: 'board-open-slot', flexShrink: 0, children: [Button({ key: 'board-open', label: fit.buttonLabel, variant: 'secondary', onPress: onOpen })] }),
353 )
354 return Box({ key: 'whiteboard-band', flexDirection: 'row', flexWrap: 'nowrap', columnGap: 1, alignItems: 'center', children })
355}
356hooks/press-guard.js 97 lines1// Runs a pane's Button press again when it did not reach the Button's onPress (the same file
2// as in usage-ledger and ui-sampler, kept here so the mods do not depend on each other).
3//
4// A Button's onPress stays in this mod's environment under a handle the host holds for the
5// life of one drawing. A pane is drawn again whenever its props change, so a press that comes
6// in just as the drawing it was aimed at is replaced (the first click on an unfocused pane
7// focuses it, and `isFocused` changes) can find its handle gone and never reach the closure.
8//
9// So each guarded drawing keeps its own Buttons' closures here, by requestId and key, and wraps
10// each onPress so that a run of it is seen. The ui.press hook (pane.js) opens a press record
11// before next(e); if the chain settles, fails, or stays silent past a grace without the
12// Button's onPress having started, the hook takes the press over and runs the closure the
13// latest drawing holds under the same key. A press runs at most once: a closure the engine
14// starts after the take-over finds the record taken and does nothing.
15//
16// Pure: no $ here, so the hook files keep every $ call themselves.
17
18/** How long the hook waits for the press to reach an onPress before it takes it over. */
19export const GRACE_MS = 2000
20
21// The closures of the latest finished drawing, by requestId, then key
22const drawn = new Map()
23// The press in flight per `requestId key`: { started, takenOver }
24const pending = new Map()
25
26const slot = (requestId, key) => requestId + ' ' + key
27
28/**
29 * Starts one drawing of the pane `requestId`. `wrap(table)` hands back the element table with
30 * its Button keeping each onPress here; `done(tree)` makes this drawing the latest and returns
31 * the tree. A drawing that fails before done() leaves the previous one in place.
32 */
33export function guardDrawing(requestId) {
34 const handlers = new Map()
35 return {
36 wrap(table) {
37 const Button = props => {
38 const onPress = props.onPress
39 if (typeof onPress !== 'function') return table.Button(props)
40 handlers.set(props.key, onPress)
41 return table.Button({ ...props, onPress: press => runGuarded(requestId, props.key, onPress, press) })
42 }
43 return { ...table, Button }
44 },
45 done(tree) {
46 drawn.set(requestId, handlers)
47 return tree
48 },
49 }
50}
51
52// The onPress the surface runs: marks the press as reached, unless the hook already took it
53// over, in which case it does nothing (the press has run once already)
54function runGuarded(requestId, key, onPress, press) {
55 const record = pending.get(slot(requestId, key))
56 if (record?.takenOver) return undefined
57 if (record) record.started = true
58 return onPress(press)
59}
60
61/**
62 * Opens the record of one press before next(e). Undefined when the latest drawing of that
63 * pane has no guarded Button under the key (another site, or a key this mod does not keep):
64 * such a press is only watched.
65 */
66export function beginPress(e) {
67 if (!drawn.get(e.requestId)?.has(e.element)) return undefined
68 const record = { started: false, takenOver: false }
69 pending.set(slot(e.requestId, e.element), record)
70 return record
71}
72
73/** Whether the press has reached (started) its Button's onPress. */
74export function hasStarted(record) {
75 return record.started
76}
77
78/**
79 * Takes the press over and runs the latest drawing's closure for its key with `press` (the
80 * ui.press argument). Marks the record first, so the engine's own onPress, if it still
81 * comes, does nothing. Resolves to what the closure returned.
82 */
83export function takeOver(record, press) {
84 record.takenOver = true
85 const onPress = drawn.get(press.requestId)?.get(press.element)
86 return onPress ? onPress(press) : undefined
87}
88
89/**
90 * Closes the record of a press the engine ran itself. A taken-over record stays until the
91 * next press on the key replaces it, so the engine's own onPress, arriving late, still finds it.
92 */
93export function endPress(press, record) {
94 const key = slot(press.requestId, press.element)
95 if (!record.takenOver && pending.get(key) === record) pending.delete(key)
96}
97hooks/mermaid.js 650 lines1// A small mermaid drawer for the cards' ```mermaid fences (pure: no $ here).
2//
3// The desktop's Markdown shows a mermaid fence as a code block, so the ones this file can read
4// are drawn as an SVG for the `Svg` element instead; anything else (another kind of diagram,
5// something it cannot parse, a diagram too big) gives null and the fence stays a code block.
6//
7// Read: flowchart / graph with the directions TD TB BT LR RL; nodes `A`, `A[text]`, `A(text)`,
8// `A((text))`, `A([text])`, `A{text}`, with quotes in the text allowed; edges `-->` `---` `-.->`
9// `-.-` `==>` `===`, `-- text -->`, `-->|text|`; chains `A --> B --> C`; `A & B --> C`; `;` and
10// newlines; `%%` comments; `style`, `classDef`, `class`, `linkStyle` and `click` lines are
11// skipped. A subgraph, `direction` and any other syntax give null.
12// Also sequenceDiagram with participants / actors and the arrows `->>` `-->>` `->` `-->` `-x`
13// `--x` `-)` `--)` with a message; notes, loops, activations and the rest give null.
14//
15// Drawn: layers by the longest path (a cycle's back edge is drawn as a returning curve), nodes
16// filled and edges and labels on their own small fills, so it reads on a light and a dark theme.
17
18import { cells } from './board.js'
19
20/** What a diagram may be at most; a bigger one is left as code. */
21export const MERMAID_LIMITS = {
22 nodes: 30,
23 edges: 60,
24 label: 80,
25 messages: 40,
26}
27
28// ---- Parsing
29
30const ID = /^[A-Za-z0-9_-ヿ㐀-鿿0-9A-Za-z]+/
31
32// The shapes: opener, closer, shape. The longer openers first
33const SHAPES = [
34 ['((', '))', 'circle'],
35 ['([', '])', 'stadium'],
36 ['[[', ']]', 'rect'],
37 ['{{', '}}', 'rect'],
38 ['[(', ')]', 'rect'],
39 ['[', ']', 'rect'],
40 ['(', ')', 'round'],
41 ['{', '}', 'diamond'],
42]
43
44// The edge forms, the dotted and thick before the plain: [pattern, style, hasHead]
45const CONNECTORS = [
46 [/^-\.+->/, 'dotted', true],
47 [/^-\.+-/, 'dotted', false],
48 [/^={2,}>/, 'thick', true],
49 [/^={3,}/, 'thick', false],
50 [/^-{2,}>/, 'normal', true],
51 [/^-{3,}/, 'normal', false],
52]
53
54// `-- text -->`: a start, the text, an end
55const LABELLED = /^(--|==|-\.)\s+(.+?)\s+(\.+->|\.+-(?!>)|-\.+->|-\.+-|={2,}>|={3,}|-{2,}>|-{3,})/
56
57/** A label as lines: <br> is a line break, quotes and blanks dropped. */
58const labelOf = (t) =>
59 String(t)
60 .replace(/<br\s*\/?>/gi, '\n')
61 .split('\n')
62 .map((l) => l.trim())
63 .filter((l, i, all) => l !== '' || all.length === 1)
64 .join('\n')
65 .trim()
66
67// Splits a source into statements: newlines, and `;` outside brackets and quotes
68function statementsOf(source) {
69 const out = []
70 for (const raw of String(source).split(/\r?\n/)) {
71 let line = ''
72 let depth = 0
73 let quoted = false
74 for (const ch of raw) {
75 if (ch === '"') quoted = !quoted
76 if (!quoted) {
77 if ('[({'.includes(ch)) depth++
78 if ('])}'.includes(ch)) depth = Math.max(0, depth - 1)
79 if (ch === ';' && depth === 0) {
80 out.push(line.trim())
81 line = ''
82 continue
83 }
84 }
85 line += ch
86 }
87 out.push(line.trim())
88 }
89 return out.filter((s) => s !== '' && !s.startsWith('%%'))
90}
91
92// The shape at s[i], or null: { shape, text, end }
93function shapeAt(s, i) {
94 for (const [open, close, shape] of SHAPES) {
95 if (!s.startsWith(open, i)) continue
96 const k = i + open.length
97 if (s[k] === '"') {
98 const q = s.indexOf('"', k + 1)
99 if (q < 0 || !s.startsWith(close, q + 1)) return null
100 return { shape, text: s.slice(k + 1, q), end: q + 1 + close.length }
101 }
102 const c = s.indexOf(close, k)
103 if (c < 0) return null
104 return { shape, text: s.slice(k, c), end: c + close.length }
105 }
106 return null
107}
108
109// A node at s[i]: { id, text?, shape?, end }, or null
110function nodeAt(s, i) {
111 const m = ID.exec(s.slice(i))
112 if (!m) return null
113 let end = i + m[0].length
114 const shaped = shapeAt(s, end)
115 if (shaped) end = shaped.end
116 const klass = /^:::[A-Za-z0-9_-]+/.exec(s.slice(end))
117 if (klass) end += klass[0].length
118 return { id: m[0], ...(shaped ? { text: shaped.text, shape: shaped.shape } : {}), end }
119}
120
121// An edge at s[i] (after blanks): { style, hasHead, label, end }, or null
122function connectorAt(s, i) {
123 const rest = s.slice(i)
124 const named = LABELLED.exec(rest)
125 if (named) {
126 const style = named[1] === '==' ? 'thick' : named[1] === '-.' ? 'dotted' : 'normal'
127 return { style, hasHead: named[3].endsWith('>'), label: named[2], end: i + named[0].length }
128 }
129 for (const [pattern, style, hasHead] of CONNECTORS) {
130 const m = pattern.exec(rest)
131 if (!m) continue
132 let end = i + m[0].length
133 let label = ''
134 const piped = /^\s*\|([^|]*)\|/.exec(s.slice(end))
135 if (piped) {
136 label = piped[1]
137 end += piped[0].length
138 }
139 return { style, hasHead, label, end }
140 }
141 return null
142}
143
144const skipBlanks = (s, i) => {
145 while (s[i] === ' ' || s[i] === '\t') i++
146 return i
147}
148
149/** Parses a flowchart / graph; null when it is not one or uses what is not read. */
150function parseFlowchart(source) {
151 const statements = statementsOf(source)
152 const header = /^(?:flowchart|graph)(?:\s+(TD|TB|BT|LR|RL))?$/i.exec(statements[0] ?? '')
153 if (!header) return null
154 const direction = (header[1] ?? 'TD').toUpperCase().replace('TB', 'TD')
155 const nodes = []
156 const edges = []
157 const find = (id) => nodes.find((n) => n.id === id)
158 const note = (n) => {
159 let node = find(n.id)
160 if (!node) {
161 if (nodes.length >= MERMAID_LIMITS.nodes) return null
162 node = { id: n.id, label: n.id, shape: 'rect' }
163 nodes.push(node)
164 }
165 if (n.text !== undefined) {
166 node.label = labelOf(n.text) || n.id
167 node.shape = n.shape
168 }
169 return node
170 }
171 for (const s of statements.slice(1)) {
172 if (/^(style|classDef|class|linkStyle|click)\b/.test(s)) continue
173 if (/^(subgraph|end|direction)\b/.test(s)) return null
174 let i = 0
175 let previous = null
176 for (;;) {
177 // A group of nodes: A & B & C
178 const group = []
179 for (;;) {
180 i = skipBlanks(s, i)
181 const n = nodeAt(s, i)
182 if (!n) return null
183 const node = note(n)
184 if (!node) return null
185 group.push(node)
186 i = skipBlanks(s, n.end)
187 if (s[i] === '&') {
188 i++
189 continue
190 }
191 break
192 }
193 if (previous) {
194 for (const from of previous.group) {
195 for (const to of group) edges.push({ from: from.id, to: to.id, ...previous.edge })
196 }
197 }
198 if (i >= s.length) break
199 const edge = connectorAt(s, i)
200 if (!edge) return null
201 previous = { group, edge: { style: edge.style, hasHead: edge.hasHead, label: labelOf(edge.label) } }
202 i = edge.end
203 }
204 if (edges.length > MERMAID_LIMITS.edges) return null
205 }
206 if (nodes.length === 0 || nodes.some((n) => [...n.label].length > MERMAID_LIMITS.label)) return null
207 if (edges.some((e) => [...e.label].length > MERMAID_LIMITS.label)) return null
208 return { kind: 'flowchart', direction, nodes, edges }
209}
210
211const MESSAGE = /^([^\s:+-]+?)\s*(--?>>|--?>|--?x|--?\))\s*[+-]?\s*([^\s:]+?)\s*:\s*(.*)$/
212const PARTICIPANT = /^(?:participant|actor)\s+([^\s]+)(?:\s+as\s+(.+))?$/
213
214/** Parses a sequence diagram of participants and messages; null otherwise. */
215function parseSequence(source) {
216 const statements = statementsOf(source)
217 if (!/^sequenceDiagram$/i.test(statements[0] ?? '')) return null
218 const participants = []
219 const messages = []
220 const touch = (id, label) => {
221 let p = participants.find((x) => x.id === id)
222 if (!p) {
223 if (participants.length >= MERMAID_LIMITS.nodes) return null
224 p = { id, label: id }
225 participants.push(p)
226 }
227 if (label) p.label = labelOf(label) || id
228 return p
229 }
230 for (const s of statements.slice(1)) {
231 const decl = PARTICIPANT.exec(s)
232 if (decl) {
233 if (!touch(decl[1], decl[2])) return null
234 continue
235 }
236 const m = MESSAGE.exec(s)
237 if (!m) return null
238 if (!touch(m[1]) || !touch(m[3])) return null
239 messages.push({ from: m[1], to: m[3], isDashed: m[2].startsWith('--'), hasHead: m[2].endsWith('>>') || m[2].endsWith('x') || m[2].endsWith(')'), text: labelOf(m[4]) })
240 if (messages.length > MERMAID_LIMITS.messages) return null
241 }
242 if (participants.length === 0 || messages.length === 0) return null
243 if (participants.some((p) => [...p.label].length > MERMAID_LIMITS.label)) return null
244 return { kind: 'sequence', participants, messages }
245}
246
247/** The model of a mermaid source (a flowchart or a sequence diagram), or null for anything not read. */
248export function parseMermaid(source) {
249 try {
250 return parseFlowchart(source) ?? parseSequence(source)
251 } catch {
252 return null
253 }
254}
255
256// ---- Layout of a flowchart
257
258const CHAR = 7 // pixels per character cell of the labels (13px type)
259const LINE = 17 // pixels per line of a label
260
261const linesOf = (label) => String(label).split('\n')
262const textWidth = (label) => Math.max(...linesOf(label).map((l) => cells(l))) * CHAR
263
264/** The pixel size of a node by its shape and label. */
265export function sizeOf(node) {
266 const w = textWidth(node.label) + 24
267 const h = LINE * linesOf(node.label).length + 14
268 if (node.shape === 'diamond') return { w: Math.max(56, w * 1.5), h: Math.max(44, h * 1.7) }
269 if (node.shape === 'circle') return { w: Math.max(48, w * 1.25), h: Math.max(44, h * 1.5) }
270 return { w: Math.max(40, w), h: Math.max(30, h) }
271}
272
273/**
274 * Layers by the longest path, the edges that close a cycle left out of it:
275 * { layers: id[][], isBack: Set of edge indexes, layerOf: Map }.
276 */
277export function layersOf(model) {
278 const ids = model.nodes.map((n) => n.id)
279 const out = new Map(ids.map((id) => [id, []]))
280 model.edges.forEach((e, i) => {
281 if (e.from !== e.to) out.get(e.from).push({ to: e.to, index: i })
282 })
283 // Depth first in the order the nodes appeared: an edge to a node still open is a back edge
284 const state = new Map()
285 const isBack = new Set()
286 const visit = (id) => {
287 state.set(id, 1)
288 for (const { to, index } of out.get(id)) {
289 if (state.get(to) === 1) isBack.add(index)
290 else if (!state.has(to)) visit(to)
291 }
292 state.set(id, 2)
293 }
294 for (const id of ids) if (!state.has(id)) visit(id)
295 const preds = new Map(ids.map((id) => [id, []]))
296 model.edges.forEach((e, i) => {
297 if (e.from !== e.to && !isBack.has(i)) preds.get(e.to).push(e.from)
298 })
299 const layerOf = new Map()
300 const depth = (id) => {
301 if (!layerOf.has(id)) layerOf.set(id, preds.get(id).reduce((m, p) => Math.max(m, depth(p) + 1), 0))
302 return layerOf.get(id)
303 }
304 ids.forEach(depth)
305 const layers = []
306 for (const id of ids) (layers[layerOf.get(id)] ??= []).push(id)
307 // Order inside a layer by the mean place of the neighbours (two sweeps down and up)
308 const place = new Map()
309 const mark = () => layers.forEach((l) => l.forEach((id, k) => place.set(id, k)))
310 mark()
311 const succs = new Map(ids.map((id) => [id, []]))
312 model.edges.forEach((e, i) => {
313 if (e.from !== e.to && !isBack.has(i)) succs.get(e.from).push(e.to)
314 })
315 const sweep = (list, neighbours) => {
316 const keyed = list.map((id, k) => {
317 const near = neighbours.get(id)
318 return { id, k, key: near.length ? near.reduce((s, n) => s + place.get(n), 0) / near.length : place.get(id) }
319 })
320 keyed.sort((a, b) => a.key - b.key || a.k - b.k)
321 return keyed.map((x) => x.id)
322 }
323 for (let round = 0; round < 2; round++) {
324 for (let l = 1; l < layers.length; l++) {
325 layers[l] = sweep(layers[l], preds)
326 mark()
327 }
328 for (let l = layers.length - 2; l >= 0; l--) {
329 layers[l] = sweep(layers[l], succs)
330 mark()
331 }
332 }
333 return { layers, isBack, layerOf }
334}
335
336/** Places the nodes: { boxes: Map id -> { x, y, w, h, shape, label } (centres), layers, isBack, layerOf }. */
337export function layoutFlowchart(model) {
338 const { layers, isBack, layerOf } = layersOf(model)
339 const isVertical = model.direction === 'TD' || model.direction === 'BT'
340 const sizes = new Map(model.nodes.map((n) => [n.id, sizeOf(n)]))
341 const main = (id) => (isVertical ? sizes.get(id).h : sizes.get(id).w)
342 const cross = (id) => (isVertical ? sizes.get(id).w : sizes.get(id).h)
343 const gapMain = isVertical ? 46 : 64
344 const gapCross = 28
345 const bandOf = layers.map((l) => Math.max(...l.map(main)))
346 const totalOf = layers.map((l) => l.reduce((s, id) => s + cross(id), 0) + gapCross * (l.length - 1))
347 const widest = Math.max(...totalOf)
348 const length = bandOf.reduce((s, b) => s + b, 0) + gapMain * (layers.length - 1)
349 const boxes = new Map()
350 let at = 0
351 layers.forEach((l, li) => {
352 let c = (widest - totalOf[li]) / 2
353 for (const id of l) {
354 const m = at + bandOf[li] / 2
355 const k = c + cross(id) / 2
356 // BT and RL run the layers the other way
357 const mm = model.direction === 'BT' || model.direction === 'RL' ? length - m : m
358 const node = model.nodes.find((n) => n.id === id)
359 boxes.set(id, { ...sizes.get(id), x: isVertical ? k : mm, y: isVertical ? mm : k, shape: node.shape, label: node.label })
360 c += cross(id) + gapCross
361 }
362 at += bandOf[li] + gapMain
363 })
364 return { boxes, layers, isBack, layerOf }
365}
366
367// ---- Drawing
368
369const esc = (s) =>
370 String(s)
371 .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '')
372 .replace(/&/g, '&')
373 .replace(/</g, '<')
374 .replace(/>/g, '>')
375 .replace(/"/g, '"')
376 .replace(/'/g, ''')
377
378const FONT = "system-ui, -apple-system, 'Segoe UI', 'Hiragino Sans', 'Yu Gothic UI', 'Meiryo', sans-serif"
379const COLOR = {
380 nodeFill: '#eef2ff',
381 nodeStroke: '#6475c9',
382 nodeText: '#1e2340',
383 line: '#7c869b',
384 tagFill: '#f6f7fb',
385 tagStroke: '#b8bfd2',
386 tagText: '#2b3040',
387}
388
389const n1 = (v) => Math.round(v * 10) / 10
390
391// A label's lines as <text>, centred on (x, y)
392function textAt(label, x, y, color) {
393 const lines = linesOf(label)
394 const top = y - ((lines.length - 1) * LINE) / 2 + 4.5
395 return lines.map((l, i) => `<text x="${n1(x)}" y="${n1(top + i * LINE)}" text-anchor="middle" font-size="13" fill="${color}">${esc(l)}</text>`).join('')
396}
397
398// The point on a node's outline in the direction (dx, dy) from its centre
399function edgePoint(box, dx, dy) {
400 const hw = box.w / 2
401 const hh = box.h / 2
402 const len = Math.hypot(dx, dy) || 1
403 const ux = dx / len
404 const uy = dy / len
405 let t
406 if (box.shape === 'circle') t = 1 / Math.hypot(ux / hw, uy / hh)
407 else if (box.shape === 'diamond') t = 1 / (Math.abs(ux) / hw + Math.abs(uy) / hh)
408 else t = Math.min(hw / (Math.abs(ux) || 1e-9), hh / (Math.abs(uy) || 1e-9))
409 return { x: box.x + ux * t, y: box.y + uy * t }
410}
411
412function nodeSvg(box) {
413 const { x, y, w, h } = box
414 const style = `fill="${COLOR.nodeFill}" stroke="${COLOR.nodeStroke}" stroke-width="1.5"`
415 let shape
416 if (box.shape === 'circle') shape = `<ellipse cx="${n1(x)}" cy="${n1(y)}" rx="${n1(w / 2)}" ry="${n1(h / 2)}" ${style}/>`
417 else if (box.shape === 'diamond') {
418 shape = `<polygon points="${n1(x)},${n1(y - h / 2)} ${n1(x + w / 2)},${n1(y)} ${n1(x)},${n1(y + h / 2)} ${n1(x - w / 2)},${n1(y)}" ${style}/>`
419 } else {
420 const r = box.shape === 'stadium' ? h / 2 : box.shape === 'round' ? 10 : 3
421 shape = `<rect x="${n1(x - w / 2)}" y="${n1(y - h / 2)}" width="${n1(w)}" height="${n1(h)}" rx="${r}" ${style}/>`
422 }
423 return shape + textAt(box.label, x, y, COLOR.nodeText)
424}
425
426// A small arrowhead with its tip at (x, y), pointing along (dx, dy)
427function headSvg(x, y, dx, dy) {
428 const len = Math.hypot(dx, dy) || 1
429 const ux = dx / len
430 const uy = dy / len
431 const bx = x - ux * 9
432 const by = y - uy * 9
433 return `<polygon points="${n1(x)},${n1(y)} ${n1(bx - uy * 4.5)},${n1(by + ux * 4.5)} ${n1(bx + uy * 4.5)},${n1(by - ux * 4.5)}" fill="${COLOR.line}"/>`
434}
435
436const tagSvg = (label, x, y) => {
437 const w = textWidth(label) + 12
438 const h = LINE * linesOf(label).length + 4
439 return `<rect x="${n1(x - w / 2)}" y="${n1(y - h / 2)}" width="${n1(w)}" height="${n1(h)}" rx="4" fill="${COLOR.tagFill}" stroke="${COLOR.tagStroke}" stroke-width="1"/>` + textAt(label, x, y, COLOR.tagText)
440}
441
442/** { source, width, height } of a flowchart's SVG. */
443function flowchartSvg(model) {
444 const { boxes, isBack, layerOf } = layoutFlowchart(model)
445 const parts = { lines: [], heads: [], nodes: [], tags: [] }
446 const points = []
447 const grow = (x, y) => points.push({ x, y })
448 for (const b of boxes.values()) {
449 grow(b.x - b.w / 2, b.y - b.h / 2)
450 grow(b.x + b.w / 2, b.y + b.h / 2)
451 }
452 // Edges between the same two nodes are spread to either side
453 const groups = new Map()
454 model.edges.forEach((e, i) => {
455 const key = [e.from, e.to].sort().join('\u0000')
456 groups.set(key, [...(groups.get(key) ?? []), i])
457 })
458 model.edges.forEach((e, i) => {
459 const a = boxes.get(e.from)
460 const b = boxes.get(e.to)
461 const dash = e.style === 'dotted' ? ' stroke-dasharray="5 4"' : ''
462 const width = e.style === 'thick' ? 3 : 1.5
463 const stroke = `fill="none" stroke="${COLOR.line}" stroke-width="${width}"${dash}`
464 if (e.from === e.to) {
465 const x = a.x + a.w / 2
466 parts.lines.push(`<path d="M ${n1(x)} ${n1(a.y - 7)} C ${n1(x + 34)} ${n1(a.y - 30)} ${n1(x + 34)} ${n1(a.y + 30)} ${n1(x)} ${n1(a.y + 7)}" ${stroke}/>`)
467 if (e.hasHead) parts.heads.push(headSvg(x, a.y + 7, -1, 0.35))
468 grow(x + 36, a.y - 30)
469 grow(x + 36, a.y + 30)
470 if (e.label) {
471 parts.tags.push(tagSvg(e.label, x + 40 + textWidth(e.label) / 2, a.y))
472 grow(x + 52 + textWidth(e.label), a.y)
473 }
474 return
475 }
476 const members = groups.get([e.from, e.to].sort().join('\u0000'))
477 const spread = (members.indexOf(i) - (members.length - 1) / 2) * 30
478 const span = Math.abs(layerOf.get(e.to) - layerOf.get(e.from))
479 const bend = spread + (span > 1 ? 40 : 0) + (isBack.has(i) ? 30 : 0)
480 // The bend is measured from the pair's own direction, so the two ways of a pair part
481 const [first, second] = [e.from, e.to].sort()
482 const p = boxes.get(first)
483 const q = boxes.get(second)
484 const len = Math.hypot(q.x - p.x, q.y - p.y) || 1
485 const nx = -(q.y - p.y) / len
486 const ny = (q.x - p.x) / len
487 const cx = (a.x + b.x) / 2 + nx * bend
488 const cy = (a.y + b.y) / 2 + ny * bend
489 const isCurved = bend !== 0
490 const start = edgePoint(a, (isCurved ? cx : b.x) - a.x, (isCurved ? cy : b.y) - a.y)
491 const end = edgePoint(b, (isCurved ? cx : a.x) - b.x, (isCurved ? cy : a.y) - b.y)
492 parts.lines.push(isCurved ? `<path d="M ${n1(start.x)} ${n1(start.y)} Q ${n1(cx)} ${n1(cy)} ${n1(end.x)} ${n1(end.y)}" ${stroke}/>` : `<line x1="${n1(start.x)}" y1="${n1(start.y)}" x2="${n1(end.x)}" y2="${n1(end.y)}" ${stroke}/>`)
493 if (e.hasHead) parts.heads.push(isCurved ? headSvg(end.x, end.y, end.x - cx, end.y - cy) : headSvg(end.x, end.y, end.x - start.x, end.y - start.y))
494 if (isCurved) {
495 grow(cx, cy)
496 grow(start.x, start.y)
497 grow(end.x, end.y)
498 }
499 if (e.label) {
500 const mx = isCurved ? 0.25 * start.x + 0.5 * cx + 0.25 * end.x : (start.x + end.x) / 2
501 const my = isCurved ? 0.25 * start.y + 0.5 * cy + 0.25 * end.y : (start.y + end.y) / 2
502 parts.tags.push(tagSvg(e.label, mx, my))
503 grow(mx - textWidth(e.label) / 2 - 8, my - 12)
504 grow(mx + textWidth(e.label) / 2 + 8, my + 12)
505 }
506 })
507 for (const b of boxes.values()) parts.nodes.push(nodeSvg(b))
508 return wrap(points, [...parts.lines, ...parts.heads, ...parts.nodes, ...parts.tags])
509}
510
511// The document around drawn parts: the points' box with a margin, the origin moved to it
512function wrap(points, body) {
513 const pad = 12
514 const minX = Math.min(...points.map((p) => p.x)) - pad
515 const minY = Math.min(...points.map((p) => p.y)) - pad
516 const width = Math.ceil(Math.max(...points.map((p) => p.x)) + pad - minX)
517 const height = Math.ceil(Math.max(...points.map((p) => p.y)) + pad - minY)
518 const source =
519 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${n1(minX)} ${n1(minY)} ${width} ${height}" font-family="${esc(FONT)}">` + body.join('') + '</svg>'
520 return { source, width, height }
521}
522
523/** { source, width, height } of a sequence diagram's SVG. */
524function sequenceSvg(model) {
525 const ps = model.participants
526 const boxW = ps.map((p) => Math.max(72, textWidth(p.label) + 24))
527 const index = new Map(ps.map((p, i) => [p.id, i]))
528 // The distance between the lifelines: wide enough for the boxes and for each message's text
529 const x = [boxW[0] / 2]
530 const need = ps.map(() => 0)
531 for (let i = 1; i < ps.length; i++) need[i] = Math.max(110, (boxW[i - 1] + boxW[i]) / 2 + 24)
532 const spans = model.messages
533 .map((m) => ({ a: Math.min(index.get(m.from), index.get(m.to)), b: Math.max(index.get(m.from), index.get(m.to)), w: textWidth(m.text) + 36 }))
534 .filter((s) => s.a !== s.b)
535 .sort((s, t) => s.b - s.a - (t.b - t.a))
536 for (const s of spans) {
537 let have = 0
538 for (let i = s.a + 1; i <= s.b; i++) have += need[i]
539 if (have < s.w) need[s.b] += s.w - have
540 }
541 for (let i = 1; i < ps.length; i++) x.push(x[i - 1] + need[i])
542 const headH = 32
543 const rowH = 38
544 const bottom = headH + 24 + model.messages.length * rowH + 8
545 const body = []
546 const points = [{ x: 0, y: 0 }]
547 ps.forEach((p, i) => {
548 body.push(`<line x1="${n1(x[i])}" y1="${headH}" x2="${n1(x[i])}" y2="${bottom}" stroke="${COLOR.line}" stroke-width="1" stroke-dasharray="4 4"/>`)
549 })
550 model.messages.forEach((m, k) => {
551 const y = headH + 24 + k * rowH + rowH / 2
552 const a = x[index.get(m.from)]
553 const b = x[index.get(m.to)]
554 const dash = m.isDashed ? ' stroke-dasharray="6 4"' : ''
555 const stroke = `fill="none" stroke="${COLOR.line}" stroke-width="1.5"${dash}`
556 if (a === b) {
557 body.push(`<path d="M ${n1(a)} ${n1(y - 6)} C ${n1(a + 44)} ${n1(y - 8)} ${n1(a + 44)} ${n1(y + 12)} ${n1(a)} ${n1(y + 10)}" ${stroke}/>`)
558 if (m.hasHead) body.push(headSvg(a, y + 10, -1, 0))
559 if (m.text) body.push(tagSvg(m.text, a + 52 + textWidth(m.text) / 2, y))
560 points.push({ x: a + 56 + textWidth(m.text), y })
561 return
562 }
563 const dir = b > a ? 1 : -1
564 body.push(`<line x1="${n1(a)}" y1="${n1(y)}" x2="${n1(b)}" y2="${n1(y)}" ${stroke}/>`)
565 if (m.hasHead) body.push(headSvg(b, y, dir, 0))
566 if (m.text) body.push(tagSvg(m.text, (a + b) / 2, y - 12))
567 })
568 // The participants' boxes, above and below the lifelines
569 for (const top of [0, bottom]) {
570 ps.forEach((p, i) => {
571 body.push(nodeSvg({ x: x[i], y: top + headH / 2, w: boxW[i], h: headH - 4, shape: 'rect', label: p.label.replace(/\n/g, ' ') }))
572 })
573 }
574 points.push({ x: x[ps.length - 1] + boxW[ps.length - 1] / 2, y: bottom + headH })
575 return wrap(points, body)
576}
577
578/** The drawing of a mermaid source: { source, width, height, alt }, or null when it is not one this file can draw. */
579export function mermaidSvg(source) {
580 const model = parseMermaid(source)
581 if (!model) return null
582 try {
583 const drawn = model.kind === 'flowchart' ? flowchartSvg(model) : sequenceSvg(model)
584 if (!Number.isFinite(drawn.width) || !Number.isFinite(drawn.height) || drawn.source.length > 100_000) return null
585 return { ...drawn, alt: altOf(model) }
586 } catch {
587 return null
588 }
589}
590
591/** What the drawing says in words, for a reader that cannot see it. */
592export function altOf(model) {
593 const flat = (s) => String(s).replace(/\n/g, ' ')
594 const text =
595 model.kind === 'flowchart'
596 ? 'フローチャート: ' + model.nodes.map((n) => flat(n.label)).join('、') + ' / ' + model.edges.map((e) => `${flat(model.nodes.find((n) => n.id === e.from).label)} → ${flat(model.nodes.find((n) => n.id === e.to).label)}${e.label ? `(${flat(e.label)})` : ''}`).join('、')
597 : 'シーケンス図: ' +
598 model.messages
599 .map((m) => `${flat(model.participants.find((p) => p.id === m.from).label)} → ${flat(model.participants.find((p) => p.id === m.to).label)}${m.text ? `: ${flat(m.text)}` : ''}`)
600 .join('、')
601 return text.length > 300 ? text.slice(0, 299) + '…' : text
602}
603
604// ---- Fences in a body
605
606/**
607 * A card's body as pieces: { type: 'md', text } and { type: 'mermaid', source, raw }. Only a
608 * ```mermaid fence (``` or ~~~) is its own piece; another fence is kept whole inside the text, so
609 * a mermaid fence shown inside it is left alone. A fence never closed stays text.
610 */
611export function splitFences(body) {
612 const lines = String(body).split('\n')
613 const pieces = []
614 let buffer = []
615 const flush = () => {
616 if (buffer.length > 0) pieces.push({ type: 'md', text: buffer.join('\n') })
617 buffer = []
618 }
619 let i = 0
620 while (i < lines.length) {
621 const open = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)\s*$/.exec(lines[i].replace(/\r$/, ''))
622 if (!open) {
623 buffer.push(lines[i++])
624 continue
625 }
626 const fence = open[1]
627 let closed = -1
628 for (let j = i + 1; j < lines.length; j++) {
629 const m = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(lines[j].replace(/\r$/, ''))
630 if (m && m[1][0] === fence[0] && m[1].length >= fence.length) {
631 closed = j
632 break
633 }
634 }
635 if (closed < 0) {
636 buffer.push(...lines.slice(i))
637 break
638 }
639 if (open[2].toLowerCase() === 'mermaid') {
640 flush()
641 pieces.push({ type: 'mermaid', source: lines.slice(i + 1, closed).join('\n'), raw: lines.slice(i, closed + 1).join('\n') })
642 } else {
643 buffer.push(...lines.slice(i, closed + 1))
644 }
645 i = closed + 1
646 }
647 flush()
648 return pieces
649}
650types/index.d.ts 101 lines1// The values whiteboard keeps in $.state for the session. The cards are also kept in $.store,
2// keyed by the session id, so they outlive the app.
3
4/** One card on the board: `id` names it for Claude, `body` is Markdown, `updatedAt` epoch ms. */
5export type WhiteboardCard = {
6 id: string
7 title: string
8 body: string
9 updatedAt: number
10 /** True for a card fixed to the top of the pane; absent otherwise. */
11 pinned?: true
12}
13
14/** Another session's board as the pane's list of boards to take over shows it. */
15export type WhiteboardOtherBoard = {
16 /** The session's id (the store keys are `board:<sid>`, `meta:<sid>` and, once taken over, `seal:<sid>`). */
17 sid: string
18 /** Its first 8 plain characters: the buttons' keys (`peek-<sid8>`, `take-<sid8>`) and the archive file's name. */
19 sid8: string
20 /** Epoch ms the board was last written (the meta's, else the newest card's); null when neither is known. */
21 updatedAt: number | null
22 /** The last folder of the session's working directory; 不明 when the board has no meta. */
23 cwdName: string
24 /** The cards the board holds. */
25 count: number
26 /** The first three titles in the pane's order, cut to 60 characters. */
27 titles: string[]
28 /** Up to 20 titles in the pane's order, for the opened row. */
29 allTitles: { title: string; pinned: boolean }[]
30 /** How many of the cards are pinned. */
31 pinnedCount: number
32 /** The first request the person made in that session (60 characters at most); null for a board saved before it was kept. */
33 firstPrompt: string | null
34 /** The session's title, when the app handed it over; null otherwise. */
35 title: string | null
36 /** The seal (the `seal:<sid>` value; a 0.9.0 board has it in its meta). Set on a sealed board; such a board is never in the list of boards to take over. */
37 handedOver: { to: string; toSid8: string; toCwdName: string; at: number } | null
38}
39
40/** A board of the clean-up list (switched off in this version). */
41export type WhiteboardCleanupRow = {
42 sid: string
43 sid8: string
44 updatedAt: number | null
45 cwdName: string
46 count: number
47 /** True for a board that was taken over (sealed). */
48 sealed: boolean
49}
50
51/** Where this session's cards came from, as the meta keeps it. */
52export type WhiteboardHandedFrom = {
53 sid: string
54 sid8: string
55 cwdName: string
56 updatedAt: number | null
57 count: number | null
58 /** Epoch ms of the hand-over. */
59 at: number
60}
61
62/** The confirmation step before a hand-over whose outcome is not plain. */
63export type WhiteboardConfirm = {
64 /** The board it is about. */
65 sid8: string
66 /** The cards of both boards when the plan was made; a different one means the plan is old. */
67 sig: string
68 plan: {
69 added: { id: string; title: string; pinned: boolean }[]
70 duplicates: { id: string; title: string; pinned: boolean }[]
71 overflow: { id: string; title: string; pinned: boolean }[]
72 }
73 /** True when the plan was made again because the boards had changed. */
74 recounted: boolean
75}
76
77declare module 'claude-code' {
78 interface PluginState {
79 whiteboard: {
80 /** The cards in the order they were added. Unset until they are loaded from the store. */
81 cards: WhiteboardCard[]
82 /** The session id the `cards` belong to. The drawings take `cards` only when it is this session's (the process goes on under another id after /clear). */
83 owner: string
84 /** The other sessions' boards in the store: the boards to take over (the first few, in order), how many more there are, the clean-up list (empty unless it is switched on), the JSON size of all the other keys, and the line about the last action (or ''). Never stored. */
85 others: { boards: WhiteboardOtherBoard[]; more: number; cleanup: WhiteboardCleanupRow[]; bytes: number; notice: string }
86 /**
87 * `sealed`: set when another session took this board over (read-only here).
88 * `from`: where this session's cards came from. `last`: the line about the last hand-over
89 * or unsealing, until the pane is opened again. Never stored (the meta keeps the first two).
90 */
91 handover: {
92 sealed: { to: string; toSid8: string; toCwdName: string; at: number } | null
93 from: WhiteboardHandedFrom | null
94 last: { text: string } | null
95 }
96 /** The pane's own state: the board opened in the list (a short id, '' for none), whether the list is shown over cards, the confirmation step. Never stored. */
97 view: { pick: string; shown: boolean; confirm: WhiteboardConfirm | null }
98 }
99 }
100}
101