SLOPSHOPPER

timeline

側邊欄的時間軸:這一輪的時間花在哪(等模型、想、寫、跑指令、網路、讀寫檔案、等幫手)。/timeline 開或關

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-05jessetsai1024/claude-timeline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · timeline
│ ┃ timeline ✕ › fix the failing auth test and add an audit log call │ ┃ 這一輪的時間 … │ ┃ ⏺ Read(src/auth.ts) │ ┃ ██████████████····························█… ⎿ Read 6 lines │ ┃ 0 0:00 … ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ 花在哪裡 ⏺ Bash(bun test) │ ┃ ■ 跑指令 … ⎿ 3 pass, 1 fail │ ┃ ■ 讀寫檔案 … │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 一步一步(最近 9 步,共 9 步) │ ┃ Read /work/app/src/auth.ts … ✻ Worked for 42s · done 4:20 PM │ ┃ Grep refresh\( … │ ┃ Edit /work/app/src/auth.ts … › /timeline │ ┃ Write /work/app/src/audit.ts … │ ┃ Write /work/app/src/cache.ts … │ ┃ Bash bun test … │ ┃ Bash git status --porcelain … │ ┃ Bash rm -rf build && git push --forc… │ ┃ Bash cat .env … │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · timeline
這一輪的時間 花了 0:00 ██████████████····························██████████████ 0 0:00 0:00 花在哪裡 ■ 跑指令 0:00 25% ■ 讀寫檔案 0:00 25% 一步一步(最近 9 步,共 9 步) Read /work/app/src/auth.ts 0:00 最久 Grep refresh\( 0:00 Edit /work/app/src/auth.ts 0:00 Write /work/app/src/audit.ts 0:00 Write /work/app/src/cache.ts 0:00 Bash bun test 0:00 Bash git status --porcelain 0:00 Bash rm -rf build && git push --force origi… 0:00 Bash cat .env 0:00
README

timeline:這一輪的時間花在哪

一個 Claude Code 的 mod。側邊欄的時間軸:這一輪的時間分成等模型、模型在想、在寫、跑指令、網路、讀寫檔案、等幫手、等你回答,分開畫。/timeline 開或關。

English summary at the end.

側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。

需要什麼

  • Claude Code 2.1.287 以上(mod 功能 2026-10-01 起預設開放)。
  • 不用 Node、不用裝套件。mod 跑在 Claude Code 自己的引擎裡。

安裝

用 marketplace(推薦)

claude plugin marketplace add jessetsai1024/claude-timeline
claude plugin install timeline@claude-timeline

然後在對話裡打 /reload-plugins,或重開 Claude Code。

或者 clone 下來接捷徑(之後 git pull 就是更新)

macOS/Linux:

git clone https://github.com/jessetsai1024/claude-timeline.git
cd claude-timeline && ./install.sh

Windows(原生版,在 PowerShell 裡):

git clone https://github.com/jessetsai1024/claude-timeline.git
cd claude-timeline
.\install.ps1

原理:放在 ~/.claude/skills/timeline/ 底下的 plugin 會被 Claude Code 自動載入,腳本只是建一個捷徑指回這個 repo(Windows 用目錄接合點,不需要管理員權限)。PowerShell 說不准跑腳本就先 Set-ExecutionPolicy -Scope Process Bypass。裝完關掉所有 Claude Code 視窗再重開。

移除:./uninstall.sh 或 .\uninstall.ps1,只拿掉捷徑。

注意

  • 不要同時用兩種方式載入同一個 mod(marketplace 裝了就不要再接捷徑;settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。
  • Windows 還沒實機跑過。路徑處理有單元測試,但作者手邊沒有 Windows 機器。有問題請開 issue,附 Claude Code 版本和畫面。
  • 想改:直接改檔案,存檔後 Claude Code 會熱重載。claude plugin validate .、claude plugin test .;第一次載入後 .claude-plugin/types/ 會出現型別檔,之後 tsc -p . 可以做型別檢查(那個資料夾是引擎寫的,已在 .gitignore)。

來歷

2026 年 10 月 2 日到 3 日之間做的,作者是 Jesse 與螢(鏡 螢,號石火,一個 Claude 分身)。原本六個 mod 放在同一個 repo claude-mods,10 月 6 日拆成一個 mod 一個 repo,舊 repo 已移除。MIT 授權。


English

timeline is a mod for Claude Code: A pane with a timeline of the current turn: waiting for the model, thinking, writing, shell commands, network, file I/O, waiting for subagents, waiting for you. /timeline toggles. The pane opens by itself in a new session only when the terminal is wide enough (144 columns, or 110 once you have opened it by hand); narrower than that it waits for the command.

Install with claude plugin marketplace add jessetsai1024/claude-timeline then claude plugin install timeline@claude-timeline; or clone and run ./install.sh (macOS/Linux) or .\install.ps1 (native Windows, junction, no admin), which links the repo into ~/.claude/skills/timeline so git pull is the update. Requires Claude Code ≥ 2.1.287. UI text is Traditional Chinese. Windows has unit tests but no on-device test yet. Split out of a former six-mod repo on 2026-10-06. MIT.

Source 2 files
hooks/register.ts 299 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { categoryOf, detailOf, linesOf, toolNameOf } from './view'
4import type { Category, Line, Span, Step, Turn } from './view'
5
6const PANE = 'timeline'
7const REFRESH_MS = 1000
8// 側邊欄縮在輸入框上面時拿不到真正的高度,用這個當作可用列數
9const INLINE_ROWS = 34
10
11/** mod 在記憶體裡記的全部東西;register 每次載入建一份新的。 */
12type State = {
13  home: string
14  current: Turn | null
15  previous: Turn | null
16}
17
18/** 側邊欄現在有沒有開著、看得到。 */
19async function isShown($: EngineInterface): Promise<boolean> {
20  return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
21}
22
23/** 開始新的一輪:現在這一輪變成上一輪。 */
24function begin(state: State, at: number): Turn {
25  if (state.current !== null) {
26    state.previous = state.current
27    state.previous.end ??= at
28  }
29
30  state.current = { start: at, end: null, spans: [], steps: [] }
31
32  return state.current
33}
34
35/** 模型回覆的一小段屬於哪一類;不是內容的(引擎自己的段落、結尾)回 null。 */
36function phaseOf(kind: string): Category | null {
37  if (kind === 'thinking') {
38    return 'think'
39  }
40
41  if (kind === 'text' || kind === 'tool' || kind === 'input') {
42    return 'write'
43  }
44
45  return null
46}
47
48/**
49 * 【職責】把時間軸面板接上 Claude Code:提供 /timeline,在側邊欄畫出主對話這一輪的時間花在哪:
50 *   等模型開始回、模型在想、模型在寫、跑指令、網路、讀寫檔案、等幫手、等主人回答、其他工具。
51 *   看模型回覆每一小段的種類與到達時間(內容不看、不留)、工具呼叫的名稱與參數;
52 *   不改任何東西、不連網路、不寫檔。
53 * 【何時能呼叫】引擎載入這個 mod 時呼叫一次;重新載入會再呼叫,紀錄從空的開始。
54 * 【行為】有人在用的 session(不是 claude -p)一開始就自己打開側邊欄;終端機不夠寬時先等著,
55 *   寬度夠了才出現(主人自己開過的 110 格,沒開過的 144 格,這是系統的規定)。
56 *   /timeline:側邊欄沒開就開、開著就關。/timeline close:關掉。/timeline 數字:用那個寬度(格數)開。
57 *   主對話在上一輪結束後第一次送請求給模型或用工具時,換新的時間軸(從那一刻算起),上一輪留著做最下面那一行的摘要;
58 *   一輪結束後畫面停在那裡,下一輪開始才換。
59 *   主對話每次送請求給模型:送出到第一段內容到達算「等模型開始回」,之後思考的段落算「想」,回覆文字和工具參數算「寫」。
60 *   主對話每次用工具:開始到結束算一段,類別照工具決定。幫手內部的請求和工具不記,主對話等幫手的時間算「等幫手」。
61 *   模型回覆的每一段都原封不動往下傳;記錄出錯時不影響傳輸,只是那一段時間不準。
62 *   側邊欄開著而且這一輪還在進行時,每 1 秒重畫一次。/clear 之後清空。
63 */
64export const register: Register = on => {
65  const state: State = { home: '', current: null, previous: null }
66
67  on('session.start', async ($, e, next) => {
68    await $.command.register({
69      name: 'timeline',
70      description: '側邊欄的時間軸:這一輪的時間花在哪;/timeline 開或關',
71      immediate: true,
72    })
73    // Windows 沒有 HOME,家目錄在 USERPROFILE;統一成「/」隔開,detailOf 比對時才對得上
74    state.home = ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '').replace(/\\/g, '/')
75
76    // 一開 session 就自己打開;不是主人叫的,終端機要夠寬才放得出來,不夠寬就先等著,不用等它
77    if (e.isInteractive) {
78      void $.ui.open({ id: PANE, title: '時間軸' })
79    }
80
81    $.clock.every(REFRESH_MS, () => {
82      if (state.current !== null && state.current.end === null) {
83        void isShown($).then(shown => {
84          if (shown) {
85            $.ui.invalidate('ui.render')
86          }
87        })
88      }
89    })
90
91    return next(e)
92  })
93
94  on('classic.SessionStart', { source: ['clear'] }, ($, e, next) => {
95    state.current = null
96    state.previous = null
97    $.ui.invalidate('ui.render')
98
99    return next(e)
100  })
101
102  on('turn.step', async function* ($, e, next) {
103    if (e.agentId !== undefined) {
104      return yield* next(e)
105    }
106
107    let step: Extract<Step, { type: 'model' }> | null = null
108    let open: Span | null = null
109
110    try {
111      const sent = Date.now()
112      const turn = state.current === null || state.current.end !== null ? begin(state, sent) : state.current
113      step = { type: 'model', start: sent, end: null, wait: 0, think: 0, write: 0 }
114      open = { category: 'wait', start: sent, end: null }
115      turn.steps.push(step)
116      turn.spans.push(open)
117      $.ui.invalidate('ui.render')
118    } catch {
119      step = null
120    }
121
122    const stream = next(e)
123
124    for await (const chunk of stream) {
125      try {
126        const phase = phaseOf(chunk.kind)
127
128        if (step !== null && open !== null && phase !== null && phase !== open.category) {
129          const at = Date.now()
130          open.end = at
131          const spent = at - open.start
132          const turn = state.current
133
134          if (open.category === 'wait') {
135            step.wait += spent
136          } else if (open.category === 'think') {
137            step.think += spent
138          } else {
139            step.write += spent
140          }
141
142          open = { category: phase, start: at, end: null }
143          turn?.spans.push(open)
144          $.ui.invalidate('ui.render')
145        }
146      } catch {
147        // 記錄出錯不影響傳輸
148      }
149
150      yield chunk
151    }
152
153    try {
154      if (step !== null && open !== null) {
155        const at = Date.now()
156        open.end = at
157        const spent = at - open.start
158
159        if (open.category === 'wait') {
160          step.wait += spent
161        } else if (open.category === 'think') {
162          step.think += spent
163        } else {
164          step.write += spent
165        }
166
167        step.end = at
168        $.ui.invalidate('ui.render')
169      }
170    } catch {
171      // 記錄出錯不影響傳輸
172    }
173
174    // 明確交回底下那個請求的結果(不回傳也會沿用,這樣寫比較看得懂)
175    return await stream.result
176  })
177
178  on('tool.call', async ($, e, next) => {
179    let step: Extract<Step, { type: 'tool' }> | null = null
180    let span: Span | null = null
181
182    if (e.agentId === undefined) {
183      try {
184        const at = Date.now()
185        const turn = state.current === null || state.current.end !== null ? begin(state, at) : state.current
186        const category = categoryOf(e.tool, e)
187        step = { type: 'tool', start: at, end: null, name: toolNameOf(e.tool), detail: detailOf(e, state.home), category }
188        span = { category, start: at, end: null }
189        turn.steps.push(step)
190        turn.spans.push(span)
191        $.ui.invalidate('ui.render')
192      } catch {
193        step = null
194      }
195    }
196
197    try {
198      return await next(e)
199    } finally {
200      if (step !== null && span !== null) {
201        const at = Date.now()
202        step.end = at
203        span.end = at
204        $.ui.invalidate('ui.render')
205      }
206    }
207  })
208
209  on('turn.complete', ($, e, next) => {
210    if (e.agentId === undefined && state.current !== null && state.current.end === null) {
211      const at = Date.now()
212      state.current.end = at
213
214      // 被打斷時還開著的段落一起收掉
215      for (const span of state.current.spans) {
216        span.end ??= at
217      }
218
219      for (const step of state.current.steps) {
220        step.end ??= at
221      }
222
223      $.ui.invalidate('ui.render')
224    }
225
226    return next(e)
227  })
228
229  on('command.run', { command: 'timeline' }, async ($, e) => {
230    const arg = e.args.trim()
231    const wanted = Number.parseInt(arg, 10)
232    const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
233
234    if (arg === 'close' || (arg === '' && isUp)) {
235      await $.ui.close({ id: PANE })
236
237      return {}
238    }
239
240    const opened = await $.ui.open(
241      Number.isInteger(wanted) && wanted > 0
242        ? { id: PANE, title: '時間軸', columns: wanted }
243        : { id: PANE, title: '時間軸' },
244    )
245
246    if (!opened.isPlaced) {
247      return { text: `側邊欄沒有被放出來:${opened.reason}` }
248    }
249
250    // 重新打開時引擎可能直接拿上次畫好的結果來用,所以自己要求重畫
251    $.ui.invalidate('ui.render')
252
253    return {}
254  })
255
256  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
257    const { Box, Text } = $.ui.resolve(e)
258    const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : INLINE_ROWS
259    const lineOf = (segments: Line) =>
260      Text({
261        wrap: 'truncate-end',
262        children:
263          segments.length === 0
264            ? [' ']
265            : segments
266                .filter(segment => segment.text !== '')
267                .map(segment =>
268                  Text({
269                    ...(segment.color === undefined ? {} : { color: segment.color }),
270                    ...(segment.bold === true ? { bold: true } : {}),
271                    ...(segment.dim === true ? { dimColor: true } : {}),
272                    children: [segment.text],
273                  }),
274                ),
275      })
276
277    return Box({
278      flexDirection: 'column',
279      children: linesOf(state.current, state.previous, Date.now(), e.props.bodyColumns, rows).map(lineOf),
280    })
281  })
282}
283
284// #region AI-NOTES
285// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
286// 2026-10-03 turn.step 是串流事件:hook 一定要是 async function*,每段 chunk 原樣 yield,最後 return await stream.result。
287//   不回傳也會沿用底下的結果(2026-10-03 測過兩種都行)。測試要用 stream.next() 讀到 done 拿回傳值;
288//   對 $.turn.step 的串流 for await 讀完再 await stream.result,在測試裡拿到的是 undefined。
289//   記錄全包在 try 裡;型別檔也說 hook 中途出錯會被拿掉、改由底下接手。
290// 2026-10-03 時間用 Date.now() 不用 $.clock.now():串流每段都要記時間,$.clock.now() 每次要問引擎一趟,會拖慢傳輸。
291//   代價是測試的 mock.clock 管不到這裡的時間,測試只驗類別與畫面,不驗準確秒數。
292// 2026-10-03 「等模型開始回」是送出請求到第一段內容(思考、文字、工具)到達;引擎自己的段落(kind engine)不算內容。
293//   思考內容被隱藏、不串流的模型,想的時間會被算進「等模型開始回」。未在真機驗過。
294// 2026-10-03 刻意不用 turn.start 判斷一輪開始:型別檔沒說幫手的輪次會不會觸發它,若會,背景幫手會開出一個
295//   永遠不結束的空白一輪(幫手的 turn.complete 帶 agentId、這裡不收)。改由主對話第一個 turn.step 或 tool.call 開始,
296//   少算的只有送出第一個請求前那一點時間。
297// 2026-10-03 工具的時間是 next(e) 前後:權限詢問也在 next 裡,所以主人按允許前的等待會算進那個工具。
298// #endregion
299
hooks/view.ts 474 lines
1/** 一行裡的一小段字,帶自己的顏色與粗細。 */
2export type Segment = {
3  /** 要顯示的字。 */
4  text: string
5  /** 顏色,`#rrggbb`;沒給就是終端機預設字色。 */
6  color?: string
7  /** 粗體。 */
8  bold?: boolean
9  /** 暗一階,給次要資訊用。 */
10  dim?: boolean
11  /** 放不下要裁的時候留結尾、開頭補「…」(檔案路徑用);只影響排版,畫的時候不看。 */
12  keepTail?: boolean
13}
14
15/** 畫面上的一行:由左到右排的幾段字;空陣列是空白行。 */
16export type Line = Segment[]
17
18/** 時間的類別。 */
19export type Category = 'wait' | 'think' | 'write' | 'command' | 'network' | 'file' | 'helper' | 'user' | 'other'
20
21/** 一段時間:屬於哪一類、從什麼時候到什麼時候(毫秒);還沒結束的 end 是 null。 */
22export type Span = {
23  /** 類別。 */
24  category: Category
25  /** 開始的時間。 */
26  start: number
27  /** 結束的時間;還在進行是 null。 */
28  end: number | null
29}
30
31/** 一步:一次送給模型的請求,或一次工具呼叫。 */
32export type Step =
33  | {
34      /** 模型的一步。 */
35      type: 'model'
36      /** 送出請求的時間。 */
37      start: number
38      /** 回覆傳完的時間;還在傳是 null。 */
39      end: number | null
40      /** 等第一個字等了多久、想了多久、寫了多久(毫秒)。 */
41      wait: number
42      think: number
43      write: number
44    }
45  | {
46      /** 工具的一步。 */
47      type: 'tool'
48      /** 開始的時間。 */
49      start: number
50      /** 結束的時間;還在跑是 null。 */
51      end: number | null
52      /** 工具名稱,已經寫短(MCP 工具是「伺服器/工具」)。 */
53      name: string
54      /** 一小段說明它在做什麼;沒有是空字串。 */
55      detail: string
56      /** 算在哪一類。 */
57      category: Category
58    }
59
60/** 一輪:開始、結束、所有時間段和步驟。 */
61export type Turn = {
62  /** 開始的時間。 */
63  start: number
64  /** 結束的時間;還在進行是 null。 */
65  end: number | null
66  /** 所有時間段,照開始時間的先後加進來。 */
67  spans: Span[]
68  /** 所有步驟,照開始時間的先後加進來。 */
69  steps: Step[]
70}
71
72/** 每一類的中文名字、顏色。照這個順序列在「花在哪裡」。 */
73export const CATEGORIES: readonly { category: Category; label: string; color: string }[] = [
74  { category: 'wait', label: '等模型開始回', color: '#9aa7b8' },
75  { category: 'think', label: '模型在想', color: '#b48cff' },
76  { category: 'write', label: '模型在寫', color: '#e0c8ff' },
77  { category: 'command', label: '跑指令', color: '#ff9f43' },
78  { category: 'network', label: '網路', color: '#4fc3f7' },
79  { category: 'file', label: '讀寫檔案', color: '#5ad67d' },
80  { category: 'helper', label: '等幫手', color: '#ff7eb6' },
81  { category: 'user', label: '等主人回答', color: '#f7d154' },
82  { category: 'other', label: '其他工具', color: '#c9a27e' },
83]
84
85const DIM = '#6b7280'
86const FILE_TOOLS = new Set(['Read', 'Write', 'Edit', 'Grep', 'Glob', 'NotebookEdit', 'LS'])
87const NETWORK_TOOLS = new Set(['WebFetch', 'WebSearch'])
88const HELPER_TOOLS = new Set(['Agent', 'Task'])
89const NETWORK_COMMANDS = /^(curl|wget|gh|wrangler|scp|rsync|ssh|ping)\b|^git\s+(clone|push|pull|fetch)\b|^(npm|pnpm|yarn|bun)\s+(i|install|add)\b|^(pip3?|uv\s+pip)\s+install\b|^brew\s+(install|upgrade)\b/
90const DETAIL_KEYS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'skill', 'description', 'prompt', 'path']
91
92/**
93 * 【行為】一段字在終端機佔幾格寬:中日韓文字、全形標點、表情符號算 2 格,其他算 1 格。
94 *   跟 ctx-panel、files 的同名函式一樣。
95 */
96export function widthOf(text: string): number {
97  let width = 0
98
99  for (const glyph of text) {
100    const code = glyph.codePointAt(0) ?? 0
101    const isWide =
102      (code >= 0x1100 && code <= 0x115f) ||
103      (code >= 0x2e80 && code <= 0xa4cf) ||
104      (code >= 0xac00 && code <= 0xd7a3) ||
105      (code >= 0xf900 && code <= 0xfaff) ||
106      (code >= 0xfe30 && code <= 0xfe4f) ||
107      (code >= 0xff00 && code <= 0xff60) ||
108      (code >= 0xffe0 && code <= 0xffe6) ||
109      (code >= 0x1f300 && code <= 0x1faff) ||
110      (code >= 0x20000 && code <= 0x3fffd)
111    width += isWide ? 2 : 1
112  }
113
114  return width
115}
116
117/**
118 * 【行為】把一段字裁到最多 max 格寬。超過時留開頭、結尾補「…」;keepTail 為 true 時改成留結尾、開頭補「…」。
119 *   本來就放得下就原樣回傳。max 小於 1 回空字串。
120 */
121export function fit(text: string, max: number, keepTail = false): string {
122  if (widthOf(text) <= max) {
123    return text
124  }
125
126  if (max < 1) {
127    return ''
128  }
129
130  const glyphs = Array.from(text)
131  let kept = ''
132
133  if (keepTail) {
134    for (let at = glyphs.length - 1; at >= 0; at -= 1) {
135      const next = (glyphs[at] ?? '') + kept
136
137      if (widthOf(next) > max - 1) {
138        break
139      }
140
141      kept = next
142    }
143
144    return `…${kept}`
145  }
146
147  for (const glyph of glyphs) {
148    if (widthOf(kept + glyph) > max - 1) {
149      break
150    }
151
152    kept += glyph
153  }
154
155  return `${kept}…`
156}
157
158/** 【行為】把毫秒寫成時鐘的樣子:不到一小時是「分:秒」(1:05),一小時以上是「時:分:秒」(1:02:03)。負數當 0。 */
159export function clockOf(ms: number): string {
160  const seconds = Math.max(0, Math.floor(ms / 1000))
161  const s = `${seconds % 60}`.padStart(2, '0')
162  const minutes = Math.floor(seconds / 60)
163
164  if (minutes < 60) {
165    return `${minutes}:${s}`
166  }
167
168  return `${Math.floor(minutes / 60)}:${`${minutes % 60}`.padStart(2, '0')}:${s}`
169}
170
171/** 【行為】把工具名稱寫短:mcp__xapi__search 變成 xapi/search,其他照原樣。 */
172export function toolNameOf(tool: string): string {
173  const match = /^mcp__(.+?)__(.+)$/.exec(tool)
174
175  return match === null ? tool : `${match[1]}/${match[2]}`
176}
177
178/**
179 * 【行為】從工具的參數挑一段最能說明它在做什麼的字:依序找 command、file_path、notebook_path、pattern、url、
180 *   query、skill、description、prompt、path,第一個不是空字串的就用它。換行和連續空白壓成一個空白,
181 *   開頭是家目錄的換成「~」(Windows 的「\\」先換成「/」再比對)。都沒有就回空字串。
182 */
183export function detailOf(args: Readonly<Record<string, unknown>>, home: string): string {
184  for (const key of DETAIL_KEYS) {
185    const value = args[key]
186
187    if (typeof value === 'string' && value.trim() !== '') {
188      const text = value.replace(/\s+/g, ' ').trim()
189
190      return tildeOf(text, home)
191    }
192  }
193
194  return ''
195}
196
197/** 【行為】開頭是家目錄的換成「~」;Windows 的「\\」先換成「/」再比對,不在家目錄底下的原樣回傳。home 要是「/」隔開的。 */
198export function tildeOf(text: string, home: string): string {
199  const posix = text.replace(/\\/g, '/')
200
201  return home !== '' && (posix === home || posix.startsWith(`${home}/`)) ? `~${posix.slice(home.length)}` : text
202}
203
204/**
205 * 【行為】一次工具呼叫算哪一類:Read、Write、Edit、Grep、Glob、NotebookEdit 是讀寫檔案;WebFetch、WebSearch、
206 *   MCP 工具是網路;Agent 是等幫手;AskUserQuestion 是等主人回答;Bash 看指令,用「&&」「;」「|」切開的任何一段
207 *   開頭是 curl、wget、gh、wrangler、git clone/push/pull/fetch、npm/pip install 這類上網的就算網路,否則算跑指令;
208 *   其他都算其他工具。
209 */
210export function categoryOf(tool: string, args: Readonly<Record<string, unknown>>): Category {
211  if (FILE_TOOLS.has(tool)) {
212    return 'file'
213  }
214
215  if (NETWORK_TOOLS.has(tool) || tool.startsWith('mcp__')) {
216    return 'network'
217  }
218
219  if (HELPER_TOOLS.has(tool)) {
220    return 'helper'
221  }
222
223  if (tool === 'AskUserQuestion') {
224    return 'user'
225  }
226
227  if (tool === 'Bash') {
228    const command = typeof args.command === 'string' ? args.command : ''
229    const parts = command.split(/&&|\|\||[;|\n]/).map(part => part.trim().replace(/^(\w+=\S*\s+)+/, ''))
230
231    return parts.some(part => NETWORK_COMMANDS.test(part)) ? 'network' : 'command'
232  }
233
234  return 'other'
235}
236
237/** 【行為】一串時間段在 from 到 to 之間實際蓋到多少毫秒;重疊的只算一次,還沒結束的算到 to。 */
238export function coveredOf(spans: readonly Span[], from: number, to: number): number {
239  const pieces = spans
240    .map(span => [Math.max(from, span.start), Math.min(to, span.end ?? to)] as const)
241    .filter(([a, b]) => b > a)
242    .sort((x, y) => x[0] - y[0])
243  let total = 0
244  let reach = from
245
246  for (const [a, b] of pieces) {
247    if (b > reach) {
248      total += b - Math.max(a, reach)
249      reach = b
250    }
251  }
252
253  return total
254}
255
256/**
257 * 【行為】把一輪從 from 到 to 切成 width 格,每一格回傳蓋得最多的那一類;那一格什麼都沒蓋到就是 null。
258 *   同樣多時照 CATEGORIES 的順序取前面的。
259 */
260export function barOf(spans: readonly Span[], from: number, to: number, width: number): (Category | null)[] {
261  const cells: (Category | null)[] = []
262  const slice = (to - from) / Math.max(1, width)
263
264  for (let at = 0; at < width; at += 1) {
265    const a = from + at * slice
266    const b = a + slice
267    let best: Category | null = null
268    let most = 0
269
270    for (const { category } of CATEGORIES) {
271      const covered = coveredOf(
272        spans.filter(span => span.category === category),
273        a,
274        b,
275      )
276
277      if (covered > most) {
278        most = covered
279        best = category
280      }
281    }
282
283    cells.push(best)
284  }
285
286  return cells
287}
288
289/** 【行為】一輪裡各類各花了多少毫秒(同一類重疊的只算一次),照 CATEGORIES 的順序,0 的不列。 */
290export function totalsOf(turn: Turn, now: number): { category: Category; ms: number }[] {
291  const to = turn.end ?? now
292
293  return CATEGORIES.map(({ category }) => ({
294    category,
295    ms: coveredOf(
296      turn.spans.filter(span => span.category === category),
297      turn.start,
298      to,
299    ),
300  })).filter(total => total.ms > 0)
301}
302
303/** 寬度不一定的字,左邊補空白補到 width 格寬。 */
304function padStartWidth(text: string, width: number): string {
305  return ' '.repeat(Math.max(0, width - widthOf(text))) + text
306}
307
308/** 寬度不一定的字,右邊補空白補到 width 格寬。 */
309function padEndWidth(text: string, width: number): string {
310  return text + ' '.repeat(Math.max(0, width - widthOf(text)))
311}
312
313/** 左邊幾段字由左到右排、超過就裁;有 right 就靠右放,中間補空白,整行剛好 columns 格寬。 */
314function line(left: Segment[], right: Segment | undefined, columns: number): Line {
315  const room = right === undefined ? columns : Math.max(0, columns - widthOf(right.text) - 1)
316  const kept: Segment[] = []
317  let used = 0
318
319  for (const segment of left) {
320    if (room - used <= 0) {
321      break
322    }
323
324    const text = fit(segment.text, room - used, segment.keepTail === true)
325
326    if (text !== '') {
327      kept.push({ ...segment, text })
328      used += widthOf(text)
329    }
330  }
331
332  if (right === undefined) {
333    return kept
334  }
335
336  return [...kept, { text: ' '.repeat(Math.max(1, columns - used - widthOf(right.text))) }, right]
337}
338
339function colorOf(category: Category): string {
340  return CATEGORIES.find(item => item.category === category)?.color ?? DIM
341}
342
343function labelOf(category: Category): string {
344  return CATEGORIES.find(item => item.category === category)?.label ?? category
345}
346
347function percentOf(part: number, whole: number): string {
348  if (whole <= 0 || part <= 0) {
349    return '0%'
350  }
351
352  const percent = (part / whole) * 100
353
354  return percent < 0.5 ? '<1%' : `${Math.round(percent)}%`
355}
356
357function stepLine(step: Step, now: number, isLongest: boolean, columns: number): Line {
358  const ms = (step.end ?? now) - step.start
359  const isRunning = step.end === null
360  const right: Segment = { text: `${clockOf(ms)}${isLongest ? ' 最久' : ''}`, ...(isLongest ? { bold: true } : {}) }
361  const head: Segment = isRunning ? { text: '● ', color: '#5ad67d' } : { text: '  ' }
362
363  if (step.type === 'model') {
364    const parts = [
365      step.wait >= 2000 ? `等 ${clockOf(step.wait)}` : '',
366      step.think > 0 ? `想 ${clockOf(step.think)}` : '',
367      step.write > 0 ? `寫 ${clockOf(step.write)}` : '',
368    ].filter(Boolean)
369
370    return line(
371      [head, { text: padEndWidth('模型', 10), color: colorOf('think') }, { text: parts.join('・') || '等回應', dim: true }],
372      right,
373      columns,
374    )
375  }
376
377  const isPath = step.detail.startsWith('/') || step.detail.startsWith('~') || /^[A-Za-z]:[\\/]/.test(step.detail)
378
379  return line(
380    [head, { text: padEndWidth(step.name, 10), color: colorOf(step.category) }, { text: step.detail, keepTail: isPath, dim: true }],
381    right,
382    columns,
383  )
384}
385
386/**
387 * 【何時能呼叫】columns 至少 30 才排得好看;更窄也不會壞,只是字會被裁掉。rows 是側邊欄可用的列數。
388 * 【行為】把這一輪(turn)排成側邊欄的每一行,每一行都不超過 columns 格寬。
389 *   turn 是 null 時只寫一行「還沒有開始任何一輪」。否則:
390 *   標題一行(右邊是「進行中 時間」或「花了 時間」)、一條橫跨整個寬度的時間條(每一格塗成那段時間花最多的類別的顏色,
391 *   什麼都沒有的塗成暗點)、底下標開始、中間、結尾的時間;「花在哪裡」列出有花到時間的類別、時間、百分比;
392 *   「一步一步」照先後列最近幾步(筆數看 rows 剩多少,最少 3 步),正在跑的前面有 ●,這一輪最久的那一步標「最久」;
393 *   模型的步寫「等」(2 秒以上才寫)、「想」、「寫」各多久。
394 *   previous 不是 null 時最後多一行「上一輪:時間(最多的類別 佔幾%)」。now 是現在的時間,毫秒。
395 */
396export function linesOf(turn: Turn | null, previous: Turn | null, now: number, columns: number, rows: number): Line[] {
397  if (turn === null) {
398    return [line([{ text: '這一輪的時間', bold: true }], undefined, columns), [], [{ text: fit('還沒有開始任何一輪', columns), dim: true }]]
399  }
400
401  const to = turn.end ?? now
402  const total = to - turn.start
403  const lines: Line[] = [
404    line(
405      [{ text: '這一輪的時間', bold: true }],
406      { text: `${turn.end === null ? '進行中' : '花了'} ${clockOf(total)}`, ...(turn.end === null ? {} : { dim: true }) },
407      columns,
408    ),
409    [],
410  ]
411
412  lines.push(
413    barOf(turn.spans, turn.start, to, columns).map(category =>
414      category === null ? { text: '·', color: DIM } : { text: '█', color: colorOf(category) },
415    ),
416  )
417
418  const mid = clockOf(total / 2)
419  const end = clockOf(total)
420  const gap = Math.max(1, Math.floor((columns - 1 - widthOf(mid) - widthOf(end)) / 2))
421
422  lines.push(line([{ text: `0${' '.repeat(gap)}${mid}`, dim: true }], { text: end, dim: true }, columns))
423  lines.push([], [{ text: fit('花在哪裡', columns), bold: true }])
424
425  const totals = totalsOf(turn, now)
426
427  if (totals.length === 0) {
428    lines.push([{ text: fit('  還沒有資料', columns), dim: true }])
429  }
430
431  for (const { category, ms } of totals) {
432    lines.push(
433      line(
434        [{ text: '■ ', color: colorOf(category) }, { text: labelOf(category) }],
435        { text: `${padStartWidth(clockOf(ms), 7)} ${padStartWidth(percentOf(ms, total), 4)}` },
436        columns,
437      ),
438    )
439  }
440
441  const footer: Line[] = []
442
443  if (previous !== null && previous.end !== null) {
444    const top = totalsOf(previous, previous.end).sort((a, b) => b.ms - a.ms)[0]
445    const share = top === undefined ? '' : `(${labelOf(top.category)}佔 ${percentOf(top.ms, previous.end - previous.start)})`
446
447    footer.push([], line([{ text: `上一輪:${clockOf(previous.end - previous.start)}${share}`, dim: true }], undefined, columns))
448  }
449
450  const room = Math.max(3, rows - lines.length - 2 - footer.length)
451  const shown = turn.steps.slice(-room)
452  const longest = turn.steps.reduce<Step | null>(
453    (best, step) => (best === null || (step.end ?? now) - step.start > (best.end ?? now) - best.start ? step : best),
454    null,
455  )
456
457  lines.push([], [{ text: fit(`一步一步(最近 ${shown.length} 步,共 ${turn.steps.length} 步)`, columns), bold: true }])
458
459  for (const step of shown) {
460    lines.push(stepLine(step, now, step === longest && turn.steps.length > 1, columns))
461  }
462
463  return [...lines, ...footer]
464}
465
466// #region AI-NOTES
467// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
468// 2026-10-03 為了原生 Windows:detailOf 經 tildeOf 比對家目錄時先把「\\」換成「/」,isPath 也認磁碟機代號。沒有 Windows 機器實測。
469// 2026-10-03 widthOf、fit、clockOf、toolNameOf、detailOf 從 ~/Workspace/projects/ctx-panel 與 files 複製;mod 之間不能互相 import。
470// 2026-10-03 「網路」的 Bash 判斷是猜的:只看每一段指令開頭的字,python 腳本裡面打 API(gpt_image.py、jev_ask.py)
471//   會被算成跑指令。主人說過要分「卡在網路還是卡在指令」,之後想更準可以把這幾支腳本名字加進 NETWORK_COMMANDS。
472// 2026-10-03 百分比的分母是整輪時間;不同類別同時進行(平行的工具)時各自算,所以加起來可能超過 100%。
473// #endregion
474