SLOPSHOPPER

gold-fish

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

newbandrowsguardtoastprompt
A shopper browsing a rack in a slop shop
README

gold-fish

處理完支線,記得回主線。

一個 Claude Code Mod:自動記住 session 裡的工作堆疊,在輸入列上方畫一條金魚列。支線結束時,提醒你回到主線。

Claude Code 2.1.286+ Terminal | Desktop


為什麼需要它

在 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。

  • 你每送出一則訊息,gold-fish 把目前的工作堆疊和使用規則附給 Claude。你看不到這段文字
  • Claude 用 4 個工具改工作堆疊:push_work_item、close_work_item、rename_work_item、remove_work_item
  • 這 4 個工具在對話紀錄裡只顯示一行淡色字,例如「🐟 已開支線:w2 …」。工具失敗時改成紅字「🐟 失敗:…」
  • 收起最底層的主線時,工作堆疊變空,金魚列直接隱藏,不顯示回主線提醒
  • 金魚列不設快捷鍵,避免和 typo-picker 的選字列衝突。它和其他 mod 的列一起顯示

資料位置與費用

  • 工作堆疊存在 ~/.claude/gold-fish/<session-id>.json,一個 session 一個檔案。有設定 CLAUDE_CONFIG_DIR 時,改存在那個資料夾下
  • 檔案內容無法解析時,gold-fish 不會寫檔蓋掉它,工具會回傳錯誤。修正或移走這個檔案後,gold-fish 才會再記錄工作項
  • gold-fish 不會自動刪除舊檔案。檔案很小,不需要時可以手動刪除整個 gold-fish 資料夾
  • 每則訊息附給 Claude 的文字只有幾行,會算在 context 用量裡
Source 3 files
hooks/register.tsx 551 lines
1import { 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}
551
hooks/stack.ts 302 lines
1// 工作堆疊的純函式。不依賴 $,測試直接呼叫。
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
302
types/index.d.ts 22 lines
1/** 一件正在處理的工作。狀態不另外存:最上層是進行中,其他是暫停 */
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