SLOPSHOPPER

session-board

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-board
│ ┃ 工作台 ✕ › fix the failing auth test and add an audit╭──────────────────────╮ │ ┃ 這個對話還沒有狀態。 │ session-board │ │ ⏺ Read(src/auth.ts) │ 工作台會顯示其他對話(新開的對話也一樣) │ │ ⎿ Read 6 lines ╰──────────────────────╯ │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /board │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 工作台
這個對話還沒有狀態。
Pane · memory-view
記憶筆記(白話版) 0 條 ↻ 重新翻譯 這個專案還沒有記憶檔。
README

Claude Code mod 教學:session-board 工作台

這裡教的是 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

怎麼安裝(摘要)

  1. 取得整個資料夾(Download ZIP 解壓縮,或 git clone)。只拿單一份文件不夠,安裝要複製旁邊的 範本/session-board。
  2. 打開 Claude Code,請它讀 session-board-設計規格與安裝.md,照第 6-2 節幫您安裝;或照同一節手動做 5 步。
  3. 完整關掉 Claude Code 再開,依第 6-3 節的清單驗證。

完整步驟與常見問題請看 session-board-設計規格與安裝.md 第 6 節。

裝之前請先知道(它會做什麼)

  • 讀寫本機檔案:讀寫 ~/.claude/session-board/(每個對話一份狀態檔),並讀 ~/.claude/projects/ 內的記憶檔來顯示「記憶」。資料只在本機。
  • 會多用額度:按「記憶」「產生建議」「整理重點」時會呼叫模型一次,用量很小,但會算進您的額度。
  • 會改系統提示:它會在每個對話加一條「用台灣繁體中文回覆」的規則(程式裡的 ZH_TW_RULE)。不想要可在程式搜尋這個名稱後移除。
  • 不改您的專案檔。
  • mod 沒有沙盒:它用的是您的使用者權限。請只安裝您信任、並且看得懂做什麼的 mod;這份程式的原始碼就在 範本/session-board/hooks/register.tsx,裝之前建議自己(或請 Claude)讀過一遍。
  • 安裝會修改 ~/.claude/settings.json 的 env,請先備份。

測試範圍(誠實說明)

  • 在 Windows、Claude 桌面版(內含 Claude Code 2.1.299) 上測過安裝與載入;沒有放 .claude-plugin/types 資料夾也能載入,重開後 Claude Code 會自動產生。
  • 終端機版、Mac、其他版本沒有測過。
  • 視窗太窄時第 3 排按鈕可能換行,沒有針對各種寬度測過。

授權

MIT License,Copyright (c) 2026 Anson Chiang。

參考資料

Source 2 files
hooks/register.tsx 1263 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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 lines
1export 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