SLOPSHOPPER

tidy

Keeps everything Claude writes in view and folds each stretch of tool calls and thinking between texts into one line you can expand

newbandspinnerrowstimer
v0.4.0no licenseupdated 2026-10-08MomoChenisMe/claude-code-mods/tidy
A shopper browsing a rack in a slop shop
README

tidy:把 Claude Code 的工作過程收起來

English

Claude 寫給你的文字都留著;文字之間的工具呼叫和思考,每一段收成一行,點一下才展開。

功能

1. 過程收成一行

Claude 寫的每一段文字都留在對話裡。夾在文字之間的工具呼叫和結果、畫在畫面上的思考內容,以及工作中送來的訊息,每一段收成一行 › 處理了 N 秒。

tidy 截圖:Claude 先寫了一段說明,接著的工作收成「› 處理了」一行,最後的回覆留在下面,文字開頭都標著 ✻

  • 你的訊息:維持 Claude Code 原本的灰底樣式,tidy 不改。
  • Claude 寫的文字:開頭的圓點換成橘色粗體的 ✻,換行的部分縮排兩格對齊。先寫說明再做事時,說明也留著。
  • 每一段的時間從前一段文字算到後一段文字。有工具出錯時寫成 › 處理了 1 分 23 秒 · 1 個錯誤。
  • 問你問題時(AskUserQuestion、批准計畫的 ExitPlanMode):問題照原樣留在對話裡,不收進任何一段。
  • 原本每輪結尾的 Worked for … 不再顯示,因為時間已經寫在各段的那一行。

2. 點開看過程

點 › 變成 ⌄,這一段過程照原樣顯示,縮排兩格放在這一行底下。再點一次就收起來。每一段各自展開、收起。

tidy 截圖:點開後,這一段過程縮排在「⌄ 處理了」底下

展開的內容很長、捲到看不見 ⌄ 處理了 那一行時,輸入框上方會出現一列 ⌄ 處理了 N 秒 · ⌃ 收起。按 ⌃ 收起 就收起這一段,並把畫面捲回它的那一行,不用自己捲回去找。捲回看得到那一行時,這一列就消失。

tidy 截圖:捲到展開內容的中間,輸入框上方出現「⌄ 處理了 · ⌃ 收起」

3. 工作中顯示經過的時間與目前的動作

Claude 還在工作時,正在進行的那一段寫成 › 處理中 N 秒 · 目前的動作:

tidy 截圖:工作中的標頭寫著「處理中 6 秒 · 執行:Wait twelve seconds」

  • 時間從這一段開始算(前一段文字之後,第一段從這一輪開始),每秒更新。這一段結束後換成 › 處理了 N 秒。
  • 工具在跑時寫工具和參數,例如 執行:Run the tests、讀取 register.tsx。同時跑好幾個時加上「等 n 項」。
  • 工具跑完後保留最近一個動作,直到下一個工具開始,或模型寫出新的思考(顯示思考的第一句)。
  • 太長時截成一行。

4. subagent 回報卡片

回答之後才送來的 subagent 回報會讓 Claude 開始新的一輪,所以留在對話裡,畫成一行卡片:類型、任務說明,以及完成通知帶來的狀態和時間。

tidy 截圖:subagent 的回報畫成一行卡片「◆ Explore · 整理 statusbar 的按鈕 · 完成 5 秒 › 回報」

點一下這一列,回報全文縮排兩格展開在底下(Claude Code 會替展開的這一則加上淺灰底);再點一下收起。subagent 失敗或被停止時,◆ 和狀態是紅色。

tidy 截圖:點一下卡片,回報全文縮排兩格展開在底下

5. 其他一起收起來的列

  • 執行中指令下方的 (ctrl+b to run in background) 提示。
  • 工作中送來的 subagent 回報(Message from @…),收進那時候的那一段過程。
  • 背景工作完成的通知(Agent "…" finished、Background command "…" completed):工作中送來的收進那時候的那一段;回答之後才送來的,收進開這個工作的那一段。

需求

  • Claude Code 2.1.290 以上(以 2.1.291、2.1.292 測試)。mod 的 API 目前是 early access,Claude Code 改版時,這個 mod 可能要跟著更新。
  • 用滑鼠點 › 展開需要 Claude Code 的全螢幕版面(settings.json 的 "tui": "fullscreen")。沒開全螢幕時,過程一樣會收起來,只是點了沒有反應。

安裝

在 Claude Code 的輸入框輸入:

/plugin install tidy --marketplace MomoChenisMe/claude-code-mods

第一次會問要不要加入 github:MomoChenisMe/claude-code-mods 這個 marketplace,回答 y;範圍選 user,每個專案都會載入。

不想用時,執行 /plugin 把 tidy 停用,畫面就恢復原樣。

已知限制

  • 只整理載入 tidy 之後的對話:載入前就在畫面上的列,以及用 --resume 接回的舊對話,維持原樣。
  • 權限詢問、問答視窗、斜線指令的輸出照常顯示。
  • 同時開好幾個 subagent 時,Claude Code 把它們畫成一列 N background agents launched。這一列 mod 畫不到,收不起來;這種段落的 › 處理了 會畫在緊接著的文字上面。
  • 離開一陣子回來時出現的 ※ recap: 摘要也是 Claude Code 自己畫的,收不起來。不想看可以在 /config 關掉 recap。
  • 只改畫面:對話紀錄、ctrl+o 打開的完整紀錄、模型讀到的內容都不變。

