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

一個 Claude Code 的 mod。側邊欄的時間軸:這一輪的時間分成等模型、模型在想、在寫、跑指令、網路、讀寫檔案、等幫手、等你回答,分開畫。/timeline 開或關。
English summary at the end.
側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。
用 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,只拿掉捷徑。
settings.json 的 env 裡也別再放 CLAUDE_CODE_PLUGIN_DIRS 指到它),會出現兩份。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 授權。
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.
hooks/register.ts 299 lines1import 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
299hooks/view.ts 474 lines1/** 一行裡的一小段字,帶自己的顏色與粗細。 */
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