側邊欄的 context 用量面板:總量、分類、每輪成長、最佔地方的前幾名、快取、Claude 現在在做什麼。/ctx 開或關

一個 Claude Code 的 mod。這個對話的 context 視窗用了多少、分成哪幾類(系統提示、工具、記憶檔、對話…)、每一輪長了多少、最佔空間的是哪幾個檔案/MCP 工具/skill、上一輪的快取,最底下是 Claude 現在在做什麼。/ctx 開或關、/ctx full 算精確的(會打一次 token 計數 API)、/ctx files|mcp|skills 換「前幾名」列哪一種。
English summary at the end.
側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。
用 marketplace(推薦)
claude plugin marketplace add jessetsai1024/claude-ctx-panel
claude plugin install ctx-panel@claude-ctx-panel
然後在對話裡打 /reload-plugins,或重開 Claude Code。
或者 clone 下來接捷徑(之後 git pull 就是更新)
macOS/Linux:
git clone https://github.com/jessetsai1024/claude-ctx-panel.git
cd claude-ctx-panel && ./install.sh
Windows(原生版,在 PowerShell 裡):
git clone https://github.com/jessetsai1024/claude-ctx-panel.git
cd claude-ctx-panel
.\install.ps1
原理:放在 ~/.claude/skills/ctx-panel/ 底下的 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 授權。
ctx-panel is a mod for Claude Code: A pane showing the context window: how full, by category (system prompt, tools, memory files, messages…), growth per turn, the biggest files / MCP tools / skills, last turn's cache hit, and what Claude is doing right now. /ctx toggles, /ctx full counts precisely (one token-count API call), /ctx files|mcp|skills picks the top-N list. 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-ctx-panel then claude plugin install ctx-panel@claude-ctx-panel; or clone and run ./install.sh (macOS/Linux) or .\install.ps1 (native Windows, junction, no admin), which links the repo into ~/.claude/skills/ctx-panel 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 293 lines1import type { Register } from 'claude-code'
2
3import { activityOf, detailOf, sectionsOf, snapshotOf, toolNameOf } from './view'
4import type { Line, Snapshot, Tab, ToolRun } from './view'
5
6const PANE = 'ctx'
7const REFRESH_MS = 5000
8const MAX_HISTORY = 200
9// 側邊欄縮在輸入框上面時拿不到真正的高度,用這個當作可用列數
10const INLINE_ROWS = 34
11// 自動打開時晚一點開,讓這個側邊欄比其他自動開的(files、timeline)後開、排在最前面
12const AUTO_OPEN_DELAY_MS = 1000
13const MAX_RECENT = 3
14const TABS: readonly { tab: Tab; label: string; hotkey: string }[] = [
15 { tab: 'files', label: '記憶檔', hotkey: '1' },
16 { tab: 'mcp', label: 'MCP', hotkey: '2' },
17 { tab: 'skills', label: 'Skill', hotkey: '3' },
18]
19
20/**
21 * 【職責】把 context 用量面板接上 Claude Code:提供 /ctx,在側邊欄顯示現在的 context 視窗用了多少、
22 * 各分類各佔多少、每一輪長了多少、最佔地方的前幾名、上一輪的快取,最底下是 Claude 現在在做什麼。
23 * 讀系統給的用量數字、工具呼叫的名稱與參數、幫手清單;不讀對話的文字、不連網路、不寫檔。
24 * 【何時能呼叫】引擎載入這個 mod 時呼叫一次;重新載入會再呼叫,「每一輪長多少」的紀錄從頭開始記。
25 * 【行為】有人在用的 session(不是 claude -p)開始 1 秒後自己打開側邊欄;終端機不夠寬時先等著,
26 * 寬度夠了才出現(主人自己開過的 110 格,沒開過的 144 格,這是系統的規定)。
27 * /ctx:側邊欄沒開就開、開著就關。/ctx close:關掉。/ctx 數字:用那個寬度(格數)開。
28 * /ctx files、/ctx mcp、/ctx skills:切換「前幾名」列哪一種並打開;側邊欄裡那三顆鈕(按 1、2、3 或用滑鼠點)做同一件事。
29 * /ctx full:向伺服器問精確的分類數字再打開,比較慢;精確數字會留到下一輪結束,之後又回到估計值。
30 * 不論側邊欄開著沒,每一輪(主對話的)結束都會記一筆用量,所以晚點才打開也看得到之前每一輪長了多少。
31 * 側邊欄開著的時候另外每 5 秒重新讀一次估計值並重畫,所以 Claude 工作到一半也看得到用量在長、計時在跑。
32 * 「現在在做什麼」:主對話一輪開始到結束算工作中;每次用工具(主對話和幫手的都算)開始與結束都立刻重畫;
33 * 最近跑完的 3 個工具留在畫面上;正在跑的幫手每次重畫時向系統問。不論側邊欄開著沒都在記。
34 * 這個 mod 只看工具呼叫,不改它、不擋它,工具的結果原樣交回去。
35 * /clear 之後紀錄與畫面都歸零重來。
36 */
37export const register: Register = on => {
38 let snap: Snapshot | null = null
39 let history: number[] = []
40 let tab: Tab = 'files'
41 let home = ''
42 let isHoldingExact = false
43 // 主對話這一輪:turn.start 給的 id 和開始時間;閒著是 null
44 let turn: { id: string; since: number } | null = null
45 let lastMs: number | null = null
46 let callCount = 0
47 const calls = new Map<number, ToolRun & { agentId?: string }>()
48 let recent: (ToolRun & { agentId?: string })[] = []
49
50 on('session.start', async ($, e, next) => {
51 await $.command.register({
52 name: 'ctx',
53 description: '側邊欄的 context 用量面板(最底下是 Claude 現在在做什麼):/ctx 開或關、/ctx full 算精確的、/ctx files|mcp|skills 換清單',
54 immediate: true,
55 })
56 // Windows 沒有 HOME,家目錄在 USERPROFILE;統一成「/」隔開,比對路徑時才對得上
57 home = ((await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? '').replace(/\\/g, '/')
58
59 // 一開 session 就自己打開;不是主人叫的,終端機要夠寬才放得出來,不夠寬就先等著,不用等它
60 if (e.isInteractive) {
61 $.clock.after(AUTO_OPEN_DELAY_MS, () => {
62 void $.ui.open({ id: PANE, title: 'Context' })
63 })
64 }
65
66 // 重新載入時已經有用量了,先記一筆當起點,下一輪結束就算得出成長
67 const { context } = await $.session.usage()
68
69 if (context.tokens !== undefined) {
70 history.push(context.tokens)
71 }
72
73 $.clock.every(REFRESH_MS, () => {
74 if (isHoldingExact) {
75 return
76 }
77
78 void $.ui.panes().then(async panes => {
79 if (!panes.some(pane => pane.id === PANE && pane.isShown)) {
80 return
81 }
82
83 snap = snapshotOf(await $.session.usage({ breakdown: 'summary' }), home, false)
84 $.ui.invalidate('ui.render')
85 })
86 })
87
88 return next(e)
89 })
90
91 on('classic.SessionStart', { source: ['clear'] }, ($, e, next) => {
92 snap = null
93 history = []
94 isHoldingExact = false
95 recent = []
96 lastMs = null
97 $.ui.invalidate('ui.render')
98
99 return next(e)
100 })
101
102 on('turn.start', async ($, e, next) => {
103 // 幫手的輪次如果也走這裡,主對話正在跑時不會蓋掉它;結束時用 id 對回來
104 if (turn === null) {
105 turn = { id: e.turnId, since: await $.clock.now() }
106 $.ui.invalidate('ui.render')
107 }
108
109 return next(e)
110 })
111
112 on('tool.call', async ($, e, next) => {
113 const id = callCount
114 callCount += 1
115 calls.set(id, {
116 tool: toolNameOf(e.tool),
117 detail: detailOf(e, home),
118 at: await $.clock.now(),
119 ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
120 })
121 $.ui.invalidate('ui.render')
122
123 try {
124 return await next(e)
125 } finally {
126 const call = calls.get(id)
127 calls.delete(id)
128
129 if (call !== undefined) {
130 recent = [call, ...recent].slice(0, MAX_RECENT)
131 }
132
133 $.ui.invalidate('ui.render')
134 }
135 })
136
137 on('turn.complete', ($, e, next) => {
138 if (e.agentId === undefined || e.turnId === turn?.id) {
139 if (e.agentId === undefined) {
140 lastMs = e.durationMs
141 }
142
143 turn = null
144 }
145
146 // 幫手跑完也要重畫,幫手清單才會更新
147 $.ui.invalidate('ui.render')
148
149 if (e.agentId === undefined) {
150 void $.session.usage({ breakdown: 'summary' }).then(usage => {
151 isHoldingExact = false
152 snap = snapshotOf(usage, home, false)
153
154 if (usage.context.tokens !== undefined) {
155 history = [...history, usage.context.tokens].slice(-MAX_HISTORY)
156 }
157
158 $.ui.invalidate('ui.render')
159 })
160 }
161
162 return next(e)
163 })
164
165 on('command.run', { command: 'ctx' }, async ($, e) => {
166 const arg = e.args.trim()
167 const wanted = Number.parseInt(arg, 10)
168 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
169
170 if (arg === 'close' || (arg === '' && isUp)) {
171 await $.ui.close({ id: PANE })
172
173 return {}
174 }
175
176 if (arg === 'files' || arg === 'mcp' || arg === 'skills') {
177 tab = arg
178 }
179
180 const isExact = arg === 'full'
181
182 snap = snapshotOf(await $.session.usage({ breakdown: isExact ? 'full' : 'summary' }), home, isExact)
183 isHoldingExact = isExact
184
185 const opened = await $.ui.open(
186 Number.isInteger(wanted) && wanted > 0
187 ? { id: PANE, title: 'Context', columns: wanted }
188 : { id: PANE, title: 'Context' },
189 )
190
191 if (!opened.isPlaced) {
192 return { text: `側邊欄沒有被放出來:${opened.reason}` }
193 }
194
195 // 重新打開時引擎可能直接拿上次畫好的結果來用,所以自己要求重畫
196 $.ui.invalidate('ui.render')
197
198 return {}
199 })
200
201 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
202 const { Box, Text, Button } = $.ui.resolve(e)
203
204 if (snap === null) {
205 snap = snapshotOf(await $.session.usage({ breakdown: 'summary' }), home, false)
206 }
207
208 const agents = await $.agent.list()
209 const typeOf = new Map(agents.map(agent => [agent.id, agent.type]))
210 const withWho = ({ agentId, ...run }: ToolRun & { agentId?: string }): ToolRun =>
211 agentId === undefined ? run : { ...run, who: typeOf.get(agentId) ?? '幫手' }
212 const tail = activityOf(
213 {
214 since: turn?.since ?? null,
215 lastMs,
216 running: [...calls.values()].filter(call => call.agentId === undefined).map(withWho),
217 recent: recent.map(withWho),
218 helpers: agents
219 .filter(agent => agent.status === 'running')
220 .map(agent => ({ type: agent.type, description: agent.description })),
221 },
222 await $.clock.now(),
223 e.props.bodyColumns,
224 )
225 const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : INLINE_ROWS
226 // 「現在在做什麼」那一塊固定要放得下,所以把它佔的列數從可用列數扣掉,讓前幾名清單去縮
227 const { top, list, bottom } = sectionsOf(snap, history, tab, e.props.bodyColumns, rows - tail.length)
228 const lineOf = (line: Line) =>
229 Text({
230 wrap: 'truncate-end',
231 children:
232 line.length === 0
233 ? [' ']
234 : line
235 .filter(segment => segment.text !== '')
236 .map(segment =>
237 Text({
238 ...(segment.color === undefined ? {} : { color: segment.color }),
239 ...(segment.bold === true ? { bold: true } : {}),
240 ...(segment.dim === true ? { dimColor: true } : {}),
241 children: [segment.text],
242 }),
243 ),
244 })
245
246 return Box({
247 flexDirection: 'column',
248 children: [
249 ...top.map(lineOf),
250 Text({ bold: true, children: ['最佔地方的前幾名'] }),
251 Box({
252 flexDirection: 'row',
253 columnGap: 2,
254 children: TABS.map(item =>
255 Button({
256 key: `tab-${item.tab}`,
257 label: item.label,
258 hotkey: item.hotkey,
259 plain: true,
260 ...(tab === item.tab ? {} : { dimColor: true }),
261 onPress: () => {
262 tab = item.tab
263 $.ui.invalidate('ui.render')
264 },
265 }),
266 ),
267 }),
268 ...list.map(lineOf),
269 ...bottom.map(lineOf),
270 ...tail.map(lineOf),
271 ],
272 })
273 })
274}
275
276// #region AI-NOTES
277// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
278// 2026-10-02 這個面板全是字,用 Text 畫不用 Raster;一行是一個 Text 裡面包幾個帶顏色的小 Text,
279// 不用橫排的 Box,因為補空白對齊靠的是字元寬度,交給 flex 怕空白被吃掉。顏色用 #rrggbb。
280// 主人的畫面人家看不到:顏色有沒有出來、中文對不對得齊,都要他看。
281// 2026-10-02 不靠「開著」的旗標,要判斷就問 $.ui.panes()。字元雨踩過旗標只在 ui.render 裡設、重開後沒重跑 render 的坑
282// (~/Workspace/projects/matrix-rain/hooks/register.ts 的 AI-NOTES)。
283// 2026-10-02 turn.complete 和計時器裡的 $.session.usage 都不 await(void 加 then):不想讓面板拖慢一輪的收尾。
284// 「summary」是本機估的,型別檔說不花錢;「full」會打 token 計數 API,只在主人打 /ctx full 時用。
285// 2026-10-02 每輪成長只記主對話(e.agentId 是 undefined)的輪次;幫手的輪次不記,不然一輪會被記成好幾筆。
286// 2026-10-02 session.start 自動打開用 void、不 await:不夠寬時 $.ui.open 會等著,不能卡住 session 開始。
287// 晚 1 秒是因為主人要 ctx 排在其他自動開的側邊欄前面(當時是 crew,已刪;現在是 files、timeline),系統沒有指定分頁的設定,最後打開的排最前面(型別檔沒寫,2026-10-03 主人實測確認)。
288// 主人用 /ctx 關掉算「親手關」,下次自動打開的門檻會回到 144 格(型別檔 PaneOpenArgs 的說明)。
289// 2026-10-02 「現在在做什麼」:型別檔沒講 turn.start 會不會替幫手的輪次觸發(TurnStartInput 沒有 agentId),
290// 所以只在閒著時記開始、結束時用 turnId 對回來;未在真機驗過。tool.call 的 hook 一定要 await next(e) 原樣回傳,
291// 紀錄放 finally,工具出錯或被擋也會從「正在跑」移走。說明檔講 next 在跑的時間不算進 hook 的時間額度。
292// #endregion
293hooks/view.ts 702 lines1import type { SessionUsage } from 'claude-code'
2
3/** 一行裡的一小段字,帶自己的顏色與粗細。 */
4export type Segment = {
5 /** 要顯示的字。 */
6 text: string
7 /** 顏色,`#rrggbb`;沒給就是終端機預設字色。 */
8 color?: string
9 /** 粗體。 */
10 bold?: boolean
11 /** 暗一階,給次要資訊用。 */
12 dim?: boolean
13}
14
15/** 畫面上的一行:由左到右排的幾段字;空陣列是空白行。 */
16export type Line = Segment[]
17
18/** 「前幾名」那一塊現在列哪一種:記憶檔、MCP 工具(依伺服器合計)、skill。 */
19export type Tab = 'files' | 'mcp' | 'skills'
20
21/** 「前幾名」清單裡的一筆:名字和它佔的 token 數。 */
22export type Item = {
23 /** 顯示的名字(檔案路徑、伺服器名、skill 名)。 */
24 label: string
25 /** 估計佔多少 token。 */
26 tokens: number
27}
28
29/** 分類那一塊的一列,跟 /context 指令的分法一樣。 */
30export type Category = {
31 /** 中文名字;認不得的分類保留系統給的原名。 */
32 name: string
33 /** 估計佔多少 token。 */
34 tokens: number
35 /** used 佔著位子、free 空位、buffer 留給自動壓縮的、deferred 用到才載入所以不佔位子。 */
36 kind: 'used' | 'free' | 'buffer' | 'deferred'
37}
38
39/**
40 * 【職責】畫面板需要的全部資料,從系統給的用量報告整理出來的一份快照。
41 * 只是資料;由 snapshotOf 建立、sectionsOf 讀。
42 */
43export type Snapshot = {
44 /** 這一份是替哪個模型算的。 */
45 model: string
46 /** 現在用了多少 token:有上一次回應的實際數字就用它,沒有就用分類估計的總和。 */
47 tokens: number
48 /** 模型的 context 視窗有多大。 */
49 window: number
50 /** 自動壓縮在用到多少 token 時啟動;沒開自動壓縮是 null。 */
51 compactAt: number | null
52 /** 各分類,佔位子的在前、由大到小,再來是用到才載入的、壓縮預留、空位。 */
53 categories: Category[]
54 /** 記憶檔,由大到小。 */
55 files: Item[]
56 /** MCP 工具依伺服器合計(只算已經載入的),由大到小。 */
57 mcp: Item[]
58 /** skill,由大到小。 */
59 skills: Item[]
60 /** 上一次回應的輸入有多少是讀快取、多少是新寫進快取、多少沒走快取;還沒有回應是 null。 */
61 cache: { read: number; written: number; fresh: number } | null
62 /** 分類數字是不是向伺服器問過的精確值;false 是本機估的。 */
63 isExact: boolean
64}
65
66/** 面板的三段:前幾名清單上面的、清單本身、清單下面的。分三段是因為清單上方要插一列可以按的分頁鈕。 */
67export type Sections = {
68 /** 總量、分類、每輪成長。 */
69 top: Line[]
70 /** 目前分頁的前幾名。 */
71 list: Line[]
72 /** 快取與註腳。 */
73 bottom: Line[]
74}
75
76const NAMES: Record<string, string> = {
77 Messages: '對話訊息',
78 'Memory files': '記憶檔',
79 'System tools': '系統工具',
80 'MCP tools': 'MCP 工具',
81 Skills: 'Skill 清單',
82 'System prompt': '系統提示',
83 'Custom agents': '自訂幫手',
84 'Autocompact buffer': '壓縮預留',
85 'Free space': '空位',
86}
87
88const COLORS: Record<string, string> = {
89 對話訊息: '#b48cff',
90 記憶檔: '#ff9f43',
91 系統工具: '#4fc3f7',
92 'MCP 工具': '#26c6a8',
93 'Skill 清單': '#f7d154',
94 系統提示: '#9aa7b8',
95 自訂幫手: '#ff7eb6',
96}
97const SPARE_COLORS = ['#8bd17c', '#e57373', '#7986cb', '#a1887f']
98const DIM = '#6b7280'
99const GOOD = '#5ad67d'
100const WARN = '#f7d154'
101const BAD = '#ff6b6b'
102const SPARKS = '▁▂▃▄▅▆▇█'
103const MAX_SPARKS = 20
104const AVERAGE_OVER = 10
105/** 清單以外固定會用掉的列數,用來算清單可以放幾筆。 */
106const FIXED_ROWS = 26
107const MIN_ITEMS = 3
108const MAX_ITEMS = 10
109
110/**
111 * 【行為】一段字在終端機佔幾格寬:中日韓文字、全形標點、表情符號算 2 格,其他算 1 格。
112 * 方塊字元(█ ░ ■ 這類)算 1 格,跟主人的終端機實際畫法一致。
113 */
114export function widthOf(text: string): number {
115 let width = 0
116
117 for (const glyph of text) {
118 const code = glyph.codePointAt(0) ?? 0
119 const isWide =
120 (code >= 0x1100 && code <= 0x115f) ||
121 (code >= 0x2e80 && code <= 0xa4cf) ||
122 (code >= 0xac00 && code <= 0xd7a3) ||
123 (code >= 0xf900 && code <= 0xfaff) ||
124 (code >= 0xfe30 && code <= 0xfe4f) ||
125 (code >= 0xff00 && code <= 0xff60) ||
126 (code >= 0xffe0 && code <= 0xffe6) ||
127 (code >= 0x1f300 && code <= 0x1faff) ||
128 (code >= 0x20000 && code <= 0x3fffd)
129 width += isWide ? 2 : 1
130 }
131
132 return width
133}
134
135/**
136 * 【行為】把 token 數寫成短的樣子:不到一千照寫(850),不到一萬帶一位小數(3.2k,剛好整數就寫 6k),
137 * 不到一百萬取整數千(312k),一百萬以上用 M(1M、1.2M)。負數當 0。
138 */
139export function short(tokens: number): string {
140 const n = Math.max(0, Math.round(tokens))
141
142 if (n < 1000) {
143 return `${n}`
144 }
145
146 if (n < 10_000) {
147 const thousands = (n / 1000).toFixed(1)
148
149 return `${thousands.endsWith('.0') ? thousands.slice(0, -2) : thousands}k`
150 }
151
152 if (n < 999_500) {
153 return `${Math.round(n / 1000)}k`
154 }
155
156 const millions = (n / 1_000_000).toFixed(1)
157
158 return `${millions.endsWith('.0') ? millions.slice(0, -2) : millions}M`
159}
160
161/**
162 * 【行為】把一段字裁到最多 max 格寬。超過時留開頭、結尾補「…」;keepTail 為 true 時改成留結尾、開頭補「…」
163 * (檔案路徑用這個,因為檔名在最後面)。本來就放得下就原樣回傳。
164 */
165export function fit(text: string, max: number, keepTail = false): string {
166 if (widthOf(text) <= max) {
167 return text
168 }
169
170 const glyphs = Array.from(text)
171 let kept = ''
172
173 if (keepTail) {
174 for (let at = glyphs.length - 1; at >= 0; at -= 1) {
175 const next = (glyphs[at] ?? '') + kept
176
177 if (widthOf(next) > max - 1) {
178 break
179 }
180
181 kept = next
182 }
183
184 return `…${kept}`
185 }
186
187 for (const glyph of glyphs) {
188 if (widthOf(kept + glyph) > max - 1) {
189 break
190 }
191
192 kept += glyph
193 }
194
195 return `${kept}…`
196}
197
198/**
199 * 【行為】把一條 width 格寬的長條分給幾個數量,回傳每個數量該佔幾格,加起來剛好等於 width。
200 * 照比例分;大於 0 的數量至少給 1 格(不然小分類會整個看不到),多出來的從最長的那段扣。
201 * 全部是 0 或 width 不夠每個大於 0 的各給 1 格時,盡量分、分不到的給 0。
202 */
203export function share(amounts: readonly number[], width: number): number[] {
204 const total = amounts.reduce((sum, amount) => sum + Math.max(0, amount), 0)
205 const cells = amounts.map(() => 0)
206
207 if (total <= 0 || width <= 0) {
208 return cells
209 }
210
211 const exact = amounts.map(amount => (Math.max(0, amount) / total) * width)
212
213 exact.forEach((value, at) => {
214 cells[at] = (amounts[at] ?? 0) > 0 ? Math.max(1, Math.floor(value)) : 0
215 })
216
217 let sum = cells.reduce((a, b) => a + b, 0)
218
219 while (sum > width) {
220 const widest = cells.indexOf(Math.max(...cells))
221
222 if ((cells[widest] ?? 0) <= 0) {
223 break
224 }
225
226 cells[widest] = (cells[widest] ?? 0) - 1
227 sum -= 1
228 }
229
230 while (sum < width) {
231 // 補給「照比例還差最多」的那一段
232 let best = 0
233 let gap = -Infinity
234
235 exact.forEach((value, at) => {
236 if (value - (cells[at] ?? 0) > gap) {
237 gap = value - (cells[at] ?? 0)
238 best = at
239 }
240 })
241 cells[best] = (cells[best] ?? 0) + 1
242 sum += 1
243 }
244
245 return cells
246}
247
248/**
249 * 【行為】把每一輪結束時的用量變成「每一輪長了多少」:後一筆減前一筆。
250 * 用量變少的那一輪(壓縮過、清過對話)算 0,不算負的。少於兩筆回空陣列。
251 */
252export function growthOf(history: readonly number[]): number[] {
253 const growth: number[] = []
254
255 for (let at = 1; at < history.length; at += 1) {
256 growth.push(Math.max(0, (history[at] ?? 0) - (history[at - 1] ?? 0)))
257 }
258
259 return growth
260}
261
262/**
263 * 【行為】把系統給的用量報告整理成快照。home 是家目錄,路徑裡的家目錄會換成「~」。
264 * 報告沒帶分類明細時(呼叫時沒要求),分類與三份清單都是空的、isExact 是 false。
265 * isExact 由呼叫端告知這次是不是要求精確計算。
266 */
267export function snapshotOf(usage: SessionUsage, home: string, isExact: boolean): Snapshot {
268 const breakdown = usage.context.breakdown
269 const order = { used: 0, deferred: 1, buffer: 2, free: 3 }
270 const categories: Category[] = (breakdown?.categories ?? [])
271 .map(row => {
272 const base = row.name.replace(/\s*\(deferred\)\s*$/i, '')
273 const name = NAMES[base] ?? base
274
275 return { name: row.kind === 'deferred' ? `${name}(未載入)` : name, tokens: row.tokens, kind: row.kind }
276 })
277 .sort((a, b) => order[a.kind] - order[b.kind] || b.tokens - a.tokens)
278 const bySize = (a: Item, b: Item): number => b.tokens - a.tokens
279 const servers = new Map<string, number>()
280
281 for (const tool of breakdown?.mcpTools ?? []) {
282 if (tool.isLoaded) {
283 servers.set(tool.serverName, (servers.get(tool.serverName) ?? 0) + tool.tokens)
284 }
285 }
286
287 const api = breakdown?.apiUsage ?? null
288
289 return {
290 model: breakdown?.model ?? '',
291 tokens: usage.context.tokens ?? breakdown?.totalTokens ?? 0,
292 window: usage.context.window,
293 compactAt: breakdown?.isAutoCompactEnabled === true ? (breakdown.autoCompactThreshold ?? null) : null,
294 categories,
295 files: (breakdown?.memoryFiles ?? [])
296 .map(file => ({
297 label: tildeOf(file.path, home),
298 tokens: file.tokens,
299 }))
300 .sort(bySize),
301 mcp: Array.from(servers, ([label, tokens]) => ({ label, tokens })).sort(bySize),
302 skills: (breakdown?.skills?.skillFrontmatter ?? [])
303 .map(skill => ({ label: skill.name, tokens: skill.tokens }))
304 .sort(bySize),
305 cache:
306 api === null
307 ? null
308 : { read: api.cache_read_input_tokens, written: api.cache_creation_input_tokens, fresh: api.input_tokens },
309 isExact: isExact && breakdown !== undefined,
310 }
311}
312
313function colorOf(name: string, at: number): string {
314 return COLORS[name] ?? SPARE_COLORS[at % SPARE_COLORS.length] ?? DIM
315}
316
317/** 左邊一串、右邊一段字,中間用空白撐到剛好 columns 格寬;左邊太長會被裁掉。 */
318function row(left: Segment[], right: Segment, columns: number): Line {
319 const room = Math.max(0, columns - widthOf(right.text) - 1)
320 const kept: Segment[] = []
321 let used = 0
322
323 for (const segment of left) {
324 const text = fit(segment.text, room - used)
325
326 if (room - used <= 0) {
327 break
328 }
329
330 kept.push({ ...segment, text })
331 used += widthOf(text)
332 }
333
334 return [...kept, { text: ' '.repeat(Math.max(1, columns - used - widthOf(right.text))) }, right]
335}
336
337function percentOf(part: number, whole: number): string {
338 if (whole <= 0 || part <= 0) {
339 return '0%'
340 }
341
342 const percent = (part / whole) * 100
343
344 return percent < 0.5 ? '<1%' : `${Math.round(percent)}%`
345}
346
347/**
348 * 【何時能呼叫】columns 至少 20 才排得好看;更窄也不會壞,只是字會被裁掉。
349 * 【行為】把快照排成面板的三段文字,每一行都不超過 columns 格寬:
350 * top 是總量(用了多少、長條、離自動壓縮還有多少與照最近速度大約還能幾輪)、分類表、每輪成長的小柱狀圖;
351 * list 是 tab 指定的那一種前幾名,筆數看 rows(側邊欄高度)剩多少,最少 3 筆最多 10 筆,沒有資料時是一行說明;
352 * bottom 是上一輪的快取(三個數字加一條長條)與一行註腳(分類是估的還是精確的)。
353 * history 是每一輪結束時的用量,最舊的在前;「大約還能幾輪」取最近 10 輪有成長的平均,沒有成長紀錄就不寫。
354 */
355export function sectionsOf(
356 snap: Snapshot,
357 history: readonly number[],
358 tab: Tab,
359 columns: number,
360 rows: number,
361): Sections {
362 const top: Line[] = []
363 const used = snap.categories.filter(category => category.kind === 'used')
364 const inWindow = snap.categories.filter(category => category.kind !== 'deferred')
365 const total = inWindow.reduce((sum, category) => sum + category.tokens, 0)
366 const growth = growthOf(history)
367
368 // 一、總量
369 top.push(row([{ text: 'Context', bold: true }], { text: snap.model, dim: true }, columns))
370 top.push(
371 row(
372 [{ text: `用了 ${short(snap.tokens)} / ${short(snap.window)}` }],
373 { text: percentOf(snap.tokens, snap.window), bold: true },
374 columns,
375 ),
376 )
377
378 if (inWindow.length > 0) {
379 const cells = share(
380 inWindow.map(category => category.tokens),
381 columns,
382 )
383
384 top.push(
385 inWindow.map((category, at) => {
386 const count = cells[at] ?? 0
387
388 if (category.kind === 'used') {
389 return { text: '█'.repeat(count), color: colorOf(category.name, used.indexOf(category)) }
390 }
391
392 return { text: (category.kind === 'buffer' ? '▒' : '░').repeat(count), color: DIM }
393 }),
394 )
395 }
396
397 const recent = growth.slice(-AVERAGE_OVER).filter(delta => delta > 0)
398 const average = recent.length === 0 ? 0 : recent.reduce((a, b) => a + b, 0) / recent.length
399
400 if (snap.compactAt === null) {
401 top.push([{ text: fit(`自動壓縮沒開;空位還有 ${short(snap.window - snap.tokens)}`, columns), dim: true }])
402 } else if (snap.compactAt <= snap.tokens) {
403 top.push([{ text: fit('已經超過自動壓縮的門檻', columns), color: BAD }])
404 } else {
405 const left = snap.compactAt - snap.tokens
406 const turns = average > 0 ? `,照現在速度約 ${Math.floor(left / average)} 輪` : ''
407
408 top.push([{ text: fit(`離自動壓縮還有 ${short(left)}${turns}`, columns), dim: true }])
409 }
410
411 // 二、分類
412 top.push([])
413 top.push(row([{ text: '分類', bold: true }], { text: ' tokens %', dim: true }, columns))
414
415 if (snap.categories.length === 0) {
416 top.push([{ text: fit('還沒有分類資料', columns), dim: true }])
417 }
418
419 for (const category of snap.categories) {
420 const numbers = `${short(category.tokens).padStart(7)} ${(category.kind === 'deferred' ? '' : percentOf(category.tokens, total)).padStart(4)}`
421
422 if (category.kind === 'used') {
423 const color = colorOf(category.name, used.indexOf(category))
424
425 top.push(row([{ text: '■ ', color }, { text: category.name }], { text: numbers }, columns))
426 } else {
427 const mark = category.kind === 'buffer' ? '▒ ' : category.kind === 'free' ? '□ ' : '· '
428
429 top.push(
430 row([{ text: mark, color: DIM }, { text: category.name, dim: true }], { text: numbers, dim: true }, columns),
431 )
432 }
433 }
434
435 // 三、每一輪長多少
436 const shown = growth.slice(-Math.max(1, Math.min(MAX_SPARKS, columns - 16)))
437 const tallest = Math.max(1, ...shown)
438
439 top.push([])
440 top.push([{ text: fit(`每一輪長多少(最近 ${Math.max(shown.length, 1)} 輪)`, columns), bold: true }])
441
442 if (shown.length === 0) {
443 top.push([{ text: fit('還沒有紀錄,下一輪結束後開始畫', columns), dim: true }])
444 } else {
445 const bars = shown
446 .map(delta => SPARKS.charAt(Math.min(SPARKS.length - 1, Math.floor((delta / tallest) * (SPARKS.length - 1)))))
447 .join('')
448
449 top.push(
450 row([{ text: bars, color: GOOD }], { text: `上一輪 +${short(shown[shown.length - 1] ?? 0)}` }, columns),
451 )
452 }
453
454 top.push([])
455
456 // 四、前幾名
457 const items = tab === 'files' ? snap.files : tab === 'mcp' ? snap.mcp : snap.skills
458 const room = Math.max(MIN_ITEMS, Math.min(MAX_ITEMS, rows - FIXED_ROWS))
459 const list: Line[] = items
460 .slice(0, room)
461 .map(item => row([{ text: fit(item.label, columns - 9, tab === 'files') }], { text: short(item.tokens) }, columns))
462
463 if (list.length === 0) {
464 list.push([{ text: fit(tab === 'mcp' ? '沒有已載入的 MCP 工具' : '沒有資料', columns), dim: true }])
465 } else if (items.length > room) {
466 list.push([{ text: fit(`…還有 ${items.length - room} 個`, columns), dim: true }])
467 }
468
469 // 五、快取
470 const bottom: Line[] = [[], [{ text: '上一輪的快取', bold: true }]]
471
472 if (snap.cache === null) {
473 bottom.push([{ text: fit('還沒有資料,要等下一次回應', columns), dim: true }])
474 } else {
475 const { read, written, fresh } = snap.cache
476 const cells = share([read, written, fresh], columns)
477
478 bottom.push([
479 { text: fit(`讀快取 ${short(read)} · 新寫 ${short(written)} · 沒快取 ${short(fresh)}`, columns) },
480 ])
481 bottom.push([
482 { text: '█'.repeat(cells[0] ?? 0), color: GOOD },
483 { text: '█'.repeat(cells[1] ?? 0), color: WARN },
484 { text: '█'.repeat(cells[2] ?? 0), color: BAD },
485 ])
486 }
487
488 bottom.push([
489 { text: fit(snap.isExact ? '分類是精確計算的' : '分類是估計值;/ctx full 算精確的', columns), dim: true },
490 ])
491
492 return { top, list, bottom }
493}
494
495/** 一次工具呼叫,給「現在在做什麼」那一塊用。 */
496export type ToolRun = {
497 /** 工具名稱;MCP 工具已經整理成「伺服器/工具」。 */
498 tool: string
499 /** 一小段說它在做什麼:指令、檔案路徑、搜尋字串…;沒有就是空字串。 */
500 detail: string
501 /** 哪一種幫手用的(Explore、Plan…);主對話用的是 undefined。 */
502 who?: string
503 /** 開始的時間,毫秒。 */
504 at: number
505}
506
507/**
508 * 【職責】畫「現在在做什麼」那一塊需要的資料。只是資料;由 register.ts 收集、activityOf 讀。
509 */
510export type Activity = {
511 /** 主對話這一輪從什麼時候開始(毫秒);閒著是 null。 */
512 since: number | null
513 /** 上一輪主對話花了多久(毫秒);還沒有紀錄是 null。 */
514 lastMs: number | null
515 /** 主對話正在跑的工具,先開始的在前。 */
516 running: ToolRun[]
517 /** 最近跑完的工具(主對話和幫手都算),新的在前。 */
518 recent: ToolRun[]
519 /** 正在跑的幫手:種類和說明。 */
520 helpers: { type: string; description: string }[]
521}
522
523const TOOL = '#4fc3f7'
524const HELPER = '#ff7eb6'
525const MAX_RUNNING = 3
526const DETAIL_KEYS = ['command', 'file_path', 'notebook_path', 'pattern', 'url', 'query', 'skill', 'description', 'prompt', 'path']
527
528/** 【行為】把工具名稱寫短:mcp__xapi__search 變成 xapi/search,其他照原樣。 */
529export function toolNameOf(tool: string): string {
530 const match = /^mcp__(.+?)__(.+)$/.exec(tool)
531
532 return match === null ? tool : `${match[1]}/${match[2]}`
533}
534
535/**
536 * 【行為】從工具的參數挑一段最能說明它在做什麼的字:依序找 command、file_path、notebook_path、pattern、url、
537 * query、skill、description、prompt、path,第一個不是空字串的就用它。換行和連續空白壓成一個空白,
538 * 開頭是家目錄的換成「~」(Windows 的「\\」先換成「/」再比對)。都沒有就回空字串。
539 */
540export function detailOf(args: Readonly<Record<string, unknown>>, home: string): string {
541 for (const key of DETAIL_KEYS) {
542 const value = args[key]
543
544 if (typeof value === 'string' && value.trim() !== '') {
545 const text = value.replace(/\s+/g, ' ').trim()
546
547 return tildeOf(text, home)
548 }
549 }
550
551 return ''
552}
553
554/** 【行為】開頭是家目錄的換成「~」;Windows 的「\\」先換成「/」再比對,不在家目錄底下的原樣回傳。home 要是「/」隔開的。 */
555export function tildeOf(text: string, home: string): string {
556 const posix = text.replace(/\\/g, '/')
557
558 return home !== '' && (posix === home || posix.startsWith(`${home}/`)) ? `~${posix.slice(home.length)}` : text
559}
560
561/** 【行為】把毫秒寫成時鐘的樣子:不到一小時是「分:秒」(1:05),一小時以上是「時:分:秒」(1:02:03)。負數當 0。 */
562export function clockOf(ms: number): string {
563 const seconds = Math.max(0, Math.floor(ms / 1000))
564 const s = `${seconds % 60}`.padStart(2, '0')
565 const minutes = Math.floor(seconds / 60)
566
567 if (minutes < 60) {
568 return `${minutes}:${s}`
569 }
570
571 return `${Math.floor(minutes / 60)}:${`${minutes % 60}`.padStart(2, '0')}:${s}`
572}
573
574/** 【行為】把「過了多久」寫成白話:不到 1 秒是「剛剛」,再來是「12 秒前」「3 分前」「2 小時前」。 */
575export function agoOf(ms: number): string {
576 const seconds = Math.floor(Math.max(0, ms) / 1000)
577
578 if (seconds < 1) {
579 return '剛剛'
580 }
581
582 if (seconds < 60) {
583 return `${seconds} 秒前`
584 }
585
586 if (seconds < 3600) {
587 return `${Math.floor(seconds / 60)} 分前`
588 }
589
590 return `${Math.floor(seconds / 3600)} 小時前`
591}
592
593/** 幾段字由左到右排,超過 columns 格寬的部分裁掉。 */
594function clipped(segments: Segment[], columns: number): Line {
595 const kept: Segment[] = []
596 let used = 0
597
598 for (const segment of segments) {
599 if (columns - used <= 0) {
600 break
601 }
602
603 const text = fit(segment.text, columns - used)
604
605 kept.push({ ...segment, text })
606 used += widthOf(text)
607 }
608
609 return kept
610}
611
612/** 一個工具一行:縮排、誰用的、工具名、說明;有 right 就靠右放。說明是路徑時留結尾。 */
613function toolLine(run: ToolRun, columns: number, right?: Segment): Line {
614 const room = right === undefined ? columns : Math.max(0, columns - widthOf(right.text) - 1)
615 const head = clipped(
616 [{ text: ' ' }, ...(run.who === undefined ? [] : [{ text: `${run.who}·`, dim: true }]), { text: run.tool, color: TOOL }],
617 room,
618 )
619 const used = head.reduce((sum, segment) => sum + widthOf(segment.text), 0)
620 const left = room - used - 2
621 const isPath = run.detail.startsWith('/') || run.detail.startsWith('~') || /^[A-Za-z]:[\\/]/.test(run.detail)
622 const line: Line = left >= 2 && run.detail !== '' ? [...head, { text: ` ${fit(run.detail, left, isPath)}` }] : head
623
624 if (right === undefined) {
625 return line
626 }
627
628 const width = line.reduce((sum, segment) => sum + widthOf(segment.text), 0)
629
630 return [...line, { text: ' '.repeat(Math.max(1, columns - width - widthOf(right.text))) }, right]
631}
632
633/**
634 * 【何時能呼叫】columns 至少 20 才排得好看;更窄也不會壞,只是字會被裁掉。
635 * 【行為】把 activity 排成面板最底下「現在在做什麼」那一塊,每一行都不超過 columns 格寬,第一行是空白行。
636 * 狀態:主對話一輪進行中(since 不是 null)或有工具在跑,寫「● 工作中」加上已經多久(since 是 null 就不寫時間),
637 * 下面列正在跑的工具(最多 3 個),沒有工具在跑就寫「思考中」;否則寫「○ 閒著」,有上一輪的紀錄就加上它花了多久。
638 * 再來是「最近用的工具」:recent 全部列出,右邊寫幾秒前(now 減開始時間);沒有就寫「還沒有」。
639 * helpers 不是空的才多一段「幫手(N 個在跑)」逐個列出。now 是現在的時間,毫秒。
640 */
641export function activityOf(activity: Activity, now: number, columns: number): Line[] {
642 const lines: Line[] = [[], [{ text: fit('現在在做什麼', columns), bold: true }]]
643 const isBusy = activity.since !== null || activity.running.length > 0
644
645 if (isBusy) {
646 const elapsed = activity.since === null ? '' : ` ${clockOf(now - activity.since)}`
647
648 lines.push(clipped([{ text: '● ', color: GOOD }, { text: `工作中${elapsed}` }], columns))
649
650 if (activity.running.length === 0) {
651 lines.push([{ text: fit(' 思考中', columns), dim: true }])
652 }
653
654 for (const run of activity.running.slice(0, MAX_RUNNING)) {
655 lines.push(toolLine(run, columns))
656 }
657
658 if (activity.running.length > MAX_RUNNING) {
659 lines.push([{ text: fit(` …還有 ${activity.running.length - MAX_RUNNING} 個`, columns), dim: true }])
660 }
661 } else {
662 const last = activity.lastMs === null ? '' : `・上一輪花了 ${clockOf(activity.lastMs)}`
663
664 lines.push(clipped([{ text: '○ ', color: DIM }, { text: `閒著${last}`, dim: true }], columns))
665 }
666
667 lines.push([{ text: fit('最近用的工具', columns), bold: true }])
668
669 if (activity.recent.length === 0) {
670 lines.push([{ text: fit(' 還沒有', columns), dim: true }])
671 }
672
673 for (const run of activity.recent) {
674 lines.push(toolLine(run, columns, { text: agoOf(now - run.at), dim: true }))
675 }
676
677 if (activity.helpers.length > 0) {
678 lines.push([{ text: fit(`幫手(${activity.helpers.length} 個在跑)`, columns), bold: true }])
679
680 for (const helper of activity.helpers) {
681 lines.push(
682 clipped([{ text: ' ' }, { text: helper.type, color: HELPER }, { text: ` ${helper.description}` }], columns),
683 )
684 }
685 }
686
687 return lines
688}
689
690// #region AI-NOTES
691// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
692// 2026-10-03 為了原生 Windows:記憶檔標籤與 detailOf 都經 tildeOf,比對家目錄前先把「\\」換成「/」;isPath 也認磁碟機代號。沒有 Windows 機器實測。
693// 2026-10-02 對齊不靠引擎的排版,自己用 widthOf 算格數補空白:中文字佔兩格,交給 flex 的 space-between 沒把握,
694// 自己補的話測試可以直接驗「每一行不超過 columns」。方塊字元(█░■)在 Unicode 屬於寬度不明的那一類,
695// 這裡當 1 格,依據是毛毛和字元雨用半格字在主人的終端機畫出來沒歪。
696// 2026-10-02 分類名字系統給英文(Messages、Memory files…),型別檔交代「分支要看 kind 不要看 name」,
697// 所以邏輯全部看 kind,name 只拿來查中文對照表,查不到就顯示原名。
698// 2026-10-02 總量用 context.tokens(上一次回應的實際輸入量),分類用 breakdown(估計值、對的是壓縮視窗),
699// 兩邊加起來不一定相等,型別檔有明講。長條與百分比的分母用分類自己的總和,才不會畫超過。
700// 2026-10-02 這個檔刻意不碰 $:靜態檢查不准把 $ 傳給別檔的函式。
701// #endregion
702