SLOPSHOPPER

ctx-panel

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

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-05jessetsai1024/claude-ctx-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ctx-panel
│ ┃ Context ✕ › fix the failing auth test and add an audit log call │ ┃ Context … │ ┃ 用了 97k / 200k … ⏺ Read(src/auth.ts) │ ┃ 自動壓縮沒開;空位還有 103k ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 分類 … ⎿ Added 2 lines, removed 1 line │ ┃ 還沒有分類資料 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 每一輪長多少(最近 1 輪) │ ┃ ▁ … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ 最佔地方的前幾名 ✻ Worked for 42s · done 4:20 PM │ ┃ 1: 記憶檔 2: MCP 3: Skill │ ┃ 沒有資料 › /ctx │ ┃ │ ┃ 上一輪的快取 │ ┃ 還沒有資料,要等下一次回應 │ ┃ 分類是估計值;/ctx full 算精確的 │ ┃ │ ┃ 現在在做什麼 │ ┃ ○ 閒著・上一輪花了 0:42 │ ┃ 最近用的工具 │ ┃ Bash cat .env … │ ┃ Bash rm -rf build && git push --force or… │ ┃ Bash git status --porcelain … │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context
Context 用了 97k / 200k 49% 自動壓縮沒開;空位還有 103k 分類 tokens % 還沒有分類資料 每一輪長多少(最近 1 輪) ▁ 上一輪 +0 最佔地方的前幾名 1: 記憶檔 2: MCP 3: Skill 沒有資料 上一輪的快取 還沒有資料,要等下一次回應 分類是估計值;/ctx full 算精確的 現在在做什麼 ○ 閒著・上一輪花了 0:42 最近用的工具 Bash cat .env 剛剛 Bash rm -rf build && git push --force origin ma… 剛剛 Bash git status --porcelain 剛剛
README

ctx-panel:側邊欄的 context 用量面板

一個 Claude Code 的 mod。這個對話的 context 視窗用了多少、分成哪幾類(系統提示、工具、記憶檔、對話…)、每一輪長了多少、最佔空間的是哪幾個檔案/MCP 工具/skill、上一輪的快取,最底下是 Claude 現在在做什麼。/ctx 開或關、/ctx full 算精確的(會打一次 token 計數 API)、/ctx files|mcp|skills 換「前幾名」列哪一種。

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-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,只拿掉捷徑。

注意

  • 不要同時用兩種方式載入同一個 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

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.

Source 2 files
hooks/register.ts 293 lines
1import 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
293
hooks/view.ts 702 lines
1import 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