金魚列:自動記住工作堆疊,輸入列上方顯示主線和支線,收支線後提醒回主線

處理完支線,記得回主線。
一個 Claude Code Mod:自動記住 session 裡的工作堆疊,在輸入列上方畫一條金魚列。支線結束時,提醒你回到主線。
在 session 裡修一個 bug,中途發現要先確認另一個行為,接著又查了一份文件。支線處理完以後,你和 Claude 都忘了原本在修什麼。
gold-fish 讓 Claude 自己記下主線和支線。支線收起來時,金魚列提醒你回到主線。
| 🐟 自動記住 | Claude 開始多步驟工作時建立主線,話題轉開時開支線,你不用手動記錄 |
| ↩ 回主線提醒 | 支線結束時,金魚列標出「↩ 回到主線」,輸入列放一句灰字建議,按 Tab 採用 |
| 💾 跟著 session | 每個 session 有自己的工作堆疊,--resume 回來後還在 |
1. 只有主線
><> 修 typo-picker 重複標出
2. 開支線
∘ 修 typo-picker 重複標出 › ><> 確認 next(e) 的行為
3. 放不下時,中間變成合併項
∘ 修 typo-picker 重複標出 › ∘ 2 項 › ><> 查 Ink 的 wrap 參數
4. 收支線後的回主線提醒
↩ 回到主線 ><> 修 typo-picker 重複標出 (剛離開:確認 next(e) 的行為)
> 繼續修 typo-picker 重複標出 ← 輸入列灰字建議,Tab 採用
><> 標出目前的工作項,∘ 標出暫停的工作項。工作堆疊是空的時候,金魚列不顯示。
在 Claude Code 裡面(輸入列直接打,終端機和桌面 App 都可以):
/plugin marketplace add jaaaackieLai/claude-mods
/plugin install gold-fish@claude-mods
或在終端機:
claude plugin marketplace add jaaaackieLai/claude-mods
claude plugin install gold-fish@claude-mods
更新到最新版:在 Claude Code 裡打 /plugin marketplace update claude-mods,或在終端機執行 claude plugin marketplace update claude-mods。
平常不用做任何事。Claude 會依對話內容記錄工作堆疊。
| 想做的事 | 做法 |
|---|---|
| 收支線、改標題、刪除記錯的工作項 | 點金魚列上的工作項,從選單選一個操作 |
| 改標題 | 選「改標題」後,在對話框直接輸入新標題 |
| 處理合併項裡的工作項 | 點「∘ N 項」,先選工作項,再選操作。超過 4 項時選「更多…」翻頁 |
| 請 Claude 修正 | 直接說,例如「主線應該是修登入流程」,Claude 會改標題或刪除工作項 |
| 回到主線 | 收支線後,在輸入列按 Tab 採用灰字建議。送出下一則訊息後,提醒消失 |
| 名稱 | 意思 |
|---|---|
| 工作項 | 一件正在處理的工作,只有標題和狀態(進行中或暫停) |
| 主線 | 分出支線的工作項。最底層的工作項也叫主線 |
| 支線 | 從主線分出來的工作項。支線也可以再分出支線 |
| 收支線 | 把一個支線和它上面的工作項移出工作堆疊。支線完成或放棄都算 |
| 刪除工作項 | 只移除一個記錯的工作項,不顯示回主線提醒 |
完整的術語表見 docs/CONTEXT.md。
push_work_item、close_work_item、rename_work_item、remove_work_itemtypo-picker 的選字列衝突。它和其他 mod 的列一起顯示~/.claude/gold-fish/<session-id>.json,一個 session 一個檔案。有設定 CLAUDE_CONFIG_DIR 時,改存在那個資料夾下gold-fish 資料夾hooks/register.tsx 551 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Reminder, Stack, WorkItem } from '../types'
5import {
6 CURRENT_MARK,
7 GAP,
8 PAUSED_MARK,
9 SEPARATOR,
10 close,
11 contextText,
12 followReminder,
13 highestId,
14 layout,
15 push,
16 remove,
17 rename,
18 stackText,
19 textWidth,
20 truncate,
21} from './stack'
22import type { Failure } from './stack'
23
24/** 工作堆疊檔所在的資料夾,相對於 Claude Code 設定目錄 */
25const STACK_DIR = 'gold-fish'
26/** 金魚橘:目前的工作項的記號 */
27const GOLD = '#FF8C1A'
28/** 回主線提醒:深橘底、亮橘字 */
29const REMIND_BG = '#5C2E00'
30const REMIND_FG = '#FFB347'
31const REMIND_LABEL = ' ↩ 回到主線 '
32/** 「剛離開」後面的支線標題最多佔金魚列寬度的幾分之一,但至少有 LEFT_MIN 格 */
33const LEFT_SHARE = 4
34const LEFT_MIN = 12
35/** 顯示「剛離開」後,金魚列至少要留給工作項的格數 */
36const ITEMS_MIN = 12
37/** 選單的 3 個操作 */
38const ACTION = { close: '收支線', rename: '改標題', remove: '刪除' } as const
39const KEEP_TITLE = '保留原標題'
40const CANCEL = '取消'
41/** $.ui.ask 一次最多 4 個選項;合併項超過時分頁,最後一格是「更多」 */
42const ASK_MAX = 4
43const MORE = '更多…'
44/** 使用者自己送出的訊息。其他來源(通知、排程、其他 session)不清掉回主線提醒 */
45const USER_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
46
47/** transcript 裡 gold-fish 工具的列 */
48const TOOL_PREFIX = 'mcp__gold-fish__'
49const FISH = '🐟'
50const FAIL_COLOR = '#FF5F5F'
51
52const EMPTY: Stack = { items: [], lastId: 0 }
53
54const stackAtom = atom({ plugin: 'gold-fish', key: 'stack' } as const, EMPTY)
55const reminderAtom = atom({ plugin: 'gold-fish', key: 'reminder' } as const, null)
56const loadedAtom = atom({ plugin: 'gold-fish', key: 'loaded' } as const, null)
57
58type Args = Record<string, unknown>
59
60// 不用 $.store:它依 mod 的載入方式和 Claude Code 版本分成不同檔案(同 todo-calendar)。
61// 每個 session 一個檔案,--resume 後還讀得到。
62const stackPath = async ($: EngineInterface, session: string): Promise<string> => {
63 const file = `${STACK_DIR}/${session}.json`
64 const configDir = await $.env.get('CLAUDE_CONFIG_DIR')
65 if (configDir !== undefined && configDir !== '') {
66 return `${configDir.replace(/[\\/]+$/, '')}/${file}`
67 }
68 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
69
70 return `${home.replace(/[\\/]+$/, '')}/.claude/${file}`
71}
72
73/** 工具還在執行時,列上寫的動作和對象 */
74const runningText = (name: string, input: Args): string => {
75 const title = str(input.title)
76 const id = str(input.id)
77 if (name === 'push_work_item') {
78 return `開工作項:${title}…`
79 }
80 if (name === 'close_work_item') {
81 return `收工作項:${id ?? '最上層'}…`
82 }
83 if (name === 'rename_work_item') {
84 return `改標題:${id} ${title}…`
85 }
86
87 return `刪除工作項:${id}…`
88}
89
90const isFailure = (value: object): value is Failure => 'error' in value
91
92const isWorkItem = (item: unknown): item is WorkItem =>
93 typeof item === 'object' &&
94 item !== null &&
95 typeof (item as Args).id === 'string' &&
96 typeof (item as Args).title === 'string'
97
98/** 讀工作堆疊檔。檔案不存在時用空的工作堆疊;內容無法解析時回傳錯誤 */
99const readStackFile = async ($: EngineInterface, path: string): Promise<Stack | Failure> => {
100 if (!(await $.fs.exists(path))) {
101 return EMPTY
102 }
103 try {
104 const parsed = JSON.parse(await $.fs.read(path)) as { items?: unknown; lastId?: unknown }
105 // 有一個工作項格式不對就整個當成無法解析。只丟掉那一項的話,下次寫檔會把它從檔案刪掉
106 if (Array.isArray(parsed.items) && parsed.items.every(isWorkItem)) {
107 const items = parsed.items
108
109 // 沒有 lastId 的舊檔案,從現有的 id 算起
110 return { items, lastId: highestId(items, typeof parsed.lastId === 'number' ? parsed.lastId : 0) }
111 }
112 } catch {
113 // 落到下面的錯誤
114 }
115
116 return { error: `工作堆疊檔 ${path} 的內容無法解析。請修正或移走它,gold-fish 才會再記錄工作項。` }
117}
118
119/**
120 * 讀目前 session 的工作堆疊檔到 $.state,並清掉回主線提醒。內容無法解析時顯示空的工作堆疊,
121 * loaded 留 null:之後的變更都回傳錯誤,不寫檔蓋掉它
122 */
123const loadStack = async ($: EngineInterface): Promise<Failure | undefined> => {
124 const session = await $.session.id()
125 const file = await readStackFile($, await stackPath($, session))
126 const failed = isFailure(file)
127 await update($, stackAtom, () => (failed ? EMPTY : file))
128 await update($, reminderAtom, () => null)
129 await update($, loadedAtom, () => (failed ? null : session))
130 if (failed) {
131 $.ui.log(file.error, { to: 'debug' })
132
133 return file
134 }
135
136 return undefined
137}
138
139/** session 換了(/clear、resume),或工作堆疊檔還沒讀成功時,重讀一次 */
140const ensureLoaded = async ($: EngineInterface): Promise<Failure | undefined> =>
141 (await read($, loadedAtom)) === (await $.session.id()) ? undefined : loadStack($)
142
143/** 把 $.state 裡最新的工作堆疊寫檔 */
144const saveStack = async ($: EngineInterface): Promise<void> => {
145 const path = await stackPath($, await $.session.id())
146 const updatedAt = await $.clock.now()
147 const { items, lastId } = await read($, stackAtom)
148 await $.fs.write(path, `${JSON.stringify({ version: 1, items, lastId, updatedAt }, null, 2)}\n`)
149}
150
151/**
152 * 改工作堆疊並寫檔。change 必須是純函式:update 遇到同時的變更時,會用最新的工作堆疊重算。
153 * 回主線提醒跟著新的工作堆疊調整,例如開新的工作項或刪除主線後清掉
154 */
155const mutate = async <R extends { items: WorkItem[]; lastId?: number }>(
156 $: EngineInterface,
157 change: (items: WorkItem[], lastId: number) => R | Failure,
158): Promise<R | Failure> => {
159 const failed = await ensureLoaded($)
160 if (failed !== undefined) {
161 return failed
162 }
163 let outcome = undefined as R | Failure | undefined
164 const stack = await update($, stackAtom, current => {
165 outcome = change(current.items, current.lastId)
166
167 return isFailure(outcome) ? current : { items: outcome.items, lastId: outcome.lastId ?? current.lastId }
168 })
169 const result = outcome!
170 if (isFailure(result)) {
171 return result
172 }
173 await update($, reminderAtom, reminder => followReminder(stack.items, reminder))
174 await saveStack($)
175
176 return result
177}
178
179const returnText = (reminder: Reminder) => `繼續${reminder.returnTo}`
180
181const suggestReturn = ($: EngineInterface, reminder: Reminder) => {
182 void $.prompt.suggest({ text: returnText(reminder) })
183}
184
185/** 收支線:Claude 的工具和選單共用。工作堆疊沒變空時顯示回主線提醒 */
186const closeItem = async ($: EngineInterface, id: string | undefined): Promise<string | Failure> => {
187 const closed = await mutate($, items => close(items, id))
188 if (isFailure(closed)) {
189 return closed
190 }
191 if (closed.returnTo === undefined) {
192 return `已收起主線:${closed.left.title}。工作堆疊是空的。`
193 }
194 const reminder = { id: closed.returnTo.id, returnTo: closed.returnTo.title, left: closed.left.title }
195 await update($, reminderAtom, () => reminder)
196 // 回合進行中時輸入列不顯示建議,turn.complete 會再建議一次
197 suggestReturn($, reminder)
198
199 return `已收支線:${closed.left.title}。回到主線:${closed.returnTo.title}。`
200}
201
202const renameItem = async ($: EngineInterface, id: string, title: string): Promise<string | Failure> => {
203 const renamed = await mutate($, items => rename(items, id, title))
204
205 return isFailure(renamed) ? renamed : `已改標題:${renamed.item.id} ${renamed.item.title}。`
206}
207
208const removeItem = async ($: EngineInterface, id: string): Promise<string | Failure> => {
209 const removed = await mutate($, items => remove(items, id))
210
211 return isFailure(removed) ? removed : `已刪除工作項:${removed.removed.title}。`
212}
213
214/** 工具結果:做了什麼,加上更新後的工作堆疊,讓 Claude 知道 id */
215const answer = async ($: EngineInterface, done: string | Failure): Promise<ToolCallResult> => {
216 if (typeof done !== 'string') {
217 return { isError: true, result: done.error, text: done.error }
218 }
219
220 return { result: `${done}\n${stackText((await read($, stackAtom)).items)}` }
221}
222
223const str = (value: unknown): string | undefined =>
224 typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
225
226/** 問使用者。使用者關掉對話框時回傳 undefined */
227const ask = ($: EngineInterface, question: string, options: readonly string[], header: string) =>
228 $.ui.ask(question, { options, header }).catch(() => undefined)
229
230/** 點工作項跳出的選單:收支線、改標題、刪除工作項 */
231const openMenu = async ($: EngineInterface, item: WorkItem) => {
232 const choice = await ask($, `「${item.title}」要怎麼處理?`, [ACTION.close, ACTION.rename, ACTION.remove], 'gold-fish')
233 let done: string | Failure | undefined
234 if (choice === ACTION.close) {
235 done = await closeItem($, item.id)
236 } else if (choice === ACTION.remove) {
237 done = await removeItem($, item.id)
238 } else if (choice === ACTION.rename) {
239 // 選項以外,使用者可以在對話框直接輸入文字,那段文字就是新標題
240 const title = await ask($, `「${item.title}」的新標題是什麼?請直接輸入新標題。`, [KEEP_TITLE, CANCEL], ACTION.rename)
241 if (title !== undefined && title !== KEEP_TITLE && title !== CANCEL) {
242 done = await renameItem($, item.id, title)
243 }
244 }
245 // 選單跳出後,工作項可能已經被 Claude 移走
246 if (done !== undefined && typeof done !== 'string') {
247 $.ui.toast(done.error)
248 }
249}
250
251/** 點合併項:列出裡面的工作項,選一個後再跳出選單。超過 4 項時分頁 */
252const pickMerged = async ($: EngineInterface, merged: readonly WorkItem[]) => {
253 let rest = merged
254 while (rest.length > 1) {
255 const fits = rest.length <= ASK_MAX
256 const page = fits ? rest : rest.slice(0, ASK_MAX - 1)
257 // 選項不可重複:標題相同,或和「更多…」相同時,加上 id 區分
258 const labels = page.map(item =>
259 item.title === MORE || page.some(other => other !== item && other.title === item.title)
260 ? `${item.title}(${item.id})`
261 : item.title,
262 )
263 const choice = await ask($, '要處理哪一個工作項?', fits ? labels : [...labels, MORE], 'gold-fish')
264 const picked = choice === undefined ? undefined : page[labels.indexOf(choice)]
265 if (picked !== undefined) {
266 return openMenu($, picked)
267 }
268 if (choice !== MORE) {
269 return
270 }
271 rest = rest.slice(page.length)
272 }
273 if (rest[0] !== undefined) {
274 await openMenu($, rest[0])
275 }
276}
277
278export const register: Register = on => {
279 on('session.start', async ($, e, next) => {
280 await $.tool.register({
281 name: 'push_work_item',
282 description:
283 '把一個工作項放到 gold-fish 工作堆疊的最上層。工作堆疊是空的時候建立主線,否則開支線。開始多步驟工作,或話題轉到需要先處理的問題時呼叫。簡單問答不要呼叫。',
284 inputSchema: {
285 type: 'object',
286 properties: { title: { type: 'string', description: '工作項標題,簡短寫出要做什麼' } },
287 required: ['title'],
288 },
289 })
290 await $.tool.register({
291 name: 'close_work_item',
292 description:
293 '收支線:支線完成或放棄時呼叫。會一起移除它上面的工作項,並提醒使用者回到主線。最底層的主線完成時也用它。',
294 inputSchema: {
295 type: 'object',
296 properties: { id: { type: 'string', description: '工作項 id,例如 w2。省略時收最上層的工作項' } },
297 },
298 })
299 await $.tool.register({
300 name: 'rename_work_item',
301 description: '改工作項的標題。使用者說工作項記錯時用。',
302 inputSchema: {
303 type: 'object',
304 properties: {
305 id: { type: 'string', description: '工作項 id,例如 w1' },
306 title: { type: 'string', description: '新的標題' },
307 },
308 required: ['id', 'title'],
309 },
310 })
311 await $.tool.register({
312 name: 'remove_work_item',
313 description: '刪除一個記錯的工作項。只移除這一項,不提醒回主線。',
314 inputSchema: {
315 type: 'object',
316 properties: { id: { type: 'string', description: '工作項 id,例如 w2' } },
317 required: ['id'],
318 },
319 })
320 const failed = await loadStack($)
321 if (failed !== undefined) {
322 $.ui.toast(failed.error)
323 }
324
325 return next(e)
326 })
327
328 // /clear 和在同一個程序裡 resume 以後,session id 換了,但不會再觸發 session.start。
329 // 先清空,等設定檔的 SessionStart 事件讀新 session 的工作堆疊檔。
330 // 沒等到時,下一則訊息或下一次變更時再讀(ensureLoaded)
331 on('session.end', async ($, e, next) => {
332 if (e.reason === 'clear' || e.reason === 'resume') {
333 await update($, stackAtom, () => EMPTY)
334 await update($, reminderAtom, () => null)
335 await update($, loadedAtom, () => null)
336 }
337
338 return next(e)
339 })
340
341 // /clear 和 resume 以後設定檔的 SessionStart 事件仍會觸發。在這裡讀新 session 的工作堆疊檔,
342 // 金魚列不用等使用者送出訊息就顯示(ui.render 不能寫 $.state,所以不能在畫列時讀檔)。
343 // startup 不處理:session.start 已經讀過,檔案無法解析時才不會重複跳出錯誤
344 on('classic.SessionStart', async ($, e, next) => {
345 if (e.source === 'clear' || e.source === 'resume') {
346 const failed = await ensureLoaded($)
347 if (failed !== undefined) {
348 $.ui.toast(failed.error)
349 }
350 }
351
352 return next(e)
353 })
354
355 on('tool.call', { tool: 'mcp__gold-fish__push_work_item' }, async ($, e) => {
356 const title = str((e as unknown as Args).title)
357 if (title === undefined) {
358 return answer($, { error: 'title 不可空白。' })
359 }
360 // 開始新的工作項後,mutate 會清掉過時的回主線提醒
361 const pushed = await mutate($, (items, lastId) => ({ ...push(items, title, lastId), isMain: items.length === 0 }))
362 if (isFailure(pushed)) {
363 return answer($, pushed)
364 }
365
366 return answer($, `${pushed.isMain ? '已建立主線' : '已開支線'}:${pushed.id} ${title}。`)
367 })
368
369 on('tool.call', { tool: 'mcp__gold-fish__close_work_item' }, async ($, e) =>
370 answer($, await closeItem($, str((e as unknown as Args).id))),
371 )
372
373 on('tool.call', { tool: 'mcp__gold-fish__rename_work_item' }, async ($, e) => {
374 const args = e as unknown as Args
375 const id = str(args.id)
376 const title = str(args.title)
377 if (id === undefined || title === undefined) {
378 return answer($, { error: 'id 和 title 都不可空白。' })
379 }
380
381 return answer($, await renameItem($, id, title))
382 })
383
384 on('tool.call', { tool: 'mcp__gold-fish__remove_work_item' }, async ($, e) => {
385 const id = str((e as unknown as Args).id)
386 if (id === undefined) {
387 return answer($, { error: 'id 不可空白。' })
388 }
389
390 return answer($, await removeItem($, id))
391 })
392
393 // 每則訊息都附上工作堆疊和規則。回主線提醒只附在使用者送出的訊息上,送出後提醒消失。
394 // 通知、排程和其他 session 的訊息不是使用者回到主線,不附提醒,也不清掉它
395 on('prompt.submit', async ($, e, next) => {
396 const failed = await ensureLoaded($)
397 const fromUser = USER_ORIGINS.includes(e.origin.kind)
398 const reminder = fromUser ? await read($, reminderAtom) : null
399 const text =
400 failed === undefined ? contextText((await read($, stackAtom)).items, reminder) : `[gold-fish] ${failed.error}`
401 if (reminder !== null) {
402 await update($, reminderAtom, () => null)
403 }
404
405 return next({ ...e, context: [...(e.context ?? []), text] })
406 })
407
408 // Claude 在回合中收支線時,輸入列還不能顯示建議,回合結束後再建議
409 on('turn.complete', async ($, e, next) => {
410 const ran = await next(e)
411 const reminder = await read($, reminderAtom)
412 if (reminder !== null) {
413 suggestReturn($, reminder)
414 }
415
416 return ran
417 })
418
419 // 引擎自己的建議會蓋掉回主線的建議,有提醒時換成「繼續<主線>」
420 on('prompt.suggest', async ($, e, next) => {
421 const reminder = await read($, reminderAtom)
422 if (reminder !== null && e.origin.kind === 'suggestion') {
423 return next({ ...e, text: returnText(reminder) })
424 }
425
426 return next(e)
427 })
428
429 // transcript 裡 gold-fish 工具的列縮成一行淡色字,金魚列已經顯示整個工作堆疊
430 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
431 const { tool, input, output, isRunning, isErrored, isInterrupted } = e.props
432 // 中斷時照引擎原本的畫法,它會寫 Interrupted
433 if (!tool.startsWith(TOOL_PREFIX) || isInterrupted) {
434 return next(e)
435 }
436 const { Text } = $.ui.resolve(e)
437 if (isRunning) {
438 return <Text dimColor>{`${FISH} ${runningText(tool.slice(TOOL_PREFIX.length), input as Args)}`}</Text>
439 }
440 if (typeof output !== 'string') {
441 return next(e)
442 }
443 // 引擎畫列時,answer 回傳的 isError 不一定變成 isErrored。成功的結果後面一定附上工作堆疊,
444 // 所以只有一行的結果也是失敗
445 const [first, ...rest] = output.split('\n')
446 if (isErrored || rest.length === 0) {
447 return <Text color={FAIL_COLOR}>{`${FISH} 失敗:${first}`}</Text>
448 }
449
450 return <Text dimColor>{`${FISH} ${first}`}</Text>
451 })
452
453 // 工具結果已經畫在工具列上,結果區塊不畫
454 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
455 if (!e.props.tool.startsWith(TOOL_PREFIX) || typeof e.props.output !== 'string') {
456 return next(e)
457 }
458 const { Box } = $.ui.resolve(e)
459
460 return <Box />
461 })
462
463 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
464 const below = await next(e)
465 const { items } = await read($, stackAtom)
466 if (e.props.hasSurvey || items.length === 0) {
467 return below
468 }
469 const { Box, Button, Text } = $.ui.resolve(e)
470 const reminder = await read($, reminderAtom)
471
472 let columns = e.props.bodyColumns
473 let left: string | undefined
474 if (reminder !== null) {
475 columns -= textWidth(REMIND_LABEL) + GAP
476 const leftText = `(剛離開:${truncate(reminder.left, Math.max(LEFT_MIN, Math.floor(columns / LEFT_SHARE)))})`
477 const leftWidth = textWidth(leftText) + GAP
478 // 太窄時省略「剛離開」,把空間留給工作項
479 if (columns - leftWidth >= ITEMS_MIN) {
480 left = leftText
481 columns -= leftWidth
482 }
483 }
484
485 const slots = layout(items, columns)
486 // 太窄,連目前的工作項都放不下時不畫金魚列
487 if (slots.length === 0) {
488 return below
489 }
490 const cells = slots.flatMap((slot, index) => {
491 const separator =
492 index === 0
493 ? []
494 : [
495 <Text key={`separator-${index}`} dimColor>
496 {SEPARATOR}
497 </Text>,
498 ]
499 if (slot.kind === 'merged') {
500 return [
501 ...separator,
502 <Button key="merged" plain dimColor label={slot.label} onPress={() => pickMerged($, slot.items)} />,
503 ]
504 }
505 const { item, isCurrent } = slot
506 const mark = isCurrent ? (
507 <Text key={`mark-${item.id}`} color={GOLD} bold>
508 {CURRENT_MARK}
509 </Text>
510 ) : (
511 <Text key={`mark-${item.id}`} dimColor>
512 {PAUSED_MARK}
513 </Text>
514 )
515
516 return [
517 ...separator,
518 mark,
519 <Button key={`item-${item.id}`} plain dimColor={!isCurrent} label={slot.label} onPress={() => openMenu($, item)} />,
520 ]
521 })
522
523 const row = (
524 <Box key="gold-fish" flexDirection="row" alignItems="center" gap={GAP}>
525 {reminder !== null && (
526 <Text bold color={REMIND_FG} backgroundColor={REMIND_BG}>
527 {REMIND_LABEL}
528 </Text>
529 )}
530 {cells}
531 {left !== undefined && (
532 <Text dimColor wrap="truncate-end">
533 {left}
534 </Text>
535 )}
536 </Box>
537 )
538
539 if (!below) {
540 return row
541 }
542
543 return (
544 <Box flexDirection="column">
545 {row}
546 {below}
547 </Box>
548 )
549 })
550}
551hooks/stack.ts 302 lines1// 工作堆疊的純函式。不依賴 $,測試直接呼叫。
2// 陣列的第 0 項是最底層的主線,最後一項是目前的工作項。
3import type { Reminder, WorkItem } from '../types'
4
5export type Failure = { error: string }
6
7/** 金魚列上的一格:一個工作項,或放不下時合併的中間工作項 */
8export type Slot =
9 | { kind: 'item'; item: WorkItem; isCurrent: boolean; label: string }
10 | { kind: 'merged'; items: WorkItem[]; label: string }
11
12/** 目前的工作項的記號,後面空一格 */
13export const CURRENT_MARK = '><>'
14/** 暫停的工作項的記號,後面空一格 */
15export const PAUSED_MARK = '∘'
16/** 工作項之間的分隔,左右各空一格 */
17export const SEPARATOR = '›'
18
19const ELLIPSIS = '…'
20/** 截斷時,最底層的主線至少留下的寬度 */
21const MIN_TITLE = 4
22
23const notFound = (id: string | undefined): Failure => ({
24 error: id === undefined ? '工作堆疊是空的。' : `找不到 id 為 ${id} 的工作項。`,
25})
26
27const indexOf = (items: readonly WorkItem[], id: string | undefined): number =>
28 id === undefined ? items.length - 1 : items.findIndex(item => item.id === id)
29
30/** 用過的最大 id 編號:lastId 和現有的工作項 id(w 後面的數字)取最大 */
31export const highestId = (items: readonly WorkItem[], lastId = 0): number =>
32 Math.max(lastId, ...items.map(item => Number(/^w(\d+)$/.exec(item.id)?.[1] ?? 0)))
33
34/**
35 * 把新的工作項放到最上層。新 id 的編號比 lastId 和現有的 id 都大,
36 * 所以收掉或刪除的 id 不會再用,舊的工具結果裡的 id 不會指到別的工作項
37 */
38export const push = (
39 items: readonly WorkItem[],
40 title: string,
41 lastId = 0,
42): { items: WorkItem[]; id: string; lastId: number } => {
43 const next = highestId(items, lastId) + 1
44 const id = `w${next}`
45
46 return { items: [...items, { id, title: title.trim() }], id, lastId: next }
47}
48
49/** 收支線:移除 id 和它上面的工作項。省略 id 時收最上層 */
50export const close = (
51 items: readonly WorkItem[],
52 id?: string,
53): { items: WorkItem[]; left: WorkItem; returnTo: WorkItem | undefined } | Failure => {
54 const index = indexOf(items, id)
55 const left = items[index]
56 if (left === undefined) {
57 return notFound(id)
58 }
59 const kept = items.slice(0, index)
60
61 return { items: kept, left, returnTo: kept[kept.length - 1] }
62}
63
64/** 刪除工作項:只移除 id 這一項 */
65export const remove = (items: readonly WorkItem[], id: string): { items: WorkItem[]; removed: WorkItem } | Failure => {
66 const removed = items.find(item => item.id === id)
67 if (removed === undefined) {
68 return notFound(id)
69 }
70
71 return { items: items.filter(item => item !== removed), removed }
72}
73
74/** 改標題 */
75export const rename = (
76 items: readonly WorkItem[],
77 id: string,
78 title: string,
79): { items: WorkItem[]; item: WorkItem } | Failure => {
80 const trimmed = title.trim()
81 if (trimmed === '') {
82 return { error: '標題不可空白。' }
83 }
84 const found = items.find(item => item.id === id)
85 if (found === undefined) {
86 return notFound(id)
87 }
88 const item = { ...found, title: trimmed }
89
90 return { items: items.map(each => (each === found ? item : each)), item }
91}
92
93/**
94 * 工作堆疊改變後的回主線提醒。最上層不再是提醒的主線時(主線被刪除、工作堆疊變空、
95 * 開了新的工作項)回傳 null。主線改標題時,提醒跟著改
96 */
97export const followReminder = (items: readonly WorkItem[], reminder: Reminder | null): Reminder | null => {
98 const top = items[items.length - 1]
99 if (reminder === null || top === undefined || top.id !== reminder.id) {
100 return null
101 }
102
103 return top.title === reminder.returnTo ? reminder : { ...reminder, returnTo: top.title }
104}
105
106/**
107 * 終端機畫成 2 格的字(Unicode East Asian Width 是 W 或 F):中日韓文字、全形符號,
108 * 和 emoji,包括 ✅ ⚡ ⭐ 這類在 U+1F300 以前的寬符號
109 */
110const WIDE: readonly (readonly [number, number])[] = [
111 [0x1100, 0x115f],
112 [0x231a, 0x231b],
113 [0x2329, 0x232a],
114 [0x23e9, 0x23ec],
115 [0x23f0, 0x23f0],
116 [0x23f3, 0x23f3],
117 [0x25fd, 0x25fe],
118 [0x2614, 0x2615],
119 [0x2648, 0x2653],
120 [0x267f, 0x267f],
121 [0x2693, 0x2693],
122 [0x26a1, 0x26a1],
123 [0x26aa, 0x26ab],
124 [0x26bd, 0x26be],
125 [0x26c4, 0x26c5],
126 [0x26ce, 0x26ce],
127 [0x26d4, 0x26d4],
128 [0x26ea, 0x26ea],
129 [0x26f2, 0x26f3],
130 [0x26f5, 0x26f5],
131 [0x26fa, 0x26fa],
132 [0x26fd, 0x26fd],
133 [0x2705, 0x2705],
134 [0x270a, 0x270b],
135 [0x2728, 0x2728],
136 [0x274c, 0x274c],
137 [0x274e, 0x274e],
138 [0x2753, 0x2755],
139 [0x2757, 0x2757],
140 [0x2795, 0x2797],
141 [0x27b0, 0x27b0],
142 [0x27bf, 0x27bf],
143 [0x2b1b, 0x2b1c],
144 [0x2b50, 0x2b50],
145 [0x2b55, 0x2b55],
146 [0x2e80, 0x303e],
147 [0x3040, 0xa4cf],
148 [0xac00, 0xd7a3],
149 [0xf900, 0xfaff],
150 [0xfe30, 0xfe4f],
151 [0xff00, 0xff60],
152 [0xffe0, 0xffe6],
153 [0x1f004, 0x1f004],
154 [0x1f0cf, 0x1f0cf],
155 [0x1f18e, 0x1f18e],
156 [0x1f191, 0x1f19a],
157 [0x1f200, 0x1f2ff],
158 [0x1f300, 0x1faff],
159 [0x20000, 0x3fffd],
160]
161
162/** 一個字在終端機佔的格數 */
163const charWidth = (code: number): number => (WIDE.some(([low, high]) => code >= low && code <= high) ? 2 : 1)
164
165export const textWidth = (text: string): number => {
166 let width = 0
167 for (const char of text) {
168 width += charWidth(char.codePointAt(0) ?? 0)
169 }
170
171 return width
172}
173
174/** 截斷到 max 格以內,截掉時結尾放 … */
175export const truncate = (text: string, max: number): string => {
176 if (textWidth(text) <= max) {
177 return text
178 }
179 let out = ''
180 let width = 0
181 for (const char of text) {
182 const next = charWidth(char.codePointAt(0) ?? 0)
183 if (width + next > max - 1) {
184 break
185 }
186 out += char
187 width += next
188 }
189
190 return out + ELLIPSIS
191}
192
193/** 金魚列的 Box 在每個元素之間空的格數(gap)。寬度的計算都用它,register.tsx 畫列時也用它 */
194export const GAP = 1
195
196/** 記號的寬度,含後面的 gap */
197const MARK_WIDTH = { current: textWidth(CURRENT_MARK) + GAP, paused: textWidth(PAUSED_MARK) + GAP }
198/** 分隔的寬度,含左右的 gap */
199const SEPARATOR_WIDTH = textWidth(SEPARATOR) + 2 * GAP
200
201const mergedLabel = (count: number) => `${PAUSED_MARK} ${count} 項`
202
203/** 一列格子的總寬度,含記號和分隔 */
204const slotsWidth = (slots: readonly Slot[]): number =>
205 slots.reduce((sum, slot) => {
206 const mark = slot.kind === 'merged' ? 0 : slot.isCurrent ? MARK_WIDTH.current : MARK_WIDTH.paused
207
208 return sum + mark + textWidth(slot.label)
209 }, SEPARATOR_WIDTH * Math.max(0, slots.length - 1))
210
211const itemSlot = (item: WorkItem, isCurrent: boolean): Slot => ({ kind: 'item', item, isCurrent, label: item.title })
212
213/** 截斷標題到 max 格。max 至少是 1,所以標題至少留下「…」,按鈕不會變成空白 */
214const fit = (slot: Slot, max: number): Slot => (slot.kind === 'item' ? { ...slot, label: truncate(slot.label, max) } : slot)
215
216/**
217 * 算金魚列要顯示哪些格子。放不下時保留最底層的主線和目前的工作項,
218 * 中間從靠近目前的工作項開始盡量顯示,其餘變成合併項。還是放不下就截斷兩端的標題。
219 * 兩端的標題各留 1 格還放不下時,只顯示目前的工作項。連它都放不下時不顯示任何格子。
220 */
221export const layout = (items: readonly WorkItem[], columns: number): Slot[] => {
222 const last = items.length - 1
223 const all = items.map((item, index) => itemSlot(item, index === last))
224 if (all.length === 0 || slotsWidth(all) <= columns) {
225 return all
226 }
227 const first = all[0]!
228 const current = all[last]!
229
230 // shown 是中間要完整顯示的工作項數,從最上層往下算。shown 等於中間的項數時不合併
231 const middle = items.slice(1, last)
232 const build = (shown: number): Slot[] => {
233 const hidden = middle.slice(0, middle.length - shown)
234 const visible = all.slice(1 + hidden.length, last)
235 const merged: Slot[] = hidden.length > 0 ? [{ kind: 'merged', items: hidden, label: mergedLabel(hidden.length) }] : []
236
237 return [first, ...merged, ...visible, current]
238 }
239 // 從顯示最多的開始找放得下的。都放不下時,用最窄的去截斷標題:
240 // 合併項「∘ 1 項」可能比它取代的短標題寬,這時不合併比較窄
241 let slots = all
242 for (let shown = middle.length - 1; shown >= 0; shown -= 1) {
243 const candidate = build(shown)
244 if (slotsWidth(candidate) <= columns) {
245 return candidate
246 }
247 if (slotsWidth(candidate) < slotsWidth(slots)) {
248 slots = candidate
249 }
250 }
251
252 // 截斷兩端的標題:主線最多分到三分之一(至少 MIN_TITLE 格),目前的工作項用不完的空間也給主線
253 const ends = slots.length === 1 ? [current] : [first, current]
254 const blank = slots.map(slot => (slot.kind === 'item' && ends.includes(slot) ? { ...slot, label: '' } : slot))
255 const room = columns - slotsWidth(blank)
256 if (room < ends.length) {
257 // 兩端的標題各留 1 格都放不下:只顯示目前的工作項
258 const alone = columns - MARK_WIDTH.current
259
260 return alone >= 1 ? [fit(current, alone)] : []
261 }
262 if (slots.length === 1) {
263 return [fit(current, room)]
264 }
265 const share = Math.max(MIN_TITLE, Math.floor(room / 3), room - textWidth(current.label))
266 // 至少留 1 格給目前的工作項
267 const firstBudget = Math.max(1, Math.min(textWidth(first.label), share, room - 1))
268
269 return [fit(first, firstBudget), ...slots.slice(1, -1), fit(current, room - firstBudget)]
270}
271
272const TOOL = 'mcp__gold-fish__'
273
274/** 工作堆疊的文字:id、標題,和哪一個是進行中。工具結果和附給 Claude 的文字共用 */
275export const stackText = (items: readonly WorkItem[]): string => {
276 if (items.length === 0) {
277 return '工作堆疊是空的。'
278 }
279 const last = items.length - 1
280
281 return [
282 '工作堆疊(由下往上,最後一項是進行中的工作項):',
283 ...items.map((item, index) => `- ${item.id} ${item.title}(${index === last ? '進行中' : '暫停'})`),
284 ].join('\n')
285}
286
287/** 附給 Claude 的文字:目前的工作堆疊、工具使用規則,和回主線提醒 */
288export const contextText = (items: readonly WorkItem[], reminder: Reminder | null): string => {
289 const rules = [
290 '規則:',
291 `- 開始多步驟工作時,呼叫 ${TOOL}push_work_item 建立主線。簡單問答不建立。`,
292 `- 話題轉到需要先處理的問題時,呼叫 ${TOOL}push_work_item 開支線。`,
293 `- 支線完成或放棄時,呼叫 ${TOOL}close_work_item。`,
294 `- 使用者說工作項記錯時,用 ${TOOL}rename_work_item 或 ${TOOL}remove_work_item 修正。`,
295 '- 標題用使用者的語言,簡短寫出這件工作要做什麼。',
296 ]
297 const back = reminder === null ? [] : [`使用者剛回到主線:${reminder.returnTo}(剛離開:${reminder.left})`]
298
299 return ['[gold-fish]', stackText(items), ...rules, ...back].join('\n')
300}
301
302types/index.d.ts 22 lines1/** 一件正在處理的工作。狀態不另外存:最上層是進行中,其他是暫停 */
2export type WorkItem = { id: string; title: string }
3
4/** 工作堆疊,加上用過的最大 id 編號。lastId 讓收掉或刪除的 id 不再重用 */
5export type Stack = { items: WorkItem[]; lastId: number }
6
7/** 收支線後的回主線提醒:id 和 returnTo 是主線的 id 和標題,left 是剛離開的支線標題 */
8export type Reminder = { id: string; returnTo: string; left: string }
9
10declare module 'claude-code' {
11 interface PluginState {
12 'gold-fish': {
13 /** 工作堆疊。items 最底層在前,最上層(目前的工作項)在後 */
14 stack: Stack
15 /** 回主線提醒,沒有提醒時是 null。只放在 $.state,不寫檔 */
16 reminder: Reminder | null
17 /** stack 屬於哪個 session。null 代表還沒讀檔,或工作堆疊檔無法解析 */
18 loaded: string | null
19 }
20 }
21}
22