A band above the prompt showing what every session of this machine is doing, shared through status files.

這裡教的是 Claude Code 的 mod:一支跑在 Claude Code 裡面的程式,可以在畫面上加東西、加自己的斜線指令、改變它做事的方式。給 SA、PG、PM 看的,不需要先懂程式,用講的就能請 Claude 幫您寫。
範例是「session-board」:在輸入框上方加一個資訊與按鈕區,看得到額度與對話空間用了多少,並提供常用功能的按鈕。
| 想知道 | 看這份 |
|---|---|
| mod 是什麼、怎麼運作、怎麼安裝、安全與關閉、怎麼請 Claude 寫一個 | mod教學.md(魚骨圖:mod魚骨圖.svg) |
| session-board 的完整規格、安裝步驟、驗證清單、自己調整 | session-board-設計規格與安裝.md |
| 範例程式 | 範本/session-board |
| 對照用:會議紀錄整理(技能外掛,不是 mod:只有說明檔,沒有程式) | 範本/meeting-minutes |
git clone)。只拿單一份文件不夠,安裝要複製旁邊的 範本/session-board。session-board-設計規格與安裝.md,照第 6-2 節幫您安裝;或照同一節手動做 5 步。完整步驟與常見問題請看 session-board-設計規格與安裝.md 第 6 節。
~/.claude/session-board/(每個對話一份狀態檔),並讀 ~/.claude/projects/ 內的記憶檔來顯示「記憶」。資料只在本機。ZH_TW_RULE)。不想要可在程式搜尋這個名稱後移除。~/.claude/settings.json 的 env,請先備份。.claude-plugin/types 資料夾也能載入,重開後 Claude Code 會自動產生。MIT License,Copyright (c) 2026 Anson Chiang。
hooks/register.tsx 1263 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Limit, Mem, Row, Suggestion } from '../types'
5
6const DIR = '__你的CLAUDE資料夾__/session-board'
7const PROJECTS = '__你的CLAUDE資料夾__/projects'
8const MEM_PANE = 'memory-view'
9const BOARD_PANE = 'board'
10const BOARD_TITLE = '工作台'
11// what a control turns to under the pointer: a blue that reads on dark and light alike
12const HOVER_COLOR = '#3b82f6'
13// the suggestion the AI recommends: a green that reads on dark and light alike
14const RECOMMEND_COLOR = '#16a34a'
15const TICK_MS = 2000
16const STALE_MS = 15000
17const LABEL_LENGTH = 10
18const LABEL_STORE = 40
19const BAR = 10
20const PILL_BAR = 5
21const TWD_PER_USD = 31
22
23const rows = atom({ plugin: 'session-board', key: 'rows' } as const, [] as Row[])
24const selfId = atom({ plugin: 'session-board', key: 'selfId' } as const, '')
25const confirming = atom({ plugin: 'session-board', key: 'confirming' } as const, false)
26const compacting = atom({ plugin: 'session-board', key: 'compacting' } as const, false)
27
28const memItems = atom({ plugin: 'session-board', key: 'memItems' } as const, [] as Mem[])
29const memBusy = atom({ plugin: 'session-board', key: 'memBusy' } as const, false)
30const memOpen = atom({ plugin: 'session-board', key: 'memOpen' } as const, [] as string[])
31// the board shows this conversation alone until the person asks for every one
32const boardAll = atom({ plugin: 'session-board', key: 'boardAll' } as const, false)
33// off by default: the board and band keep to this conversation; /board-others turns it on, for every session
34const showOthers = atom({ plugin: 'session-board', key: 'showOthers' } as const, false)
35// when the person last pressed 產生建議: held by the host, so the timer's load sees a press from anywhere
36const suggestAsk = atom({ plugin: 'session-board', key: 'suggestAsk' } as const, 0)
37// whether the board shows the button guide at its top
38const helpOpen = atom({ plugin: 'session-board', key: 'helpOpen' } as const, false)
39
40// one entry per button: what it does, when to use it, what a press does. `fill` buttons put text in the composer.
41// Plain words on purpose: this is the guide the person reads in the board.
42const GUIDE = [
43 { key: 'board', label: '工作台', what: '開啟右側面板,看這個對話的狀態與下一步建議。', when: '想知道現在進度、或不知道接下來該做什麼時。', effect: '只是打開面板,不會改任何東西。' },
44 { key: 'memory', label: '記憶', what: '把 AI 替自己記的筆記翻成白話給您看。', when: '想確認 AI 記住了您的哪些習慣和規定時。', effect: '只讀取,不會修改記憶。' },
45 { key: 'compact', label: '整理重點', what: '把前面的對話濃縮成摘要,騰出空間。', when: '上下文接近滿(按鈕變橘色)、AI 開始忘記前面講的事時。', effect: '點了會先問您確認;整理後細節被濃縮,無法還原。' },
46 { key: 'suggest', label: '產生建議', what: '請 AI 根據目前對話,列出 2~3 個下一步。', when: '做完一段、不確定下一步,或想讓 AI 幫您想選項時。', effect: '結果顯示在工作台,點標題才會填入輸入框,不會自動送出。' },
47 { key: 'context', label: '上下文(上方膠囊)', what: '膠囊顯示這個對話的空間用了多少 %,越滿 AI 越容易忘記前面講的話。', when: '覺得 AI 變慢、變笨時看一眼;接近 70% 就該考慮「整理重點」。', effect: '只是顯示,不用按;綠色安全、黃色注意、紅色快滿。' },
48 { key: 'rewind', label: '退回(沒有按鈕,連按兩次 Esc)', what: '把對話或程式碼退回到之前某一次送出訊息的時間點。', when: 'AI 改壞了、或方向錯了想重來時。', effect: '在輸入框連按兩次 Esc(或輸入 /rewind),出現選單讓您挑時間點。注意:只能退回 AI 用編輯工具改的檔案。' },
49 { key: 'plan', label: '先規劃', fill: '/plan', what: '切到「只規劃、不動手」的模式,AI 只讀檔案和寫計畫。', when: '要改好幾個檔案,或您自己也還不確定怎麼做時。', effect: '放進輸入框,按 Enter 後生效;一句話能講完的小修改不需要。' },
50 { key: 'verify', label: '驗收提示', fill: '請自己跑測試或檢查,確認結果正確後,把執行的指令和輸出貼給我看,不要只說做好了。', what: '放入一段話,要求 AI 自己驗收並貼出證據。', when: 'AI 說「做好了」,但您不想自己再檢查一遍時。', effect: '只填入輸入框,您可以改字再送出。' },
51 { key: 'goal', label: '設目標', fill: '/goal ', what: '設定「做到什麼程度才算完成」,沒達成 AI 會自己繼續做。', when: '您要離開一陣子,又希望回來時事情是真的做完的。', effect: '放進輸入框,您接著打條件(例如「全部測試都通過」)再按 Enter;輸入 /goal clear 可取消。' },
52] as const
53const SHOW_OTHERS_KEY = 'showOthers'
54
55const COMPACT_HINT_PERCENT = 70
56// band layout: row 1 quotas and space, row 2 usage and sessions, row 3 buttons (the secondary ones drawn without icons)
57const ROW_QUOTA = ['five', 'seven', 'ctx']
58const SECONDARY = ['plan', 'verify', 'goal', 'help']
59// button colors by kind: look = blue, tidy up = amber / violet, tools = red / teal / green / indigo, help = grey
60const BUTTON_COLOR: Record<string, string> = {
61 board: '#60a5fa',
62 memory: '#60a5fa',
63 compact: '#fbbf24',
64 suggest: '#c084fc',
65 plan: '#2dd4bf',
66 verify: '#4ade80',
67 goal: '#818cf8',
68 help: '#94a3b8',
69}
70// the first button of each kind starts a new group, set apart by a wider gap
71const GROUP_START = ['compact', 'plan', 'help']
72const COMPACT_INSTRUCTIONS =
73 '保留:目標、已做的決定、還沒完成的事項、重要的檔案路徑、指令、數字與錯誤訊息。省略:嘗試錯誤的過程與重複的說明。'
74
75let me: Row | null = null
76let timer: unknown = null
77let hasName = false
78let hasWarned = false
79// bumped by each prompt, so a summary that finishes after the person answered is dropped
80let askSeq = 0
81// the last suggestion request this load took; null until its first look
82let takenRequest: number | null = null
83let seen: Record<string, Row> = {}
84// diagnostics: how often this session was asked to draw the band, and what it saw
85let renders = 0
86let listLen = -1
87
88// a session that ran shorter than this finishes without a notice
89const NOTIFY_MIN_MS = 10000
90const ZH_TW_RULE =
91 '一律使用繁體中文(台灣慣用詞彙)回覆,除非使用者明確要求其他語言。程式碼、指令、檔名、錯誤訊息與專有名詞保持原樣,不要翻譯。' +
92 '列出選項讓使用者選擇時,挑出你建議的一個,在該選項標題後加上「**(建議)**」。'
93
94const fileOf = (id: string) => `${DIR}/${id.replace(/[^\w-]/g, '_')}.json`
95// a request for suggestions, written by the band's button and taken by the session's timer
96const requestOf = (id: string) => `${DIR}/${id.replace(/[^\w-]/g, '_')}.suggest`
97
98// a typed /command, or the rows the transcript keeps for one: never the person's question
99const isSlashCommand = (text: string) =>
100 /<command-name>|<local-command-|<command-message>/.test(text) || cleanText(text).startsWith('/')
101
102const cleanText = (text: string) =>
103 text
104 .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
105 .replace(/<[^>]+>/g, '')
106 .trim()
107
108const clock = (ms: number) => {
109 const s = Math.max(0, Math.round(ms / 1000))
110
111 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
112}
113
114const usdOnly = (usd: number) => `US$${usd.toFixed(2)}`
115
116const money = (usd: number) => `${usdOnly(usd)} ≈NT$${Math.round(usd * TWD_PER_USD)}`
117
118const count = (n: number) =>
119 n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
120
121const until = (iso: string | undefined, now: number) => {
122 const ms = iso === undefined ? NaN : new Date(iso).getTime() - now
123
124 if (!(ms > 0)) {
125 return '—'
126 }
127
128 const minutes = Math.floor(ms / 60000)
129 const days = Math.floor(minutes / 1440)
130 const hours = Math.floor((minutes % 1440) / 60)
131
132 return days > 0 ? `${days}d ${hours}h` : `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
133}
134
135const cells = (percent: number, length: number) => {
136 const filled = Math.max(0, Math.min(length, Math.round((percent / 100) * length)))
137
138 return '▰'.repeat(filled) + '▱'.repeat(length - filled)
139}
140
141// green under 60%, amber under 85%, red above: [background, text]
142const heat = (percent: number) =>
143 percent < 60
144 ? ['#064e3b', '#6ee7b7']
145 : percent < 85
146 ? ['#78350f', '#fcd34d']
147 : ['#7f1d1d', '#fca5a5']
148
149// --- pills: the desktop draws the top row as vector capsules ---
150type Hue = { dark: string; light: string }
151
152const HUES = {
153 green: { dark: '#6ee7b7', light: '#047857' },
154 amber: { dark: '#fcd34d', light: '#b45309' },
155 red: { dark: '#fca5a5', light: '#b91c1c' },
156 purple: { dark: '#c4b5fd', light: '#6d28d9' },
157 pink: { dark: '#fda4af', light: '#be123c' },
158 teal: { dark: '#5eead4', light: '#0f766e' },
159 indigo: { dark: '#a5b4fc', light: '#4338ca' },
160 gold: { dark: '#fcd34d', light: '#a16207' },
161 slate: { dark: '#cbd5e1', light: '#475569' },
162} satisfies Record<string, Hue>
163
164const heatHue = (percent: number): Hue =>
165 percent < 60 ? HUES.green : percent < 85 ? HUES.amber : HUES.red
166
167// 24-unit stroke icons, drawn at 14px
168const ICONS = {
169 gauge: '<path d="M12 14l4-4"/><path d="M3.34 19a10 10 0 1 1 17.32 0"/>',
170 clock: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
171 calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
172 up: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/>',
173 down: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/>',
174 layers: '<path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
175 coin:
176 '<circle cx="12" cy="12" r="10"/><path d="M12 6v12"/>' +
177 '<path d="M15 9.5c0-1.2-1.3-2-3-2s-3 .8-3 2 1.3 1.7 3 2 3 .9 3 2.1-1.3 2-3 2-3-.8-3-2"/>',
178 chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2z"/>',
179 activity: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
180 help: '<circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
181 panel: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/>',
182 book:
183 '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/>' +
184 '<path d="M4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5"/>',
185 scissors:
186 '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>' +
187 '<path d="M20 4 8.12 15.88"/><path d="M14.47 14.48 20 20"/><path d="M8.12 8.12 12 12"/>',
188 sparkles: '<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 17v4M17 19h4"/>',
189 rewind: '<path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>',
190 list: '<path d="M3 6h18M3 12h18M3 18h12"/>',
191 check: '<path d="M20 6 9 17l-5-5"/>',
192}
193
194// a lone icon, for a control whose label stays a native button
195const iconSvg = (name: keyof typeof ICONS, hue: Hue) =>
196 `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24">` +
197 `<style>.i{fill:none;stroke:${hue.dark};stroke-width:2;stroke-linecap:round;stroke-linejoin:round}` +
198 `@media (prefers-color-scheme:light){.i{stroke:${hue.light}}}</style><g class="i">${ICONS[name]}</g></svg>`
199
200type Part =
201 | { kind: 'icon'; name: keyof typeof ICONS }
202 | { kind: 'text'; text: string; bold?: boolean; dim?: boolean }
203 | { kind: 'bar'; percent: number; pace?: number }
204 | { kind: 'rule' }
205
206const PILL_H = 26
207const PAD_X = 10
208const PART_GAP = 6
209const CHAR_W = 7
210// a Chinese character takes about two monospace cells
211const WIDE_W = 12
212const ICON_W = 14
213const BAR_W = 44
214
215const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
216
217const partWidth = (part: Part) =>
218 part.kind === 'icon'
219 ? ICON_W
220 : part.kind === 'bar'
221 ? BAR_W
222 : part.kind === 'rule'
223 ? 1
224 : [...part.text].reduce((sum, ch) => sum + (ch.charCodeAt(0) >= 0x2e80 ? WIDE_W : CHAR_W), 0)
225
226// one capsule: tinted fill, hairline border, parts laid left to right
227const pill = (hue: Hue, parts: Part[]) => {
228 const width = PAD_X * 2 + parts.reduce((sum, part) => sum + partWidth(part), 0) + PART_GAP * (parts.length - 1)
229 const mid = PILL_H / 2
230 let x = PAD_X
231 let body = ''
232
233 for (const part of parts) {
234 const w = partWidth(part)
235
236 if (part.kind === 'icon') {
237 body += `<g class="i" transform="translate(${x} ${mid - 7}) scale(${ICON_W / 24})">${ICONS[part.name]}</g>`
238 } else if (part.kind === 'text') {
239 body +=
240 `<text class="t${part.dim ? ' d' : ''}" x="${x}" y="${mid + 4}" textLength="${w}" lengthAdjust="spacing"` +
241 `${part.bold ? ' font-weight="700"' : ''}>${esc(part.text)}</text>`
242 } else if (part.kind === 'bar') {
243 const fill = Math.max(0, Math.min(BAR_W, (part.percent / 100) * BAR_W))
244
245 body += `<rect class="tr" x="${x}" y="${mid - 2}" width="${BAR_W}" height="4" rx="2"/>`
246 body += `<rect class="f" x="${x}" y="${mid - 2}" width="${fill.toFixed(1)}" height="4" rx="2"/>`
247
248 if (part.pace !== undefined) {
249 const at = x + Math.max(0, Math.min(1, part.pace)) * BAR_W
250
251 body += `<rect class="k" x="${(at - 1).toFixed(1)}" y="${mid - 5}" width="2" height="10" rx="1"/>`
252 }
253 } else {
254 body += `<rect class="r" x="${x}" y="${mid - 7}" width="1" height="14"/>`
255 }
256
257 x += w + PART_GAP
258 }
259
260 const style =
261 `.bg{fill:${hue.dark};fill-opacity:.1;stroke:${hue.dark};stroke-opacity:.35}` +
262 `.t{fill:${hue.dark};font:12px ui-monospace,Consolas,Menlo,monospace}.d{fill-opacity:.75}` +
263 `.i{fill:none;stroke:${hue.dark};stroke-width:2;stroke-linecap:round;stroke-linejoin:round}` +
264 `.tr{fill:${hue.dark};fill-opacity:.2}.f{fill:${hue.dark}}.k{fill:#f8fafc}.r{fill:${hue.dark};fill-opacity:.35}` +
265 `@media (prefers-color-scheme:light){.bg{fill:${hue.light};fill-opacity:.08;stroke:${hue.light}}` +
266 `.t,.f,.r,.tr{fill:${hue.light}}.i{stroke:${hue.light}}.k{fill:#334155}}`
267
268 const source =
269 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${PILL_H}" viewBox="0 0 ${width} ${PILL_H}">` +
270 `<style>${style}</style>` +
271 `<rect class="bg" x=".5" y=".5" width="${width - 1}" height="${PILL_H - 1}" rx="${mid - 0.5}"/>` +
272 `${body}</svg>`
273
274 return { source, width, height: PILL_H }
275}
276
277const WINDOW_MS = { five_hour: 5 * 3600e3, seven_day: 7 * 24 * 3600e3 } as Record<string, number>
278
279// share of the window already gone: usage left of the tick is under pace
280const paceOf = (limit: Limit, now: number) => {
281 const span = WINDOW_MS[limit.kind]
282 const left = limit.resetsAt === undefined ? NaN : new Date(limit.resetsAt).getTime() - now
283
284 return span && left > 0 ? 1 - left / span : undefined
285}
286
287const quotaPill = (name: string, icon: keyof typeof ICONS, hue: Hue, limit: Limit, now: number) =>
288 pill(hue, [
289 { kind: 'icon', name: icon },
290 { kind: 'text', text: name, dim: true },
291 { kind: 'bar', percent: limit.percentUsed, pace: paceOf(limit, now) },
292 { kind: 'text', text: `${Math.round(limit.percentUsed)}%`, bold: true },
293 { kind: 'rule' },
294 { kind: 'icon', name: 'clock' },
295 { kind: 'text', text: until(limit.resetsAt, now), dim: true },
296 ])
297
298const countPill = (icon: keyof typeof ICONS, hue: Hue, text: string) =>
299 pill(hue, [
300 { kind: 'icon', name: icon },
301 { kind: 'text', text, bold: true },
302 ])
303// --- end pills ---
304
305const barOf = (row: Row, now: number) => {
306 if (row.total !== undefined && row.total > 0) {
307 const filled = Math.round((BAR * (row.done ?? 0)) / row.total)
308
309 return `${'█'.repeat(filled)}${'░'.repeat(BAR - filled)} ${row.done ?? 0}/${row.total}`
310 }
311
312 if (row.status === 'running') {
313 const at = Math.floor(now / TICK_MS) % BAR
314
315 return Array.from({ length: BAR }, (_, i) => (i === at ? '▓' : '░')).join('')
316 }
317
318 return ''
319}
320
321async function save($: any, patch: Partial<Row>) {
322 if (me === null) {
323 return
324 }
325
326 me = { ...me, ...patch, updatedAt: await $.clock.now() }
327 await $.fs.write(fileOf(me.id), JSON.stringify(me))
328}
329
330async function refresh($: any) {
331 const now: number = await $.clock.now()
332 const entries = await $.fs.list(DIR).catch(() => [])
333 const found: Row[] = []
334
335 for (const entry of entries) {
336 if (!entry.name.endsWith('.json')) {
337 continue
338 }
339
340 try {
341 const row = JSON.parse(await $.fs.read(`${DIR}/${entry.name}`)) as Row
342
343 if (row.status !== 'ended' && now - row.updatedAt < STALE_MS) {
344 found.push(row)
345 }
346 } catch {
347 // a file caught mid-write: the next tick reads it whole
348 }
349 }
350
351 found.sort((a, b) => a.id.localeCompare(b.id))
352
353 // another session that was running and has gone idle: tell the person looking at this one
354 for (const row of found) {
355 const was = seen[row.id]
356
357 if (
358 me !== null &&
359 row.id !== me.id &&
360 was?.status === 'running' &&
361 row.status === 'idle' &&
362 now - was.startedAt >= NOTIFY_MIN_MS
363 ) {
364 $.ui.toast(`「${row.label.slice(0, LABEL_LENGTH)}」已完成(${clock(now - was.startedAt)})`)
365 }
366
367 }
368
369 seen = Object.fromEntries(found.map(row => [row.id, row]))
370 await update($, rows, () => found)
371}
372
373async function runCompact($: any) {
374 await update($, confirming, () => false)
375 await update($, compacting, () => true)
376
377 try {
378 // the same as typing /compact <instructions>: a desktop session is headless, where
379 // $.session.compact is refused and a slash prompt must go through the command noun
380 const result = await $.command.run({ command: 'compact', args: COMPACT_INSTRUCTIONS })
381
382 $.ui.toast(result?.text ? `整理重點:${String(result.text).slice(0, 80)}` : '已整理重點')
383 } catch (error) {
384 $.ui.toast(`整理失敗:${String(error)}`)
385 } finally {
386 await update($, compacting, () => false)
387 }
388}
389
390const MEM_PROMPT =
391 '背景:使用者在 Claude 桌面版裡,請 AI 助理做了一個叫 session-board 的外掛(mod),它在輸入框上方畫一條資訊列,顯示各個對話的狀態、花費與額度,列上有「✂ 整理重點」(壓縮對話內容)和「📖 記憶」兩個按鈕。「分割畫面」指桌面版把兩個對話左右並排。\n\n' +
392 '下面是 AI 助理寫給自己看的一則記憶筆記。請用繁體中文(台灣用語),像對完全不懂技術的人說明,2 句話以內:這在記什麼(要點名是哪個按鈕或哪個部分)、使用者遇到時該怎麼做。不要用術語,不要加標題或前言。\n\n'
393
394// one translation per note, run only when the person asks: it costs a small model call each
395async function loadMemory($: any) {
396 await $.ui.open({ id: MEM_PANE, title: '記憶(白話)' })
397 await update($, memBusy, () => true)
398
399 try {
400 const cwd: string = await $.session.cwd()
401 const dir = `${PROJECTS}/${cwd.replace(/[^A-Za-z0-9]/g, '-')}/memory`
402 const entries: Array<{ name: string }> = await $.fs.list(dir).catch(() => [])
403 const files = entries
404 .map(entry => entry.name)
405 .filter(name => name.endsWith('.md') && name !== 'MEMORY.md')
406 .sort()
407 const items: Mem[] = await Promise.all(
408 files.map(async file => ({
409 file,
410 raw: (await $.fs.read(`${dir}/${file}`)) as string,
411 plain: '',
412 state: 'wait' as const,
413 })),
414 )
415
416 await update($, memItems, () => items)
417 await Promise.all(
418 items.map(async item => {
419 let next: Pick<Mem, 'plain' | 'state'> = { plain: '', state: 'fail' }
420
421 try {
422 const result = await $.model.complete({
423 model: 'haiku',
424 prompt: MEM_PROMPT + item.raw,
425 effort: 'low',
426 maxTokens: 400,
427 })
428
429 next = result.isAnswered
430 ? { plain: result.text.trim(), state: 'done' }
431 : { plain: `翻譯失敗(${result.reason})`, state: 'fail' }
432 } catch (error) {
433 next = { plain: `翻譯失敗:${String(error)}`, state: 'fail' }
434 }
435
436 await update($, memItems, list =>
437 list.map(one => (one.file === item.file ? { ...one, ...next } : one)),
438 )
439 }),
440 )
441 } catch (error) {
442 $.ui.toast(`讀取記憶失敗:${String(error)}`)
443 } finally {
444 await update($, memBusy, () => false)
445 }
446}
447
448const openBoard = ($: any) => $.ui.open({ id: BOARD_PANE, title: BOARD_TITLE })
449
450const ASK_TAIL = 6000
451const ASK_MAX = 40
452const PROMPT_TAIL = 1500
453const NEXT_MAX = 3
454const NEXT_LABEL_MAX = 24
455const NEXT_DETAIL_MAX = 120
456const NEXT_PROMPT_MAX = 600
457// suggestions the person acts on need real judgment: a mid-size model, not the cheapest
458const NEXT_MODEL = 'sonnet'
459const SUGGEST_TIMEOUT_MS = 60000
460// how long a press shows 產生中 before the timer has taken it; past this the button is back
461const SUGGEST_PICKUP_MS = 6000
462const ASK_PROMPT =
463 '下面是使用者最後送出的訊息,和 AI 助理的回覆。請做兩件事:\n' +
464 '1. 判斷回覆結尾是否在等使用者做決定、回答問題或提供東西(例如「要我動手嗎?」「要不要一起刪掉?」「請截圖給我」)。' +
465 '如果是,用一句 30 字以內、讓人一眼看懂要決定什麼的話寫在 ask,例如「要不要刪掉 next-steps-app 草稿」。\n' +
466 '2. 寫出使用者最可能接著送出的 2 到 3 則訊息,放在 next。回覆裡有列出選項時,優先照那些選項寫,' +
467 '而且彼此要有明顯差別(例如:照做、換個做法、先確認)。每則包含:\n' +
468 ' label:20 字以內、看了就知道要做什麼的標題,要有具體對象' +
469 '(例如「把按鈕外框拿掉,只留滑鼠移過變色」,不要只寫「調整按鈕」);\n' +
470 ' detail:一到兩句、60 字以內,說明選了之後 AI 會做什麼、結果會怎樣;\n' +
471 ' prompt:使用者會直接送出的完整訊息,用使用者的口吻,具體到 AI 不用再追問就能做;\n' +
472 ' recommended:只有一則是 true,其餘 false。回覆裡標了(建議)的選項就選它;沒有標就選你判斷最好的一則。\n' +
473 '全部用繁體中文(台灣用語)。只回 JSON,不要其他文字:' +
474 '{"waiting": true 或 false, "ask": "...", "next": [{"label": "...", "detail": "...", "prompt": "...", "recommended": true 或 false}]}\n\n'
475
476// one model call each time the person presses 產生建議: what this session now waits on
477// the person for, and the few messages the person is likely to send next
478async function findAsk($: any, asked: string, answer: string, seq: number) {
479 let ask: string | undefined
480 let next: Suggestion[] | undefined
481 // how the call went, kept in the status file and told to the person when it fails
482 let outcome = ''
483 const started: number = await $.clock.now()
484
485 await save($, { suggestAt: started })
486
487 try {
488 const result = await $.model.complete({
489 model: NEXT_MODEL,
490 prompt: `${ASK_PROMPT}使用者:\n${asked.slice(-PROMPT_TAIL)}\n\n回覆:\n${answer.slice(-ASK_TAIL)}`,
491 effort: 'low',
492 maxTokens: 2000,
493 timeoutMs: SUGGEST_TIMEOUT_MS,
494 })
495
496 outcome = result.isAnswered ? 'answered' : `not answered: ${String(result.reason)}`
497
498 const text: string = result.isAnswered ? result.text : ''
499 const json = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) as {
500 waiting?: boolean
501 ask?: string
502 next?: Array<{ label?: unknown; detail?: unknown; prompt?: unknown; recommended?: unknown }>
503 }
504
505 ask = json.waiting && typeof json.ask === 'string' ? cleanText(json.ask).slice(0, ASK_MAX) || undefined : undefined
506
507 const items = (Array.isArray(json.next) ? json.next : [])
508 .map(item => ({
509 label: cleanText(String(item?.label ?? '')).slice(0, NEXT_LABEL_MAX),
510 detail: cleanText(String(item?.detail ?? '')).slice(0, NEXT_DETAIL_MAX) || undefined,
511 prompt: cleanText(String(item?.prompt ?? '')).slice(0, NEXT_PROMPT_MAX),
512 recommended: item?.recommended === true,
513 }))
514 .filter(item => item.label && item.prompt)
515 .slice(0, NEXT_MAX)
516
517 next = items.length > 0 ? items : undefined
518 outcome = next ? `ok: ${next.length}` : `${outcome}, no suggestions in the reply`
519 } catch (error) {
520 outcome = outcome ? `${outcome}, unreadable: ${String(error)}` : `failed: ${String(error)}`
521 }
522
523 const took = Math.round(((await $.clock.now()) - started) / 1000)
524 const isStale = seq !== askSeq || me?.status !== 'idle'
525
526 // the person already sent the next message: these suggestions answer the old one
527 await save($, {
528 suggestAt: undefined,
529 suggestNoteAt: started,
530 suggestNote: `${new Date(started).toLocaleTimeString()} ${took}s ${isStale ? 'stale' : outcome}`,
531 ...(isStale ? {} : { ask, next }),
532 })
533 if (!isStale && !next) {
534 $.ui.toast(`沒能產生建議(${took} 秒):${outcome.slice(0, 80)}`)
535 }
536}
537
538// a suggestion goes into the composer as a draft: the person edits or sends it
539async function fillPrompt($: any, text: string) {
540 try {
541 const filled = await $.prompt.fill({ text })
542
543 if (!filled?.isFilled) {
544 $.ui.toast('無法填入輸入框')
545 }
546 } catch (error) {
547 $.ui.toast(`無法填入:${String(error)}`)
548 }
549}
550
551// the band's button and /suggest do no work themselves: where they run is not the timer's load,
552// which holds this session's row. They leave the request in host-held state; the timer takes it.
553async function requestSuggest($: any) {
554 const at: number = await $.clock.now()
555
556 await update($, suggestAsk, () => at)
557 await openBoard($)
558}
559
560// on the timer's load: a request newer than the last one taken asks for suggestions
561async function takeRequest($: any) {
562 if (me === null) {
563 return
564 }
565
566 const fromFile = Number(await $.fs.read(requestOf(me.id)).catch(() => '0')) || 0
567 const fromState = (await read($, suggestAsk)) || 0
568 const request = Math.max(fromFile, fromState)
569
570 if (takenRequest === null) {
571 // the first look after a load: a request already there is an old one
572 takenRequest = request
573 } else if (request > takenRequest) {
574 takenRequest = request
575 void recoverAsk($)
576 }
577}
578
579// a request not yet taken, or a call under way: what the band and the board show as 產生中
580const isSuggestingFor = (row: Row | undefined, askedAt: number, now: number) =>
581 row?.suggestAt !== undefined || (askedAt > 0 && now - askedAt < SUGGEST_PICKUP_MS && !(row?.suggestNoteAt && row.suggestNoteAt >= askedAt))
582
583// reads the last exchange back and asks for suggestions about it
584async function recoverAsk($: any) {
585 if (me === null || me.status !== 'idle') {
586 $.ui.toast('回答還沒結束,等這輪跑完再產生建議')
587 return
588 }
589
590 const seq = askSeq
591 const messages: Array<{ role: string; text: string; toolResults?: unknown[] }> = await $.session
592 .messages()
593 .catch(() => [])
594 // the person's last prompt: a user row that is no tool result
595 let at = messages.length - 1
596
597 while (at >= 0) {
598 const m = messages[at]
599
600 if (m && m.role === 'user' && !m.toolResults?.length && cleanText(m.text) !== '' && !isSlashCommand(m.text)) {
601 break
602 }
603
604 at -= 1
605 }
606
607 if (at < 0) {
608 $.ui.toast('這個對話還沒有可以參考的問答')
609 return
610 }
611
612 const answer = messages
613 .slice(at + 1)
614 .filter(m => m.role === 'assistant')
615 .map(m => m.text)
616 .filter(Boolean)
617 .join('\n\n')
618
619 if (answer.trim() === '') {
620 $.ui.toast('找不到最後一則回答')
621 return
622 }
623
624 const asked = cleanText(messages[at]?.text ?? '')
625
626 await findAsk($, asked, answer, seq)
627}
628
629// a reload can land while the previous load's suggestion call is still out: that load writes
630// its result to this session's file afterwards, so take it in before the next write covers it
631async function adoptLate($: any) {
632 if (me === null || me.status !== 'idle' || me.ask !== undefined || me.next !== undefined) {
633 return
634 }
635
636 const disk = await $.fs
637 .read(fileOf(me.id))
638 .then((text: string) => JSON.parse(text) as Row)
639 .catch(() => undefined)
640
641 if (disk?.status === 'idle' && (disk.ask !== undefined || disk.next !== undefined)) {
642 me = { ...me, ask: disk.ask, next: disk.next }
643 }
644}
645
646async function tick($: any) {
647 try {
648 const usage = await $.session.usage().catch(() => undefined)
649 const limits: Limit[] = (usage?.rateLimits ?? [])
650 .filter((limit: Limit) => limit.kind === 'five_hour' || limit.kind === 'seven_day')
651 .map((limit: Limit) => ({
652 kind: limit.kind,
653 percentUsed: limit.percentUsed,
654 resetsAt: limit.resetsAt,
655 }))
656
657 await adoptLate($)
658 await takeRequest($)
659
660 await save($, {
661 renders,
662 listLen,
663 usd: usage?.cost?.usd,
664 ctx: usage?.context?.percent,
665 ...(limits.length > 0 ? { limits, limitsAt: await $.clock.now() } : {}),
666 })
667 await refresh($)
668 } catch (error) {
669 if (!hasWarned) {
670 hasWarned = true
671 $.ui.toast(`session-board: ${String(error)}`)
672 }
673 }
674}
675
676async function ensure($: any) {
677 if (me === null) {
678 const id: string = await $.session.id()
679 const cwd: string = await $.session.cwd()
680 const now: number = await $.clock.now()
681 const before = await $.fs
682 .read(fileOf(id))
683 .then((text: string) => JSON.parse(text) as Row)
684 .catch(() => undefined)
685
686 // a reload or a resume keeps what this session already counted
687 me = {
688 id,
689 label: before?.label ?? (cwd.split(/[\\/]/).filter(Boolean).pop() ?? id.slice(0, 6)),
690 status: 'idle',
691 startedAt: now,
692 updatedAt: now,
693 tin: before?.tin,
694 tout: before?.tout,
695 tcache: before?.tcache,
696 // what the last answer left waiting survives the reload that follows an edit
697 ask: before?.ask,
698 next: before?.next,
699 }
700 hasName = before !== undefined && before.label !== undefined
701 await update($, selfId, () => id)
702 }
703
704 if (timer === null) {
705 timer = $.clock.every(TICK_MS, () => tick($))
706 }
707
708 await tick($)
709}
710
711export const register: Register = on => {
712 on('prompt.compose', async ($, e, next) => {
713 const composed = await next(e)
714
715 return {
716 sections: [
717 ...composed.sections,
718 { id: 'session-board:zh-tw', text: ZH_TW_RULE, scope: 'session' as const },
719 ],
720 }
721 })
722
723 on('session.start', async ($, e, next) => {
724 await $.command.register({ name: 'board', description: '在右側開啟工作台:這個對話的狀態與下一步建議', immediate: true })
725 await $.command.register({ name: 'suggest', description: '產生這個對話的下一步建議(同資訊列的「產生建議」按鈕)', immediate: true })
726 await $.command.register({ name: 'board-others', description: '切換工作台是否顯示其他對話(所有對話共用這個設定)', immediate: true })
727 const stored = await $.store.get(SHOW_OTHERS_KEY).catch(() => undefined)
728 await update($, showOthers, () => stored === true)
729 await ensure($)
730
731 return next(e)
732 })
733
734 on('command.run', { command: 'board' }, async $ => {
735 await openBoard($)
736
737 return {}
738 })
739
740 // typed, or run by the band's button: leaves the request for this session's timer to take
741 on('command.run', { command: 'suggest' }, async $ => {
742 await requestSuggest($)
743
744 return { text: '已送出:正在產生下一步建議,結果會出現在右側工作台。' }
745 })
746
747 on('command.run', { command: 'board-others' }, async $ => {
748 const value = !(await read($, showOthers))
749
750 await $.store.set(SHOW_OTHERS_KEY, value)
751 await update($, showOthers, () => value)
752 $.ui.toast(value ? '工作台會顯示其他對話(新開的對話也一樣)' : '工作台只顯示這個對話')
753
754 return {}
755 })
756
757 on('prompt.submit', async ($, e, next) => {
758 await ensure($)
759
760 if (isSlashCommand(typeof e.text === 'string' ? e.text : '')) {
761 return next(e)
762 }
763
764 const now: number = await $.clock.now()
765 const text = typeof e.text === 'string' ? cleanText(e.text) : ''
766 const isNaming = !hasName && text !== ''
767
768 hasName = hasName || isNaming
769 askSeq += 1
770 await save($, {
771 status: 'running',
772 startedAt: now,
773 ask: undefined,
774 next: undefined,
775 ...(isNaming ? { label: text.slice(0, LABEL_STORE) } : {}),
776 })
777
778 return next(e)
779 })
780
781 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
782 const todos: Array<{ status: string }> = Array.isArray(e.todos) ? e.todos : []
783
784 await ensure($)
785 await save($, {
786 done: todos.filter(todo => todo.status === 'completed').length,
787 total: todos.length,
788 })
789
790 return next(e)
791 })
792
793 on('turn.complete', async ($, e, next) => {
794 await ensure($)
795
796 const usage = e.usage
797 // a subagent's turn ending leaves the main loop running
798 const isMain = e.agentId === undefined
799
800 // suggestions come only from the band's button: nothing is spent when the person does not ask
801
802 await save($, {
803 ...(isMain ? { status: 'idle' as const } : {}),
804 ...(usage
805 ? {
806 tin: (me?.tin ?? 0) + usage.input_tokens + usage.cache_creation_input_tokens,
807 tout: (me?.tout ?? 0) + usage.output_tokens,
808 tcache: (me?.tcache ?? 0) + usage.cache_read_input_tokens,
809 }
810 : {}),
811 })
812
813 return next(e)
814 })
815
816 on('session.end', async ($, e, next) => {
817 await save($, { status: 'ended' })
818
819 return next(e)
820 })
821
822 on('ui.render', { component: 'Pane', requestId: MEM_PANE }, async ($, e) => {
823 const { Box, Button, Text } = $.ui.resolve(e)
824 const items = await read($, memItems)
825 const busy = await read($, memBusy)
826 const open = await read($, memOpen)
827
828 return (
829 <Box flexDirection="column" gap={1}>
830 <Box gap={2}>
831 <Text bold>記憶筆記(白話版)</Text>
832 <Text dimColor>{busy ? '翻譯中…' : `${items.length} 條`}</Text>
833 {busy ? null : (
834 <Button key="again" plain label="↻ 重新翻譯" onPress={() => loadMemory($)} />
835 )}
836 </Box>
837 {items.length === 0 && !busy ? <Text dimColor>這個專案還沒有記憶檔。</Text> : null}
838 {items.map(item => {
839 const isOpen = open.includes(item.file)
840
841 return (
842 <Box key={item.file} flexDirection="column">
843 <Text bold color="#93c5fd">{item.file.replace(/\.md$/, '')}</Text>
844 <Text color={item.state === 'fail' ? '#fca5a5' : undefined} dimColor={item.state === 'wait'}>
845 {item.state === 'wait' ? '翻譯中…' : item.plain}
846 </Text>
847 <Button
848 key={`raw-${item.file}`}
849 plain
850 dimColor
851 label={isOpen ? '▾ 收起原文' : '▸ 看原文'}
852 onPress={() =>
853 update($, memOpen, list =>
854 list.includes(item.file) ? list.filter(f => f !== item.file) : [...list, item.file],
855 )
856 }
857 />
858 {isOpen ? <Text dimColor>{item.raw}</Text> : null}
859 </Box>
860 )
861 })}
862 </Box>
863 )
864 })
865
866 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
867 const list = await read($, rows)
868
869 renders += 1
870 listLen = list.length
871
872 if (e.props.hasSurvey || list.length === 0) {
873 return next(e)
874 }
875
876 const mine = await read($, selfId)
877 const isConfirming = await read($, confirming)
878 const isCompacting = await read($, compacting)
879 const now: number = await $.clock.now()
880 const isSuggesting = isSuggestingFor(list.find(row => row.id === mine), await read($, suggestAsk), now)
881 const { Box, Button, Text } = $.ui.resolve(e)
882 const running = list.filter(row => row.status === 'running').length
883 // this conversation's own question is in its pane: the band counts the others
884 const isShowingOthers = await read($, showOthers)
885 const waiting = isShowingOthers
886 ? list.filter(row => row.id !== mine && row.status !== 'running' && row.ask !== undefined).length
887 : 0
888 const priced = list.filter(row => row.usd !== undefined)
889 const total = priced.reduce((sum, row) => sum + (row.usd ?? 0), 0)
890 const self = list.find(row => row.id === mine)
891
892 // the account's windows are shared: show the freshest reading any session has
893 const fresh = list
894 .filter(row => row.limits !== undefined && row.limitsAt !== undefined)
895 .sort((a, b) => (b.limitsAt ?? 0) - (a.limitsAt ?? 0))[0]
896 const five = fresh?.limits?.find(limit => limit.kind === 'five_hour')
897 const seven = fresh?.limits?.find(limit => limit.kind === 'seven_day')
898
899 const Pill = (props: { key: string; bg: string; fg: string; text: string }) => (
900 <Text key={props.key} backgroundColor={props.bg} color={props.fg} bold>
901 {` ${props.text} `}
902 </Text>
903 )
904 const quota = (key: string, name: string, limit: Limit, tint?: string[]) => {
905 const [bg = '', fg = ''] = tint ?? heat(limit.percentUsed)
906
907 return Pill({
908 key,
909 bg,
910 fg,
911 text: `${name} ${cells(limit.percentUsed, PILL_BAR)} ${Math.round(limit.percentUsed)}% · ${until(limit.resetsAt, now)}`,
912 })
913 }
914
915 // the remote surfaces draw vector capsules; the terminal keeps its colored text
916 const Svg = e.surface === 'terminal' ? null : ($.ui.resolve(e) as any).Svg
917 const capsules = Svg
918 ? [
919 five ? { key: 'five', alt: `5 小時額度 ${Math.round(five.percentUsed)}%`, ...quotaPill('5h', 'gauge', heatHue(five.percentUsed), five, now) } : null,
920 seven
921 ? {
922 key: 'seven',
923 alt: `7 天額度 ${Math.round(seven.percentUsed)}%`,
924 ...quotaPill('7d', 'calendar', seven.percentUsed < 85 ? HUES.purple : HUES.red, seven, now),
925 }
926 : null,
927 self?.tin !== undefined ? { key: 'in', alt: `輸入 ${count(self.tin)}`, ...countPill('up', HUES.pink, count(self.tin)) } : null,
928 self?.tout !== undefined ? { key: 'out', alt: `輸出 ${count(self.tout)}`, ...countPill('down', HUES.teal, count(self.tout)) } : null,
929 self?.tcache !== undefined
930 ? { key: 'cache', alt: `快取 ${count(self.tcache)}`, ...countPill('layers', HUES.indigo, count(self.tcache)) }
931 : null,
932 self?.ctx !== undefined
933 ? {
934 key: 'ctx',
935 alt: `上下文 ${self.ctx}%`,
936 ...pill(heatHue(self.ctx), [
937 { kind: 'icon', name: 'gauge' },
938 { kind: 'text', text: '空間', dim: true },
939 { kind: 'bar', percent: self.ctx },
940 { kind: 'text', text: `${Math.round(self.ctx)}%`, bold: true },
941 ]),
942 }
943 : null,
944 self?.usd !== undefined && list.length < 2
945 ? {
946 key: 'usd',
947 alt: money(self.usd),
948 ...countPill('coin', HUES.gold, `$${self.usd.toFixed(2)}`),
949 }
950 : null,
951 ].filter(c => c !== null)
952 : []
953
954 return (
955 <Box flexDirection="column">
956 {Svg ? (
957 <Box flexDirection="column">
958 <Box gap={1} flexWrap="wrap">
959 {capsules.filter(c => ROW_QUOTA.includes(c.key)).map(c => (
960 <Box key={c.key}>
961 <Svg source={c.source} alt={c.alt} width={c.width} height={c.height} />
962 </Box>
963 ))}
964 </Box>
965 <Box gap={1} flexWrap="wrap" alignItems="center">
966 {[
967 ...capsules.filter(c => !ROW_QUOTA.includes(c.key)),
968 {
969 key: 'count',
970 alt: `${list.length} 個對話,${running} 執行中`,
971 ...countPill('chat', running > 0 ? HUES.green : HUES.slate, `${list.length} 對話 · ${running} 執行中`),
972 },
973 waiting > 0
974 ? { key: 'waiting', alt: `其他 ${waiting} 個對話在等你`, ...countPill('help', HUES.amber, `其他 ${waiting} 個在等你`) }
975 : null,
976 priced.length >= 2
977 ? { key: 'total', alt: `合計 ${money(total)}`, ...countPill('coin', HUES.gold, `合計 $${total.toFixed(2)}`) }
978 : null,
979 ]
980 .filter(c => c !== null)
981 .map(c => (
982 <Box key={c.key}>
983 <Svg source={c.source} alt={c.alt} width={c.width} height={c.height} />
984 </Box>
985 ))}
986 </Box>
987 </Box>
988 ) : (
989 <Box gap={1} flexWrap="wrap">
990 {five ? quota('five', '5h', five) : null}
991 {seven
992 ? quota('seven', '7d', seven, seven.percentUsed < 85 ? ['#3b0764', '#d8b4fe'] : undefined)
993 : null}
994 {self?.tin !== undefined
995 ? Pill({ key: 'in', bg: '#831843', fg: '#f9a8d4', text: `↑ ${count(self.tin)}` })
996 : null}
997 {self?.tout !== undefined
998 ? Pill({ key: 'out', bg: '#134e4a', fg: '#5eead4', text: `↓ ${count(self.tout)}` })
999 : null}
1000 {self?.tcache !== undefined
1001 ? Pill({ key: 'cache', bg: '#1e3a8a', fg: '#93c5fd', text: `快取 ${count(self.tcache)}` })
1002 : null}
1003 {self?.ctx !== undefined
1004 ? Pill({ key: 'ctx', bg: heat(self.ctx)[0] ?? '', fg: heat(self.ctx)[1] ?? '', text: `上下文 ${cells(self.ctx, PILL_BAR)} ${Math.round(self.ctx)}%` })
1005 : null}
1006 {self?.usd !== undefined && list.length < 2
1007 ? Pill({ key: 'usd', bg: '#78350f', fg: '#fcd34d', text: money(self.usd) })
1008 : null}
1009 </Box>
1010 )}
1011 {Svg ? (
1012 <Box gap={1} flexWrap="wrap" alignItems="center">
1013 {[
1014 { key: 'board', icon: 'panel' as const, label: '工作台', isHot: false, onPress: () => openBoard($) },
1015 { key: 'memory', icon: 'book' as const, label: '記憶', isHot: false, onPress: () => loadMemory($) },
1016 {
1017 key: 'compact',
1018 icon: 'scissors' as const,
1019 label: isCompacting ? '整理中…' : '整理重點',
1020 isHot: isCompacting || (self?.ctx ?? 0) >= COMPACT_HINT_PERCENT,
1021 onPress: () => (isCompacting ? undefined : update($, confirming, value => !value)),
1022 },
1023 {
1024 key: 'suggest',
1025 icon: 'sparkles' as const,
1026 label: isSuggesting
1027 ? `產生中 ${self?.suggestAt ? Math.max(0, Math.round((now - self.suggestAt) / 1000)) : 0}s`
1028 : '產生建議',
1029 isHot: isSuggesting,
1030 onPress: () => (isSuggesting ? undefined : void requestSuggest($)),
1031 },
1032 ...GUIDE.filter(g => 'fill' in g).map(g => ({
1033 key: g.key,
1034 icon: ({ plan: 'list', verify: 'check', goal: 'check' } as const)[g.key as 'plan'],
1035 label: g.label,
1036 isHot: false,
1037 onPress: () => void fillPrompt($, (g as { fill: string }).fill),
1038 })),
1039 {
1040 key: 'help',
1041 icon: 'help' as const,
1042 label: '說明',
1043 isHot: false,
1044 onPress: () => {
1045 void update($, helpOpen, () => true)
1046 void openBoard($)
1047 },
1048 },
1049 ].map(b => (
1050 <Box
1051 key={`chip-${b.key}`}
1052 alignItems="center"
1053 gap={1}
1054 paddingX={1}
1055 borderStyle="round"
1056 borderColor={b.isHot ? '#d97706' : (BUTTON_COLOR[b.key] ?? '#64748b')}
1057 marginLeft={GROUP_START.includes(b.key) ? 2 : 0}
1058 hover={{ borderColor: HOVER_COLOR }}
1059 >
1060 {SECONDARY.includes(b.key) ? null : (
1061 <Svg
1062 source={iconSvg(b.icon, { dark: BUTTON_COLOR[b.key] ?? '#cbd5e1', light: BUTTON_COLOR[b.key] ?? '#475569' })}
1063 alt={b.label}
1064 width={14}
1065 height={14}
1066 />
1067 )}
1068 <Button key={b.key} plain label={b.label} hover={{ bold: true }} onPress={b.onPress}>
1069 <Text color={BUTTON_COLOR[b.key]} bold={b.isHot}>{b.label}</Text>
1070 </Button>
1071 </Box>
1072 ))}
1073 </Box>
1074 ) : (
1075 <Box gap={2}>
1076 <Text bold>{`工作階段 ${list.length}`}</Text>
1077 <Text color={running > 0 ? '#4ade80' : undefined} dimColor={running === 0}>
1078 {`● ${running} 執行中`}
1079 </Text>
1080 {waiting > 0 ? <Text color="#fbbf24" bold>{`❓ 其他 ${waiting} 個在等你`}</Text> : null}
1081 {priced.length >= 2 ? (
1082 <Text color="#fbbf24">{`合計 ${money(total)}`}</Text>
1083 ) : null}
1084 <Button key="board" plain dimColor label="🗂 工作台" onPress={() => openBoard($)} />
1085 <Button key="memory" plain dimColor label="📖 記憶" onPress={() => loadMemory($)} />
1086 {isCompacting ? (
1087 <Text color="#fbbf24">✂ 整理中…</Text>
1088 ) : (
1089 <Button
1090 key="compact"
1091 plain
1092 dimColor={(self?.ctx ?? 0) < COMPACT_HINT_PERCENT}
1093 label="✂ 整理重點"
1094 onPress={() => update($, confirming, value => !value)}
1095 />
1096 )}
1097 <Button
1098 key="suggest"
1099 plain
1100 dimColor={!isSuggesting}
1101 label={isSuggesting ? '✨ 產生中…' : '✨ 產生建議'}
1102 onPress={() => (isSuggesting ? undefined : void requestSuggest($))}
1103 />
1104 {GUIDE.filter(g => 'fill' in g).map(g => (
1105 <Button key={g.key} plain dimColor label={g.label} onPress={() => void fillPrompt($, (g as { fill: string }).fill)} />
1106 ))}
1107 <Button
1108 key="help"
1109 plain
1110 dimColor
1111 label="?說明"
1112 onPress={() => {
1113 void update($, helpOpen, () => true)
1114 void openBoard($)
1115 }}
1116 />
1117 </Box>
1118 )}
1119 {isConfirming && !isCompacting ? (
1120 <Box gap={1}>
1121 <Text color="#fbbf24">
1122 {`✂ 要整理重點嗎?目前上下文 ${self?.ctx ?? '?'}%,整理後細節會被濃縮,無法還原。`}
1123 </Text>
1124 <Button key="yes" variant="primary" label="確定" onPress={() => runCompact($)} />
1125 <Button
1126 key="no"
1127 variant="secondary"
1128 label="取消"
1129 onPress={() => update($, confirming, () => false)}
1130 />
1131 </Box>
1132 ) : null}
1133 </Box>
1134 )
1135 })
1136
1137 on('ui.render', { component: 'Pane', requestId: BOARD_PANE }, async ($, e) => {
1138 const { Box, Button, Text } = $.ui.resolve(e)
1139 const list = await read($, rows)
1140 const mine = await read($, selfId)
1141 const now: number = await $.clock.now()
1142 const isShowingOthers = await read($, showOthers)
1143 const isAll = isShowingOthers && (await read($, boardAll))
1144 const isSuggesting = isSuggestingFor(list.find(row => row.id === mine), await read($, suggestAsk), now)
1145 const isWaiting = (row: Row) => row.status !== 'running' && row.ask !== undefined
1146 const others = list.filter(row => row.id !== mine)
1147 const othersWaiting = others.filter(isWaiting).length
1148 const othersRunning = others.filter(row => row.status === 'running').length
1149 const shown = isAll ? list : list.filter(row => row.id === mine)
1150 const groups = [
1151 { key: 'waiting', title: '❓ 等你回覆', color: '#fbbf24', items: shown.filter(isWaiting) },
1152 { key: 'running', title: '● 執行中', color: '#4ade80', items: shown.filter(row => row.status === 'running') },
1153 { key: 'idle', title: '○ 閒置', color: '#94a3b8', items: shown.filter(row => row.status !== 'running' && !isWaiting(row)) },
1154 ]
1155 const othersNote = [
1156 `其他 ${others.length} 個對話`,
1157 othersRunning > 0 ? `${othersRunning} 執行中` : '',
1158 othersWaiting > 0 ? `${othersWaiting} 在等你` : '',
1159 ]
1160 .filter(Boolean)
1161 .join(' · ')
1162
1163 const isHelp = await read($, helpOpen)
1164
1165 return (
1166 <Box flexDirection="column" gap={1}>
1167 {isHelp ? (
1168 <Box key="guide" flexDirection="column" gap={1}>
1169 <Box gap={1} alignItems="center">
1170 <Text bold>按鈕說明</Text>
1171 <Button key="guide-close" plain dimColor label="收起 ✕" onPress={() => update($, helpOpen, () => false)} />
1172 </Box>
1173 {GUIDE.map(g => (
1174 <Box key={g.key} flexDirection="column" borderStyle="round" borderColor="#475569" paddingX={1}>
1175 <Text bold>{g.label}</Text>
1176 <Text>{`做什麼:${g.what}`}</Text>
1177 <Text>{`什麼時候用:${g.when}`}</Text>
1178 <Text dimColor>{`點了會怎樣:${g.effect}`}</Text>
1179 </Box>
1180 ))}
1181 </Box>
1182 ) : null}
1183 {isShowingOthers && others.length > 0 ? (
1184 <Box key="scope" gap={1} alignItems="center">
1185 <Text dimColor>{isAll ? '全部對話' : othersNote}</Text>
1186 <Button
1187 key="scope-toggle"
1188 plain
1189 dimColor
1190 label={isAll ? '◂ 只看這個對話' : '展開全部 ▸'}
1191 hover={{ color: HOVER_COLOR, dimColor: false }}
1192 onPress={() => update($, boardAll, value => !value)}
1193 />
1194 </Box>
1195 ) : null}
1196 {shown.length === 0 ? <Text dimColor>這個對話還沒有狀態。</Text> : null}
1197 {groups.map(group =>
1198 group.items.length === 0 ? null : (
1199 <Box key={group.key} flexDirection="column" gap={1}>
1200 <Text bold color={group.color}>{`${group.title} ${group.items.length}`}</Text>types/index.d.ts 57 lines1export type Limit = {
2 kind: string
3 percentUsed: number
4 resetsAt?: string
5}
6
7export type Mem = {
8 file: string
9 raw: string
10 plain: string
11 state: 'wait' | 'done' | 'fail'
12}
13
14export type Suggestion = { label: string; detail?: string; prompt: string; recommended?: boolean }
15
16export type Row = {
17 id: string
18 label: string
19 status: 'running' | 'idle' | 'ended'
20 startedAt: number
21 updatedAt: number
22 done?: number
23 total?: number
24 usd?: number
25 ctx?: number
26 tin?: number
27 tout?: number
28 tcache?: number
29 limits?: Limit[]
30 limitsAt?: number
31 renders?: number
32 listLen?: number
33 ask?: string
34 next?: Suggestion[]
35 suggestAt?: number
36 suggestNote?: string
37 suggestNoteAt?: number
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'session-board': {
43 rows: Row[]
44 selfId: string
45 confirming: boolean
46 compacting: boolean
47 memItems: Mem[]
48 memBusy: boolean
49 memOpen: string[]
50 boardAll: boolean
51 showOthers: boolean
52 suggestAsk: number
53 helpOpen: boolean
54 }
55 }
56}
57