開發

claude plugin validate tidy
claude plugin test tidy

本機開發方式見 repo 的 README。

Source 4 files
hooks/register.tsx 414 lines
1import { atom, memberOf, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, RenderViewport, ResolveInput, Timer } from 'claude-code'
3
4import type { Agent, Header, Turn, View } from '../types'
5
6import { noticeOf, senderOf, STATUS } from './agents'
7import { addNote, addRow, duration, endTurn, fit, groupOf, startTurn, textKey, views } from './turns'
8
9export const turns = atom({ plugin: 'tidy', key: 'turns' } as const, [] as Turn[])
10// 按「›」展開的過程(每段以它第一列的 id 記)。
11export const expanded = atom({ plugin: 'tidy', key: 'expanded' } as const, [] as string[])
12// 每一列自己的畫法。畫面只讀這一份,所以新增一列時只有變了的列重畫,長對話也不會拖慢。
13// 用 memberOf(view, { requestId: 列 id }) 取某一列的那份。
14const view = atom({ plugin: 'tidy', key: 'view' } as const, null as View | null)
15// subagent 回報卡片:回報列是誰送來的(以列 id 取),與那個 subagent 的說明和結果(以它的 id 取)。
16const sender = atom({ plugin: 'tidy', key: 'sender' } as const, null as string | null)
17const agent = atom({ plugin: 'tidy', key: 'agent' } as const, null as Agent | null)
18
19// 一個 ui.render 事件:畫在哪、哪一列、畫面多寬。
20type Drawn = ResolveInput & { requestId: string; viewport?: RenderViewport }
21// 一列在對話畫面裡露出的範圍(props.onScreen):null 是在畫面外,沒有這個欄位是畫面不回報。
22type OnScreen = { first: number } | null | undefined
23
24// 只寫畫法有變的列。
25const paint = async ($: EngineInterface, before: Record<string, View>, after: Record<string, View>) => {
26  for (const [id, next] of Object.entries(after)) {
27    if (JSON.stringify(before[id]) !== JSON.stringify(next)) {
28      await update($, memberOf(view, { requestId: id }), () => next)
29    }
30  }
31}
32
33// 改輪次表,再把有變的輪次重新算一次各列畫法。
34const change = async ($: EngineInterface, fn: (all: Turn[]) => Turn[]) => {
35  let before: Turn[] = []
36  let after: Turn[] = []
37  await update($, turns, all => {
38    before = all
39    after = fn(all)
40    return after
41  })
42  const [open, now] = await Promise.all([read($, expanded), $.clock.now()])
43  for (const turn of after) {
44    const old = before.find(t => t.id === turn.id)
45    if (old !== turn) {
46      await paint($, old === undefined ? {} : views(old, open, now), views(turn, open, now))
47    }
48  }
49}
50
51// 「處理中 12 秒」的時間每秒往前走:進行中的那一輪每秒重算一次,只有標頭那一列的畫法會變。
52// 這一輪結束(沒有進行中的輪次)就停。ticked 是上一次重算用的時間。
53let ticker: Timer | null = null
54let ticked = 0
55
56const tick = async ($: EngineInterface) => {
57  const current = (await read($, turns)).at(-1)
58  if (current === undefined || current.durationMs !== null) {
59    stopTicking()
60    return
61  }
62  const [open, now] = await Promise.all([read($, expanded), $.clock.now()])
63  await paint($, views(current, open, ticked), views(current, open, now))
64  ticked = now
65}
66
67const startTicking = async ($: EngineInterface) => {
68  if (ticker === null) {
69    ticked = await $.clock.now()
70    ticker = $.clock.every(1000, () => void tick($))
71  }
72}
73
74const stopTicking = () => {
75  ticker?.cancel()
76  ticker = null
77}
78
79const toggle = async ($: EngineInterface, group: string) => {
80  let before: string[] = []
81  let after: string[] = []
82  await update($, expanded, ids => {
83    before = ids
84    after = ids.includes(group) ? ids.filter(one => one !== group) : [...ids, group]
85    return after
86  })
87  const turn = (await read($, turns)).find(t => t.rows.some(r => groupOf(r) === group))
88  if (turn !== undefined) {
89    const now = await $.clock.now()
90    await paint($, views(turn, before, now), views(turn, after, now))
91  }
92}
93
94// 浮動列:展開中的一段,標頭捲出畫面、內容還在畫面上時,輸入框上方畫一列「⌄ 處理了 … · ⌃ 收起」,不用捲回去就
95// 能收起。每一列露出多少只在畫它的時候知道(捲動時,畫面邊緣的列會重畫),畫面 hook 又不能寫 $.state,所以記在
96// 模組裡;該出現、該換或該消失時,請引擎把這個 mod 畫的東西重畫一次。
97type Seen = { group: string; shows: boolean; header?: { label: string; requestId: string; shows: boolean } }
98const seen = new Map<string, Seen>()
99let stuck: { group: string; label: string; requestId: string } | null = null
100
101const stuckOf = () => {
102  const groups = new Map<string, Omit<Seen, 'group'>>()
103  for (const one of seen.values()) {
104    const g = groups.get(one.group)
105    groups.set(one.group, { shows: (g?.shows ?? false) || one.shows, header: one.header ?? g?.header })
106  }
107  const found = [...groups].filter(([, g]) => g.shows && g.header !== undefined && !g.header.shows).at(-1)
108  return found === undefined ? null : { group: found[0], label: found[1].header!.label, requestId: found[1].header!.requestId }
109}
110
111// 標頭那一列最上面是一行空白(marginTop),所以捲掉一行時標頭還看得到。
112const track = ($: EngineInterface, e: Drawn, site: string, view: View | null, onScreen: OnScreen) => {
113  if (onScreen === undefined) {
114    return
115  }
116  const id = `${site}:${e.requestId}`
117  if (view === null || view.kind === 'answer' || !view.isOpen) {
118    seen.delete(id)
119  } else {
120    const shows = onScreen !== null
121    seen.set(id, {
122      group: view.group,
123      shows,
124      header: view.kind === 'header' ? { label: view.label, requestId: e.requestId, shows: shows && onScreen.first <= 1 } : undefined,
125    })
126  }
127  const next = stuckOf()
128  if (JSON.stringify(next) !== JSON.stringify(stuck)) {
129    stuck = next
130    $.ui.invalidate('ui.render')
131  }
132}
133
134// 從浮動列收起:收起這一段,再把畫面捲回它的標頭。捲不動(畫面不支援)也已經收起了。
135const unstick = async ($: EngineInterface, at: NonNullable<typeof stuck>) => {
136  await toggle($, at.group)
137  await $.ui.scroll({ to: { requestId: at.requestId } }).catch(() => undefined)
138}
139
140// 過程裡的一列(含工具結果、執行中的「ctrl+b 放到背景」提示):收起來時藏起來,展開時照原樣、
141// 縮排兩格,看得出是「處理了」底下的內容。回答與不在輪次表裡的列照原樣。
142const tail = async ($: EngineInterface, e: Drawn, view: View | null, original: () => Promise<RenderElement>) => {
143  if (view === null || view.kind === 'answer') {
144    return original()
145  }
146  const { Box } = $.ui.resolve(e)
147  return view.isOpen ? (
148    <Box paddingLeft={2} flexDirection="column">
149      {await original()}
150    </Box>
151  ) : (
152    <Box />
153  )
154}
155
156// 「› 處理了 1 分 23 秒」;按下去展開或收起這一段過程。「處理中 · …」太長時截成一行。
157const headerRow = ($: EngineInterface, e: Drawn, header: Header) => {
158  const { Box, Button, Text } = $.ui.resolve(e)
159  const label = fit(header.label, (e.viewport?.columns ?? 80) - 8)
160  return (
161    <Box marginTop={1}>
162      <Button key={`group:${header.group}`} label={`${header.isOpen ? '⌄' : '›'} ${label}`} plain dimColor onPress={() => toggle($, header.group)} />
163      <Text> </Text>
164    </Box>
165  )
166}
167
168// 過程列:一段的第一個工具列換成「› 處理了 1 分 23 秒」,其餘藏起來;按下去展開,內容縮排在標頭下面。
169// 不是過程列(模型寫的文字、不在輪次表裡的列)回 null。
170const fold = async ($: EngineInterface, e: Drawn, view: View | null, original: () => Promise<RenderElement>): Promise<RenderElement | null> => {
171  if (view === null || view.kind === 'answer') {
172    return null
173  }
174  if (view.kind === 'work') {
175    return tail($, e, view, original)
176  }
177  const { Box } = $.ui.resolve(e)
178  return view.isOpen ? (
179    <Box flexDirection="column">
180      {headerRow($, e, view)}
181      {await tail($, e, view, original)}
182    </Box>
183  ) : (
184    headerRow($, e, view)
185  )
186}
187
188// 記下 subagent 的回報是誰送來的(類型與任務說明查 $.agent.list()),以及完成通知的狀態和時間。
189// 通知通常比回報晚幾秒到;先到也照記,回報到了再補上說明。
190const noteAgents = async ($: EngineInterface, uuid: string, content: readonly { type: string; [field: string]: unknown }[]) => {
191  const from = senderOf(content)
192  if (from !== null) {
193    const info = (await $.agent.list()).find(one => one.id === from)
194    await update($, memberOf(agent, { requestId: from }), a => ({
195      status: null,
196      durationMs: null,
197      ...a,
198      type: info?.type ?? a?.type ?? '',
199      description: info?.description ?? a?.description ?? '',
200    }))
201    await update($, memberOf(sender, { requestId: uuid }), () => from)
202  }
203  const notice = noticeOf(content)
204  if (notice !== null) {
205    await update($, memberOf(agent, { requestId: notice.id }), a => ({
206      type: '',
207      description: '',
208      ...a,
209      status: notice.status,
210      durationMs: notice.durationMs ?? a?.durationMs ?? null,
211    }))
212  }
213}
214
215// subagent 回報卡片:「◆ Explore · Review Standards axis · 完成 2 分 17 秒  › 回報」。點一下這一列,引擎把它
216// 標成展開(和 ctrl+o 的完整紀錄一樣),回報全文縮排兩格畫在底下;再點一下收起。失敗或被停止時「◆」與狀態
217// 是紅色。不知道是誰送來的(mod 載入前的回報)回 null。
218const card = async ($: EngineInterface, e: Drawn, report: string, name: string | undefined, isOpen: boolean) => {
219  const from = await read($, memberOf(sender, e))
220  if (from === null) {
221    return null
222  }
223  const info = await read($, memberOf(agent, { requestId: from }))
224  const { Box, Markdown, Text } = $.ui.resolve(e)
225  const failed = info?.status === 'failed' || info?.status === 'killed'
226  const title = [info?.type ?? '', info?.description ?? ''].filter(part => part !== '').join(' · ') || (name ?? 'subagent')
227  const status =
228    info === null || info.status === null
229      ? null
230      : [STATUS[info.status] ?? info.status, info.durationMs === null ? '' : duration(info.durationMs)].join(' ').trim()
231  return (
232    <Box flexDirection="column" marginTop={1}>
233      <Box>
234        <Box width={2} flexShrink={0}>
235          <Text color={failed ? 'error' : 'suggestion'}>◆</Text>
236        </Box>
237        <Text>{title}</Text>
238        {status !== null && (
239          <Text color={failed ? 'error' : undefined} dimColor={!failed}>
240            {` · ${status}`}
241          </Text>
242        )}
243        <Box marginLeft={2} flexShrink={0}>
244          <Text dimColor>{`${isOpen ? '⌄' : '›'} 回報`}</Text>
245        </Box>
246      </Box>
247      {isOpen && (
248        <Box paddingLeft={2} flexDirection="column">
249          <Markdown text={report} />
250        </Box>
251      )}
252    </Box>
253  )
254}
255
256export const register: Register = on => {
257  // 重新載入 mod(/reload-plugins)時輪次表還在,照它把各列畫法補回來;還在進行的那一輪接著計時。
258  // 新 session 的表是空的。
259  on('session.start', async ($, e, next) => {
260    const [open, now, all] = await Promise.all([read($, expanded), $.clock.now(), read($, turns)])
261    for (const turn of all) {
262      await paint($, {}, views(turn, open, now))
263    }
264    if (all.at(-1)?.durationMs === null) {
265      await startTicking($)
266    }
267    return next(e)
268  })
269
270  // 輪次表只記主對話;subagent 的輪次與列都帶 agentId。
271  on('turn.start', async ($, e, next) => {
272    const startedAt = await $.clock.now()
273    await change($, all => startTurn(all, e.turnId, startedAt))
274    await startTicking($)
275    return next(e)
276  })
277
278  // 回答完、被中斷(Esc)或出錯都會結束這一輪。
279  on('turn.complete', async ($, e, next) => {
280    if (e.agentId === undefined) {
281      stopTicking()
282      await change($, all => endTurn(all, e.turnId, e.durationMs))
283    }
284    return next(e)
285  })
286
287  // 這個 hook 擋在寫入對話紀錄的路上:先記帳再交給下一層;記帳出錯也照樣寫入。
288  // 一輪進行中送進來的訊息走 delivery。
289  on('session.append', async ($, e, next) => {
290    if (e.agentId === undefined) {
291      const at = await $.clock.now()
292      await change($, all => (e.door === 'delivery' ? addNote(all, e.uuid, at) : addRow(all, e.uuid, e.message.type, e.message.content, at)))
293      await noteAgents($, e.uuid, e.message.content)
294    }
295    return next(e)
296  }).catch(($, e, next) => next(e))
297
298  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
299    if (e.surface !== 'terminal') return next(e)
300    // 先用列 id 找;有些 session 的列 id 對不上 uuid,再用內文指紋找。
301    const key = textKey(e.props.text)
302    const found = (await read($, memberOf(view, e))) ?? (key === null ? null : await read($, memberOf(view, { requestId: key })))
303    // 模型文字的摘要列(isSummary,掛在思考的 uuid 上)也是寫給你的話:照原樣留著;它是一段過程的標頭時,
304    // 標頭畫在它上面。
305    const mine: View | null =
306      e.props.isSummary === true && found !== null && found.kind !== 'answer'
307        ? { kind: 'answer', header: found.kind === 'header' ? found : undefined }
308        : found
309    track($, e, 'AssistantMessage', mine, e.props.onScreen)
310    const folded = await fold($, e, mine, () => next(e))
311    if (folded !== null) return folded
312    if (mine === null) return next(e)
313    // 回答:開頭換成橘色粗體的「✻」,內容接在後面,換行縮排兩格對齊。
314    const { Box, Markdown, Text } = $.ui.resolve(e)
315    const answer = (
316      <Box marginTop={1}>
317        <Box width={2} flexShrink={0}>
318          <Text color="claude" bold>
319            ✻
320          </Text>
321        </Box>
322        <Box flexDirection="column" flexGrow={1}>
323          <Markdown text={e.props.text} />
324        </Box>
325      </Box>
326    )
327    return mine.kind === 'answer' && mine.header !== undefined ? (
328      <Box flexDirection="column">
329        {headerRow($, e, mine.header)}
330        {answer}
331      </Box>
332    ) : (
333      answer
334    )
335  })
336
337  // 一組工具呼叫看它第一個呼叫的畫法:同一組的呼叫都在同一輪裡,標頭所在的第一個工具列就是第一組的第一個呼叫。
338  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
339    if (e.surface !== 'terminal') return next(e)
340    const first = e.props.calls.find(c => c.tool_use_id !== undefined)?.tool_use_id
341    if (first === undefined) return next(e)
342    const mine = await read($, memberOf(view, { requestId: first }))
343    track($, e, 'ToolGroup', mine, e.props.onScreen)
344    const folded = await fold($, e, mine, () => next(e))
345    return folded ?? next(e)
346  })
347
348  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
349    if (e.surface !== 'terminal') return next(e)
350    const mine = await read($, memberOf(view, { requestId: e.props.tool_use_id }))
351    track($, e, 'ToolUse', mine, e.props.onScreen)
352    const folded = await fold($, e, mine, () => next(e))
353    return folded ?? next(e)
354  })
355
356  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
357    if (e.surface !== 'terminal') return next(e)
358    const mine = await read($, memberOf(view, { requestId: e.props.tool_use_id }))
359    track($, e, 'ToolResult', mine, e.props.onScreen)
360    return tail($, e, mine, () => next(e))
361  })
362
363  on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
364    if (e.surface !== 'terminal') return next(e)
365    return tail($, e, await read($, memberOf(view, { requestId: e.props.tool_use_id })), () => next(e))
366  })
367
368  // subagent 的回報(Message from @…)畫成卡片,背景工作完成的通知(Agent "…" finished)藏起來:一輪進行中
369  // 送進來的收進那一輪;回答之後才來的完成通知,跟著開這個工作的工具呼叫收起來。回答之後才來的回報會開新
370  // 的一輪,和你的訊息一樣留在對話裡。點開的列與 ctrl+o 的完整紀錄(isExpanded)不收合。
371  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
372    const { origin, task, isExpanded } = e.props
373    if (e.surface !== 'terminal' || (origin.kind !== 'peer' && origin.kind !== 'task-notification')) {
374      return next(e)
375    }
376    const mine = await read($, memberOf(view, e))
377    const opener =
378      mine === null && task?.toolUseId !== undefined ? await read($, memberOf(view, { requestId: task.toolUseId })) : null
379    const found = mine ?? opener
380    const shown: View | null = isExpanded && found !== null && found.kind !== 'answer' ? { kind: 'work', group: found.group, isOpen: true } : found
381    track($, e, 'UserMessage', shown, e.props.onScreen)
382    const drawn = async () =>
383      origin.kind === 'peer' ? ((await card($, e, e.props.text, e.props.from?.name, isExpanded)) ?? next(e)) : next(e)
384    return tail($, e, shown, drawn)
385  })
386
387  // 浮動列畫在輸入框上方這一區的最上面,其他 mod 畫的(例如 speclink 的技能列)接在下面。/clear 之後新 session
388  // 沒有展開中的段,所以舊的記錄不會畫出來。
389  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
390    const below = await next(e)
391    const at = stuck
392    if (e.surface !== 'terminal' || e.props.hasSurvey || at === null || !(await read($, expanded)).includes(at.group)) {
393      return below
394    }
395    const { Box, Button, Text } = $.ui.resolve(e)
396    return (
397      <Box flexDirection="column">
398        <Box>
399          <Text dimColor>{`⌄ ${fit(at.label, e.props.bodyColumns - 12)} · `}</Text>
400          <Button key="unstick" label="⌃ 收起" plain dimColor onPress={() => unstick($, at)} />
401        </Box>
402        {below}
403      </Box>
404    )
405  })
406
407  // 每輪結尾的「Worked for …」:時間已經寫在「處理了 …」裡。
408  on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
409    if (e.surface !== 'terminal') return next(e)
410    const { Box } = $.ui.resolve(e)
411    return <Box />
412  })
413}
414
hooks/agents.ts 25 lines
1// subagent 的回報與背景工作的完成通知:從主對話記下的原始內容取出是誰、怎麼結束、花了多久。
2
3type Block = { readonly type: string; readonly [field: string]: unknown }
4
5const textOf = (content: readonly Block[]) =>
6  content.map(b => (b.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('\n')
7
8// 回報開頭的 `<agent-message from="a8e5…">`:送來的 subagent 的 id;不是回報回 null。
9export const senderOf = (content: readonly Block[]) => textOf(content).match(/<agent-message from="([^"]+)"/)?.[1] ?? null
10
11// `<task-notification>` 裡的工作 id(subagent 的就是它的 id)、狀態與花費時間;背景指令的通知沒有時間。
12export const noticeOf = (content: readonly Block[]) => {
13  const text = textOf(content)
14  if (!text.includes('<task-notification>')) {
15    return null
16  }
17  const tag = (name: string) => text.match(new RegExp(`<${name}>([^<]*)</${name}>`))?.[1]
18  const id = tag('task-id')
19  const ms = tag('duration_ms')
20  return id === undefined ? null : { id, status: tag('status') ?? null, durationMs: ms === undefined ? null : Number(ms) }
21}
22
23// 狀態在卡片上的寫法;其他字照原樣。
24export const STATUS: Record<string, string> = { completed: '完成', failed: '失敗', killed: '已停止' }
25
hooks/turns.ts 254 lines
1import type { Header, Row, Turn, View } from '../types'
2
3// 只留最近的輪次;更早的列照原樣畫。
4const KEEP = 200
5
6type Block = { readonly type: string; readonly [field: string]: unknown }
7
8export const startTurn = (turns: Turn[], id: string, startedAt: number): Turn[] =>
9  [...turns, { id, rows: [], durationMs: null, startedAt }].slice(-KEEP)
10
11export const endTurn = (turns: Turn[], id: string, durationMs: number): Turn[] =>
12  turns.map(t => (t.id === id ? { ...t, durationMs } : t))
13
14// 把主對話新記下的一列併進進行中的那一輪:模型的思考與文字、工具呼叫,與工具結果(出錯的記在那次呼叫上,
15// 跑完的從「正在跑」拿掉)。at 是記下的時間,用來算每段過程花多久。沒有進行中的輪次(歷史列、mod 載入前)
16// 就不動,回傳原本的表。
17export const addRow = (turns: Turn[], uuid: string, type: string, content: readonly Block[], at: number): Turn[] => {
18  const current = turns.at(-1)
19  if (current === undefined || current.durationMs !== null) {
20    return turns
21  }
22  let rows = current.rows
23  const added: Row[] = []
24  let running = current.running ?? []
25  let recent = current.recent
26  if (type === 'assistant') {
27    // 有內容的思考會畫成一段帶圓點的文字;空的(不給看的)不會畫,不記。
28    const thinking = content.filter(b => b.type === 'thinking' && typeof b.thinking === 'string').map(b => b.thinking as string).join('')
29    const thought = textKey(thinking)
30    if (thought !== null) {
31      added.push({ kind: 'thought', id: uuid, key: thought, at })
32    }
33    const texts = content.filter(b => b.type === 'text' && typeof b.text === 'string').map(b => b.text as string)
34    if (texts.length > 0) {
35      added.push({ kind: 'text', id: uuid, key: textKey(texts.join('')), at })
36    }
37    // 文字留在對話裡,結束了這一段過程;下一段的動作從頭記。
38    recent = texts.length > 0 ? undefined : (firstSentence(thinking) ?? recent)
39    const calls = content.filter(b => b.type === 'tool_use' && typeof b.id === 'string')
40    if (calls.length > 0) {
41      const ids = calls.map(b => b.id as string)
42      added.push(
43        calls.every(b => b.name === 'Agent')
44          ? { kind: 'tools', ids, agents: true, at }
45          : calls.every(b => ASKS.includes(String(b.name)))
46            ? { kind: 'tools', ids, ask: true, at }
47            : { kind: 'tools', ids, at },
48      )
49      const steps = calls.map(b => ({ id: b.id as string, label: describe(String(b.name), (b.input ?? {}) as Record<string, unknown>) }))
50      running = [...running, ...steps]
51      recent = steps[steps.length - 1].label
52    }
53  } else if (type === 'user') {
54    const results = content.filter(b => b.type === 'tool_result')
55    const failed = results.filter(b => b.is_error === true).map(b => b.tool_use_id)
56    if (failed.length > 0) {
57      rows = rows.map(r =>
58        r.kind === 'tools' && r.ids.some(id => failed.includes(id))
59          ? { ...r, errors: (r.errors ?? 0) + r.ids.filter(id => failed.includes(id)).length }
60          : r,
61      )
62    }
63    running = running.filter(step => !results.some(b => b.tool_use_id === step.id))
64  }
65  if (added.length === 0 && rows === current.rows && running === (current.running ?? []) && recent === current.recent) {
66    return turns
67  }
68  return [...turns.slice(0, -1), { ...current, rows: [...rows, ...added], running, recent }]
69}
70
71// 問你問題的工具:它是對話,不是過程。
72const ASKS = ['AskUserQuestion', 'ExitPlanMode']
73
74// 工具在標頭上的說明:動詞,與要顯示的那個參數(路徑只留檔名)。
75const VERBS: Record<string, [verb: string, field: string]> = {
76  Read: ['讀取 ', 'file_path'],
77  Edit: ['編輯 ', 'file_path'],
78  MultiEdit: ['編輯 ', 'file_path'],
79  Write: ['寫入 ', 'file_path'],
80  NotebookEdit: ['編輯 ', 'notebook_path'],
81  Grep: ['搜尋 ', 'pattern'],
82  Glob: ['找檔案 ', 'pattern'],
83  Agent: ['subagent:', 'description'],
84  WebFetch: ['讀取網頁 ', 'url'],
85  WebSearch: ['搜尋網路:', 'query'],
86  Skill: ['技能 ', 'skill'],
87}
88
89// 「執行:Run the tests」「讀取 register.tsx」;MCP 工具是「伺服器:工具」,其他工具用名稱。
90export const describe = (name: string, input: Record<string, unknown>) => {
91  if (ASKS.includes(name)) {
92    return '等你回答'
93  }
94  const field = (key: string) => (typeof input[key] === 'string' && input[key] !== '' ? (input[key] as string) : undefined)
95  if (name === 'Bash') {
96    const what = field('description') ?? field('command')?.split('\n')[0]
97    return what === undefined ? '執行指令' : `執行:${what}`
98  }
99  const verb = VERBS[name]
100  const value = verb === undefined ? undefined : field(verb[1])
101  if (verb !== undefined && value !== undefined) {
102    return verb[0] + (verb[1].endsWith('_path') ? value.split('/').pop() : value)
103  }
104  const mcp = name.match(/^mcp__(.+?)__(.+)$/)
105  return mcp === null ? name : `${mcp[1]}:${mcp[2]}`
106}
107
108// 一段文字的第一句:第一個非空行,拿掉 Markdown 的強調、程式碼與標題記號,切在第一個句號。
109export const firstSentence = (text: string) => {
110  const line = text
111    .split('\n')
112    .map(l => l.replace(/[*`]/g, '').replace(/^\s*(#+|>|[-+]|\d+\.)\s+/, '').trim())
113    .find(l => l !== '')
114  return line?.split(/(?<=[。!?])|(?<=[.!?])\s/)[0].trim()
115}
116
117// 「處理中」後面接的動作:正在跑的工具(同時好幾個時寫「等 n 項」)。沒有工具在跑時是最近一個動作:
118// 模型常常不寫說明、思考內容也是空的,工具跑完就保留它的說明,直到下一個工具或新的說明出現。
119const doing = (turn: Turn) => {
120  const running = turn.running ?? []
121  if (running.length === 0) return turn.recent
122  return running.length === 1 ? running[0].label : `${running[0].label} 等 ${running.length} 項`
123}
124
125// 一輪進行中送進來的訊息(subagent 的回報、背景工作完成的通知)也是過程。沒有進行中的輪次就不動。
126export const addNote = (turns: Turn[], uuid: string, at: number): Turn[] => {
127  const current = turns.at(-1)
128  if (current === undefined || current.durationMs !== null) {
129    return turns
130  }
131  return [...turns.slice(0, -1), { ...current, rows: [...current.rows, { kind: 'note', id: uuid, at }] }]
132}
133
134// 文字的指紋:去掉空白後的 FNV-1a 雜湊。畫面給的列 id 對不上 uuid 時,用內文找回同一列。
135export const textKey = (text: string) => {
136  let hash = 0x811c9dc5
137  const bare = text.replace(/\s+/g, '')
138  for (const ch of bare) {
139    hash = Math.imul(hash ^ ch.codePointAt(0)!, 0x01000193) >>> 0
140  }
141  return bare === '' ? null : `text:${hash.toString(16)}`
142}
143
144// 一段過程:連在一起的工具呼叫、思考與送進來的訊息。模型寫的文字與問你問題的工具不算,它們把過程切成好幾段。
145const isWork = (row: Row) => row.kind === 'note' || row.kind === 'thought' || (row.kind === 'tools' && row.ask !== true)
146
147// 一段過程以它第一列的 id 記:展開哪幾段、標頭按鈕的 key 都用它。
148export const groupOf = (row: Row) => (row.kind === 'tools' ? row.ids[0]! : row.id)
149
150const idsOf = (row: Row) =>
151  row.kind === 'tools' ? row.ids : row.kind === 'note' ? [row.id] : [row.id, row.key ?? null].filter(id => id !== null)
152
153// 一段過程的標頭:做完的「處理了 1 分 23 秒 · 1 個錯誤」,還在進行的「處理中 12 秒 · 執行:…」。時間從前一段文字
154// (第一段從這一輪開始)算到後一段文字(最後一段算到這一輪結束或現在)。舊版記下的列沒有時間,只有一段時用
155// 這一輪的時間,否則不寫時間。
156const labelOf = (turn: Turn, start: number, end: number, count: number, now?: number) => {
157  const { rows } = turn
158  const isLast = end === rows.length - 1
159  const from = start === 0 ? turn.startedAt : rows[start - 1]!.at
160  const until = !isLast
161    ? rows[end + 1]!.at
162    : turn.durationMs === null
163      ? now
164      : turn.startedAt === undefined
165        ? undefined
166        : turn.startedAt + turn.durationMs
167  const ms = from !== undefined && until !== undefined ? until - from : count === 1 && turn.durationMs !== null ? turn.durationMs : undefined
168  const time = ms === undefined ? '' : ` ${duration(ms)}`
169  if (turn.durationMs === null && isLast) {
170    const action = doing(turn)
171    return action === undefined ? `處理中${time === '' ? '…' : time}` : `處理中${time} · ${action}`
172  }
173  const errors = rows.slice(start, end + 1).reduce((n, r) => n + (r.kind === 'tools' ? (r.errors ?? 0) : 0), 0)
174  return `處理了${time}${errors > 0 ? ` · ${errors} 個錯誤` : ''}`
175}
176
177// 一輪裡每一列該怎麼畫,以列 id(文字與思考列的 uuid 與指紋、工具呼叫的 tool_use id、訊息列的 uuid)查。
178// 模型寫的文字都留在對話裡;夾在文字之間的每一段過程收成一行「› 處理了 …」,各段各自展開。收起來時標頭畫在這段
179// 的第一個工具列(沒有工具就是第一段思考):工具列一定畫得出來。展開時標頭移到這段的第一列,展開的內容才會都在
180// 標頭下面。一段裡沒有列畫得出標頭時(例如只有一起開的 subagent),標頭畫在緊接著的文字上面;後面也沒有文字就
181// 照原樣,免得過程藏起來卻點不開。expanded 是展開中的段,now 是現在的時間。
182export const views = (turn: Turn, expanded: readonly string[], now?: number): Record<string, View> => {
183  const { rows } = turn
184  const spans: { start: number; end: number }[] = []
185  rows.forEach((row, index) => {
186    if (isWork(row)) {
187      const last = spans.at(-1)
188      if (last !== undefined && last.end === index - 1) {
189        last.end = index
190      } else {
191        spans.push({ start: index, end: index })
192      }
193    }
194  })
195  // 同一則回覆開好幾個 subagent 時,引擎把這些呼叫畫成自己的一列「N background agents launched」,mod 畫不到;
196  // 訊息列也不一定收合(只收 subagent 與背景工作的)。這兩種列不放標頭。
197  const isAgents = (row: Row | undefined) => row?.kind === 'tools' && row.agents === true
198  const isGrouped = (index: number) => {
199    const row = rows[index]!
200    return row.kind === 'tools' && row.agents === true && (row.ids.length > 1 || isAgents(rows[index - 1]) || isAgents(rows[index + 1]))
201  }
202  const shown: View[] = rows.map(() => ({ kind: 'answer' }))
203  for (const { start, end } of spans) {
204    const group = groupOf(rows[start]!)
205    const isOpen = expanded.includes(group)
206    const header: Header = { group, label: labelOf(turn, start, end, spans.length, now), isOpen }
207    const members = rows.slice(start, end + 1).map((_, k) => start + k)
208    const hosts = members.filter(i => rows[i]!.kind !== 'note' && !isGrouped(i))
209    const host = isOpen ? hosts[0] : (hosts.find(i => rows[i]!.kind === 'tools') ?? hosts[0])
210    const after = rows[end + 1]
211    if (host === undefined && after?.kind !== 'text') {
212      continue
213    }
214    for (const i of members) {
215      shown[i] = i === host ? { kind: 'header', ...header } : { kind: 'work', group, isOpen }
216    }
217    if (host === undefined) {
218      shown[end + 1] = { kind: 'answer', header }
219    }
220  }
221  // 依列的順序寫:同一則回覆的思考與文字共用 uuid,後面的文字蓋過前面的思考。
222  const out: Record<string, View> = {}
223  rows.forEach((row, index) => {
224    for (const id of idsOf(row)) {
225      out[id] = shown[index]!
226    }
227  })
228  return out
229}
230
231// 截成最多 cells 格(中日韓文字算兩格),截掉的地方補「…」。
232export const fit = (text: string, cells: number) => {
233  let used = 0
234  let out = ''
235  for (const ch of text) {
236    const width = ch.codePointAt(0)! >= 0x2e80 ? 2 : 1
237    if (used + width > cells - 1) {
238      return `${out}…`
239    }
240    used += width
241    out += ch
242  }
243  return out
244}
245
246// 83000 → 1 分 23 秒
247export const duration = (ms: number) => {
248  const s = Math.max(1, Math.round(ms / 1000))
249  if (s < 60) return `${s} 秒`
250  const m = Math.floor(s / 60)
251  if (m < 60) return `${m} 分 ${s % 60} 秒`
252  return `${Math.floor(m / 60)} 小時 ${m % 60} 分`
253}
254
types/index.d.ts 54 lines
1// 一輪裡依序出現的列:模型寫的一段文字、畫在畫面上的思考內容(列 id 與內文指紋)、一次工具呼叫(tool_use id),
2// 或一輪進行中送進來的訊息(subagent 的回報、背景工作完成的通知;列 id)。
3// key 是 null:記下時還沒有內文。舊版記下的列沒有 key。agents:這次呼叫的工具全是 Agent;ask:全是問你的
4// 工具(AskUserQuestion、ExitPlanMode)。errors:出錯的呼叫數。at:記下的時間(epoch 毫秒),舊版記下的列沒有。
5export type Row =
6  | { kind: 'text'; id: string; key?: string | null; at?: number }
7  | { kind: 'thought'; id: string; key: string; at?: number }
8  | { kind: 'tools'; ids: string[]; agents?: true; ask?: true; errors?: number; at?: number }
9  | { kind: 'note'; id: string; at?: number }
10
11// 一個正在跑的工具呼叫,與它在標頭上的說明(「讀取 register.tsx」)。
12export type Step = { id: string; label: string }
13
14export type Turn = {
15  // turn.start 給的 id。
16  id: string
17  rows: Row[]
18  // 這一輪花的時間;還在進行是 null。
19  durationMs: number | null
20  // 正在跑的工具呼叫,與這一段過程最近一個動作(最近開始的工具,或最近一段思考的第一句,看哪個晚);
21  // 「處理中」標頭顯示它們。舊版記下的輪次沒有。
22  running?: Step[]
23  recent?: string
24  // 開始的時間(epoch 毫秒);第一段過程的時間從這裡算。舊版記下的輪次沒有。
25  startedAt?: number
26}
27
28// 一段過程的「處理了 …」標頭:哪一段(第一列的 id)、標籤、是否展開。
29export type Header = { group: string; label: string; isOpen: boolean }
30
31// 一列的畫法:一段過程的「處理了 …」標頭、收起來的過程列(屬於哪一段),或照原樣畫的列(模型寫的文字、
32// 問你的問題)。一段裡沒有列畫得出標頭時,標頭畫在緊接著的文字上面(header)。
33export type View =
34  | ({ kind: 'header' } & Header)
35  | { kind: 'work'; group: string; isOpen: boolean }
36  | { kind: 'answer'; header?: Header }
37
38// subagent 的類型、任務說明,與完成通知帶來的狀態和花費時間(還沒收到是 null)。
39export type Agent = { type: string; description: string; status: string | null; durationMs: number | null }
40
41declare module 'claude-code' {
42  interface PluginState {
43    // view 每列一份,各列只讀自己那份:新增一列時只有變了的列重畫。null 是不在輪次表裡的列。
44    // sender(送來回報的 subagent id)以回報列的 id、agent 以 subagent 的 id 各一份。
45    tidy: {
46      turns: Turn[]
47      expanded: string[]
48      view: StateFamily<View | null>
49      sender: StateFamily<string | null>
50      agent: StateFamily<Agent | null>
51    }
52  }
53}
54