側邊欄的 token 往來:主對話每次送給 Anthropic 多少 token、等多久、收到多少,最上面是合計。/tokens 開或關

一個 Claude Code 的 mod。側邊欄列出主對話每次送給模型多少 token、等了多久、收回多少、大約花多少美金;最上面是合計、總花費(跟 /cost 同一個數字)與快取比例。訂閱方案看到的是照 API 價錢換算的等值金額。/tokens 開或關。
English summary at the end.
側邊欄會在新視窗自己打開,但只有終端機夠寬時(系統規定:沒手動開過要 144 格以上,手動開過一次之後 110 格);不夠寬就等你打指令,不是壞掉。
用 marketplace(推薦)
claude plugin marketplace add jessetsai1024/claude-tokens
claude plugin install tokens@claude-tokens
然後在對話裡打 /reload-plugins,或重開 Claude Code。
或者 clone 下來接捷徑(之後 git pull 就是更新)
macOS/Linux:
git clone https://github.com/jessetsai1024/claude-tokens.git
cd claude-tokens && ./install.sh
Windows(原生版,在 PowerShell 裡):
git clone https://github.com/jessetsai1024/claude-tokens.git
cd claude-tokens
.\install.ps1
原理:放在 ~/.claude/skills/tokens/ 底下的 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 授權。
tokens is a mod for Claude Code: A pane with per-request token traffic: sent, waited, received, approximate cost; totals, session cost (same number as /cost) and cache ratio at the top. On a subscription the dollar figures are API-price equivalents. /tokens 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-tokens then claude plugin install tokens@claude-tokens; or clone and run ./install.sh (macOS/Linux) or .\install.ps1 (native Windows, junction, no admin), which links the repo into ~/.claude/skills/tokens 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 344 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { linesOf, sentOf } from './view'
4import type { Entry, Line, Money, Totals, Usage } from './view'
5
6const PANE = 'tokens'
7const TITLE = 'token'
8const REFRESH_MS = 1000
9// 最多記幾筆;更舊的從清單丟掉,合計照樣累加
10const MAX_ENTRIES = 200
11// 側邊欄縮在輸入框上面時拿不到真正的高度,用這個當作可用列數
12const INLINE_ROWS = 34
13
14/** mod 在記憶體裡記的全部東西;register 每次載入建一份新的。 */
15type State = {
16 entries: Entry[]
17 total: number
18 main: Totals
19 helper: Totals
20 /** 合計要顯示的總花費(美金,已扣掉 base);系統沒有記花費、或還沒讀到時是 null。 */
21 spent: number | null
22 /** 總花費從哪裡起算:/clear 當下讀到的系統總花費,系統自己歸零了就是 0。 */
23 base: number
24 /** 主對話每一筆的花費加起來,含已經從清單丟掉的舊筆。 */
25 mainCost: number
26}
27
28/** 側邊欄現在有沒有開著、看得到。 */
29async function isShown($: EngineInterface): Promise<boolean> {
30 return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown)
31}
32
33/** 跟系統要這段對話到目前的總花費(美金,跟 /cost 一樣);系統沒有記花費或問不到時回 null,不會丟錯。 */
34async function readUsd($: EngineInterface): Promise<number | null> {
35 try {
36 return (await $.session.usage()).cost?.usd ?? null
37 } catch {
38 return null
39 }
40}
41
42/** 重讀總花費、更新合計要顯示的金額並要求重畫;回傳讀到的原始值(null 是沒讀到)。 */
43async function refresh($: EngineInterface, state: State): Promise<number | null> {
44 const usd = await readUsd($)
45
46 if (usd !== null) {
47 // 比起點還小,代表系統自己把總花費歸零了(/clear),起點跟著歸零
48 if (usd < state.base) {
49 state.base = 0
50 }
51
52 state.spent = usd - state.base
53 $.ui.invalidate('ui.render')
54 }
55
56 return usd
57}
58
59/** /clear 之後把總花費的起點設成現在讀到的值,合計從 $0.00 開始。 */
60async function rebase($: EngineInterface, state: State): Promise<void> {
61 const usd = await readUsd($)
62
63 if (usd !== null) {
64 state.base = usd
65 state.spent = 0
66 $.ui.invalidate('ui.render')
67 }
68}
69
70/**
71 * 帳單到了、或那筆中斷之後,在背景補上金額:主對話那筆用「送出前」與「現在」的總花費相減;
72 * 幫手(entry 是 null)只更新合計。已經算過、或那筆已經被 /clear 清掉就不算。出錯就算了,不影響傳輸。
73 */
74async function bill($: EngineInterface, state: State, entry: Entry | null, before: Promise<number | null> | null): Promise<void> {
75 try {
76 const [from, to] = await Promise.all([before, refresh($, state)])
77
78 if (entry === null || from === null || to === null || entry.cost !== null || !state.entries.includes(entry)) {
79 return
80 }
81
82 entry.cost = Math.max(0, to - from)
83 state.mainCost += entry.cost
84 $.ui.invalidate('ui.render')
85 } catch {
86 // 算錢出錯只是少一個數字
87 }
88}
89
90/** 把一張帳單加進合計。 */
91function add(totals: Totals, usage: Usage): void {
92 totals.sent += sentOf(usage)
93 totals.received += usage.output_tokens
94}
95
96/**
97 * 【職責】把 token 往來面板接上 Claude Code:提供 /tokens,在側邊欄列出主對話每次送給 Anthropic 多少 token、
98 * 等了多久、收到多少 token、大約花多少美金,最上面是合計與總花費。只看每次回覆最後附的帳單(token 數)
99 * 和系統記的總花費(跟 /cost 同一個數字),回覆內容不看、不留;不改任何東西、不連網路、不寫檔。
100 * 【何時能呼叫】引擎載入這個 mod 時呼叫一次;重新載入會再呼叫,紀錄從空的開始。
101 * 【行為】有人在用的 session(不是 claude -p)一開始就自己打開側邊欄;終端機不夠寬時先等著,
102 * 寬度夠了才出現(主人自己開過的 110 格,沒開過的 144 格,這是系統的規定)。
103 * /tokens:側邊欄沒開就開、開著就關。/tokens close:關掉。/tokens 數字:用那個寬度(格數)開。
104 * 主對話每次送請求給模型就多一筆「等待中」;回覆收完、拿到帳單時補上送出與收到的數字並加進合計;
105 * 沒拿到帳單就結束(被打斷、出錯)的那筆標成中斷,不加進合計;主對話一輪結束時還在等的也一律標成中斷。
106 * 幫手的請求不列進清單,只把帳單加進「幫手另計」。最多留最近 200 筆,合計不受影響。
107 * 模型回覆的每一段都原封不動往下傳;記錄出錯時不影響傳輸。
108 * 錢:合計那一行是系統記的總花費(跟 /cost 一樣,含幫手與壓縮對話),session 一開始、每張帳單到了、每輪結束時重讀。
109 * 主對話每一筆的金額是送出前與帳單到了之後的總花費相減,所以那段時間如果有幫手在背景同時跑,幫手的錢會混進這一筆。
110 * 中斷的那筆照樣相減,有花到錢才顯示。總花費減掉清單每一筆(含丟掉的舊筆)的和,就是「其他」。
111 * 系統沒有記花費時(cost 欄位不存在)完全不顯示金額。訂閱方案看到的是照 API 價錢換算的等值金額。
112 * 有請求在等而且側邊欄看得到時,每 1 秒重畫一次。/clear 之後清單、合計都清空,總花費從 $0.00 重新算。
113 * 壓縮對話的摘要、其他 mod 用 $.model 自己發的請求不經過 turn.step,不會出現在清單和 token 合計裡;它們花的錢算在「其他」。
114 */
115export const register: Register = on => {
116 const state: State = {
117 entries: [],
118 total: 0,
119 main: { sent: 0, received: 0 },
120 helper: { sent: 0, received: 0 },
121 spent: null,
122 base: 0,
123 mainCost: 0,
124 }
125
126 on('session.start', async ($, e, next) => {
127 await $.command.register({
128 name: 'tokens',
129 description: '側邊欄的 token 往來:每次送出多少、等多久、收到多少;/tokens 開或關',
130 immediate: true,
131 })
132
133 // 一開 session 就自己打開;不是主人叫的,終端機要夠寬才放得出來,不夠寬就先等著,不用等它
134 if (e.isInteractive) {
135 void $.ui.open({ id: PANE, title: TITLE })
136 }
137
138 // 續接的對話或重新載入 mod 時,之前已經花的錢也要算進合計
139 void refresh($, state)
140
141 $.clock.every(REFRESH_MS, () => {
142 if (state.entries.some(entry => entry.status === 'waiting')) {
143 void isShown($).then(shown => {
144 if (shown) {
145 $.ui.invalidate('ui.render')
146 }
147 })
148 }
149 })
150
151 return next(e)
152 })
153
154 on('classic.SessionStart', { source: ['clear'] }, ($, e, next) => {
155 state.entries = []
156 state.total = 0
157 state.main = { sent: 0, received: 0 }
158 state.helper = { sent: 0, received: 0 }
159 state.mainCost = 0
160 state.spent = state.spent === null ? null : 0
161 void rebase($, state)
162 $.ui.invalidate('ui.render')
163
164 return next(e)
165 })
166
167 on('turn.step', async function* ($, e, next) {
168 const isHelper = e.agentId !== undefined
169 let entry: Entry | null = null
170 let usage: Usage | null = null
171 let before: Promise<number | null> | null = null
172
173 if (!isHelper) {
174 try {
175 entry = { sentAt: Date.now(), doneAt: null, status: 'waiting', sent: 0, cached: 0, received: 0, cost: null }
176 state.entries.push(entry)
177 state.total += 1
178
179 if (state.entries.length > MAX_ENTRIES) {
180 state.entries.splice(0, state.entries.length - MAX_ENTRIES)
181 }
182
183 $.ui.invalidate('ui.render')
184 // 送出前的總花費;帳單到了再讀一次,相減就是這一筆的錢
185 before = readUsd($)
186 } catch {
187 entry = null
188 }
189 }
190
191 const stream = next(e)
192
193 // 被打斷時外面不再讀,這個 generator 會從 yield 那裡直接結束,所以收尾放在 finally
194 try {
195 for await (const chunk of stream) {
196 try {
197 if (chunk.kind === 'stop' && chunk.usage !== null && usage === null) {
198 usage = chunk.usage
199 settle(state, entry, usage, isHelper)
200 void bill($, state, entry, before)
201 $.ui.invalidate('ui.render')
202 }
203 } catch {
204 // 記錄出錯不影響傳輸
205 }
206
207 yield chunk
208 }
209
210 const result = await stream.result
211
212 try {
213 if (usage === null && result.usage !== null) {
214 usage = result.usage
215 settle(state, entry, usage, isHelper)
216 void bill($, state, entry, before)
217 $.ui.invalidate('ui.render')
218 }
219 } catch {
220 // 記錄出錯不影響傳輸
221 }
222
223 // 明確交回底下那個請求的結果(不回傳也會沿用,這樣寫比較看得懂)
224 return result
225 } finally {
226 if (entry !== null && entry.status === 'waiting') {
227 entry.status = 'failed'
228 entry.doneAt = Date.now()
229 // 中斷前可能已經花了錢(例如模型寫到一半)
230 void bill($, state, entry, before)
231 $.ui.invalidate('ui.render')
232 }
233 }
234 })
235
236 // 一輪結束時還在等的,一定是沒拿到帳單就斷了;以防引擎沒有關掉上面那個串流、finally 沒跑到
237 on('turn.complete', ($, e, next) => {
238 if (e.agentId === undefined) {
239 const at = Date.now()
240
241 for (const entry of state.entries) {
242 if (entry.status === 'waiting') {
243 entry.status = 'failed'
244 entry.doneAt = at
245 }
246 }
247
248 void refresh($, state)
249 $.ui.invalidate('ui.render')
250 }
251
252 return next(e)
253 })
254
255 on('command.run', { command: 'tokens' }, async ($, e) => {
256 const arg = e.args.trim()
257 const wanted = Number.parseInt(arg, 10)
258 const isUp = (await $.ui.panes()).some(pane => pane.id === PANE)
259
260 if (arg === 'close' || (arg === '' && isUp)) {
261 await $.ui.close({ id: PANE })
262
263 return {}
264 }
265
266 const opened = await $.ui.open(
267 Number.isInteger(wanted) && wanted > 0 ? { id: PANE, title: TITLE, columns: wanted } : { id: PANE, title: TITLE },
268 )
269
270 if (!opened.isPlaced) {
271 return { text: `側邊欄沒有被放出來:${opened.reason}` }
272 }
273
274 // 重新打開時引擎可能直接拿上次畫好的結果來用,所以自己要求重畫
275 $.ui.invalidate('ui.render')
276
277 return {}
278 })
279
280 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
281 const { Box, Text } = $.ui.resolve(e)
282 const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : INLINE_ROWS
283 const money: Money | undefined =
284 state.spent === null ? undefined : { spent: state.spent, other: Math.max(0, state.spent - state.mainCost) }
285 const lineOf = (segments: Line) =>
286 Text({
287 wrap: 'truncate-end',
288 children:
289 segments.length === 0
290 ? [' ']
291 : segments
292 .filter(segment => segment.text !== '')
293 .map(segment =>
294 Text({
295 ...(segment.color === undefined ? {} : { color: segment.color }),
296 ...(segment.bold === true ? { bold: true } : {}),
297 ...(segment.dim === true ? { dimColor: true } : {}),
298 children: [segment.text],
299 }),
300 ),
301 })
302
303 return Box({
304 flexDirection: 'column',
305 children: linesOf(state.entries, state.total, state.main, state.helper, Date.now(), e.props.bodyColumns, rows, money).map(lineOf),
306 })
307 })
308}
309
310/** 拿到帳單:主對話的那筆補上數字並標成收完,加進對應的合計。 */
311function settle(state: State, entry: Entry | null, usage: Usage, isHelper: boolean): void {
312 if (isHelper) {
313 add(state.helper, usage)
314
315 return
316 }
317
318 add(state.main, usage)
319
320 if (entry !== null) {
321 entry.status = 'done'
322 entry.doneAt = Date.now()
323 entry.sent = sentOf(usage)
324 entry.cached = usage.cache_read_input_tokens
325 entry.received = usage.output_tokens
326 }
327}
328
329// #region AI-NOTES
330// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
331// 2026-10-03 帳單(usage)只在回覆最後的 stop 段和 stream.result 上,送出當下拿不到,所以送出的數字收完才補。
332// 先看 stop 段(收到的時間最準),沒有才看 result;兩邊都有時只算一次。
333// 2026-10-03 收尾一定要放 finally:主人按 Esc 時外層不再讀這個 generator,for await 後面的程式不會跑,
334// 不放 finally 那筆會永遠「等待中」、每秒重畫。真引擎按 Esc 時會不會關掉 generator 沒驗過,
335// 所以 turn.complete 再補一道(兩邊都只動 waiting 的,重複跑沒關係)。
336// 2026-10-03 turn.step 的串流寫法(async function*、原樣 yield、測試用 stream.next() 讀到 done)見 timeline 的 AI-NOTES。
337// 2026-10-03 時間用 Date.now() 不用 $.clock.now():理由同 timeline(每段都問引擎一趟會拖慢傳輸);測試只驗數字不驗時間。
338// 2026-10-04 錢用「送出前後的系統總花費相減」,不用價目表:帳單只有 cache_creation 總數,不分 5 分鐘或 1 小時快取,
339// 兩種價錢差快一倍(claude -p 實測 haiku 寫 40,000 快取=$0.08,等於輸入價的 2 倍,是 1 小時快取的價),價目表也要跟著調價改。
340// 2026-10-04 claude -p 實測(2.1.289):stop 段到的時候 $.session.usage().cost 已經含這一筆,所以 bill 不必等 result。
341// /clear 會不會把系統總花費歸零沒驗過,refresh 與 rebase 兩種情況都接得住;背景幫手混進差額也沒在真機量過。
342// 2026-10-03 一個 step 裡伺服器端工具連跑好幾次時,帳單是加總的(型別檔 ModelUsage 的說明),所以「送出」會比單次大。未在真機遇過。
343// #endregion
344hooks/view.ts 331 lines1/** 一行裡的一小段字,帶自己的顏色與粗細。 */
2export type Segment = {
3 /** 要顯示的字。 */
4 text: string
5 /** 顏色,`#rrggbb`;沒給就是終端機預設字色。 */
6 color?: string
7 /** 粗體。 */
8 bold?: boolean
9 /** 暗一階,給次要資訊用。 */
10 dim?: boolean
11}
12
13/** 畫面上的一行:由左到右排的幾段字;空陣列是空白行。 */
14export type Line = Segment[]
15
16/** 一次送給 Anthropic 的請求(只記主對話的)。時間都是毫秒(Date.now() 的值)。 */
17export type Entry = {
18 /** 送出的時間。 */
19 sentAt: number
20 /** 收完或中斷的時間;還在等是 null。 */
21 doneAt: number | null
22 /** 還在等、收完了、還是中斷了(被打斷或出錯,沒拿到帳單)。 */
23 status: 'waiting' | 'done' | 'failed'
24 /** 整包送出的 token 數(沒快取的+快取讀到的+寫進快取的);收完才知道,之前是 0。 */
25 sent: number
26 /** 送出的量裡面,Anthropic 已經記住、從快取讀的部分。 */
27 cached: number
28 /** 收到的 token 數(模型想的部分也算在裡面)。 */
29 received: number
30 /** 這一筆大約花了幾美金(送出前後系統總花費的差);還不知道、或系統沒有記花費時是 null。 */
31 cost: number | null
32}
33
34/** 送出與收到的累計。 */
35export type Totals = {
36 /** 送出的 token 數。 */
37 sent: number
38 /** 收到的 token 數。 */
39 received: number
40}
41
42/** 花費的兩個數字,美金。 */
43export type Money = {
44 /** 這段對話到目前的總花費,跟 /cost 一樣(/clear 之後從 0 算起)。 */
45 spent: number
46 /** 總花費裡不是主對話的請求花的:幫手、壓縮對話、其他 mod 自己發的請求。從清單丟掉的舊筆不算在這裡。 */
47 other: number
48}
49
50/** API 帳單上的四個數字,欄位名稱照 API 的寫法。 */
51export type Usage = {
52 /** 沒用到快取的輸入。 */
53 input_tokens: number
54 /** 模型產生的輸出。 */
55 output_tokens: number
56 /** 從快取讀到的輸入。 */
57 cache_read_input_tokens: number
58 /** 這次寫進快取的輸入。 */
59 cache_creation_input_tokens: number
60}
61
62const SENT_COLOR = '#5fafd7'
63const RECEIVED_COLOR = '#87d787'
64const WAITING_COLOR = '#d7af5f'
65const FAILED_COLOR = '#d75f5f'
66// 時間欄的寬度(「10:42:03」加兩格空白),等待那一行用同樣寬的空白對齊
67const TIME_COLUMN = 10
68// 一筆佔的列數:送出、等待、收到,加一行空白
69const ROWS_PER_ENTRY = 4
70
71/**
72 * 【行為】一段字在終端機佔幾格寬:中日韓文字、全形標點、表情符號算 2 格,其他算 1 格。
73 * 跟 ctx-panel、files、timeline 的同名函式一樣。
74 */
75export function widthOf(text: string): number {
76 let width = 0
77
78 for (const glyph of text) {
79 const code = glyph.codePointAt(0) ?? 0
80 const isWide =
81 (code >= 0x1100 && code <= 0x115f) ||
82 (code >= 0x2e80 && code <= 0xa4cf) ||
83 (code >= 0xac00 && code <= 0xd7a3) ||
84 (code >= 0xf900 && code <= 0xfaff) ||
85 (code >= 0xfe30 && code <= 0xfe4f) ||
86 (code >= 0xff00 && code <= 0xff60) ||
87 (code >= 0xffe0 && code <= 0xffe6) ||
88 (code >= 0x1f300 && code <= 0x1faff) ||
89 (code >= 0x20000 && code <= 0x3fffd)
90 width += isWide ? 2 : 1
91 }
92
93 return width
94}
95
96/** 【行為】把一段字裁到最多 max 格寬,超過時留開頭、結尾補「…」;放得下就原樣回傳,max 小於 1 回空字串。 */
97export function fit(text: string, max: number): string {
98 if (widthOf(text) <= max) {
99 return text
100 }
101
102 if (max < 1) {
103 return ''
104 }
105
106 let kept = ''
107
108 for (const glyph of text) {
109 if (widthOf(kept + glyph) > max - 1) {
110 break
111 }
112
113 kept += glyph
114 }
115
116 return `${kept}…`
117}
118
119/** 【行為】整數加千分位逗號:1284530 → 「1,284,530」。小數無條件捨去,負數當 0。 */
120export function countOf(n: number): string {
121 return `${Math.max(0, Math.floor(n))}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
122}
123
124/** 【行為】美金寫成「$0.031」:小數點後 digits 位(四捨五入),負數當 0。 */
125export function usdOf(n: number, digits: number): string {
126 return `$${Math.max(0, n).toFixed(digits)}`
127}
128
129/** 【行為】把毫秒時間戳寫成本機時區的「時:分:秒」,24 小時制、兩位數補零,例如「09:05:03」。 */
130export function timeOf(at: number): string {
131 const date = new Date(at)
132 const two = (n: number) => `${n}`.padStart(2, '0')
133
134 return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`
135}
136
137/**
138 * 【行為】把等了多久寫成白話:不到 60 秒是「8.2 秒」(一位小數,無條件捨去),60 秒以上是「1 分 05 秒」。
139 * isLive 為 true(還在等、每秒跳)時不寫小數:「3 秒」。負數當 0。
140 */
141export function durationOf(ms: number, isLive = false): string {
142 const safe = Math.max(0, ms)
143 const seconds = Math.floor(safe / 1000)
144
145 if (seconds >= 60) {
146 return `${Math.floor(seconds / 60)} 分 ${`${seconds % 60}`.padStart(2, '0')} 秒`
147 }
148
149 return isLive ? `${seconds} 秒` : `${(Math.floor(safe / 100) / 10).toFixed(1)} 秒`
150}
151
152/** 【行為】帳單上整包送出的量:沒快取的+快取讀到的+寫進快取的。 */
153export function sentOf(usage: Usage): number {
154 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
155}
156
157/** 寬度不一定的字,左邊補空白補到 width 格寬。 */
158function padStartWidth(text: string, width: number): string {
159 return ' '.repeat(Math.max(0, width - widthOf(text))) + text
160}
161
162/** 幾段字由左到右排,超過 columns 就從超出的那段開始裁;有 right 就靠右放,中間補空白。 */
163function line(left: Segment[], right: Segment | undefined, columns: number): Line {
164 const room = right === undefined ? columns : Math.max(0, columns - widthOf(right.text) - 1)
165 const kept: Segment[] = []
166 let used = 0
167
168 for (const segment of left) {
169 if (room - used <= 0) {
170 break
171 }
172
173 const text = fit(segment.text, room - used)
174
175 if (text !== '') {
176 kept.push({ ...segment, text })
177 used += widthOf(text)
178 }
179 }
180
181 if (right === undefined || room <= 0) {
182 return kept
183 }
184
185 return [...kept, { text: ' '.repeat(Math.max(1, columns - used - widthOf(right.text))) }, right]
186}
187
188/** 一筆請求畫成的幾行(不含後面的空白行)。 */
189function entryLines(entry: Entry, now: number, columns: number, isCostShown: boolean): Line[] {
190 const indent = ' '.repeat(TIME_COLUMN)
191 const lines: Line[] = []
192 const sentText = entry.status === 'done' ? padStartWidth(countOf(entry.sent), 10) : `${' '.repeat(5)}…`
193 const share = entry.status === 'done' && entry.sent > 0 ? ` 快取 ${Math.round((entry.cached / entry.sent) * 100)}%` : ''
194
195 lines.push(
196 line(
197 [
198 { text: `${timeOf(entry.sentAt)} ` },
199 { text: '↑ 送出', color: SENT_COLOR, bold: true },
200 { text: sentText },
201 { text: share, dim: true },
202 ],
203 undefined,
204 columns,
205 ),
206 )
207
208 if (entry.doneAt === null) {
209 lines.push(
210 line([{ text: indent }, { text: `⋯ 等待回應中… ${durationOf(now - entry.sentAt, true)}`, color: WAITING_COLOR }], undefined, columns),
211 )
212
213 return lines
214 }
215
216 lines.push(line([{ text: indent }, { text: `⋯ 等待回應 ${durationOf(entry.doneAt - entry.sentAt)}`, dim: true }], undefined, columns))
217
218 // 中斷的那筆只有真的花了錢才寫金額
219 const cost: Segment | undefined =
220 !isCostShown || entry.cost === null || (entry.status === 'failed' && entry.cost <= 0)
221 ? undefined
222 : { text: entry.cost < 0.0005 ? '不到 $0.001' : `約 ${usdOf(entry.cost, 3)}`, dim: true }
223
224 if (entry.status === 'done') {
225 lines.push(
226 line(
227 [
228 { text: `${timeOf(entry.doneAt)} ` },
229 { text: '↓ 收到', color: RECEIVED_COLOR, bold: true },
230 { text: padStartWidth(countOf(entry.received), 10) },
231 ],
232 cost,
233 columns,
234 ),
235 )
236 } else {
237 lines.push(line([{ text: `${timeOf(entry.doneAt)} ` }, { text: '✕ 中斷,沒收到帳單', color: FAILED_COLOR }], cost, columns))
238 }
239
240 return lines
241}
242
243/**
244 * 【何時能呼叫】columns 至少 40 才排得好看;更窄也不會壞,只是字會被裁掉。rows 是側邊欄可用的列數。
245 * 【行為】把 token 往來排成側邊欄的每一行,每一行都不超過 columns 格寬。
246 * 最上面是標題(右邊是「最近幾筆,共幾筆」)、主對話送出與收到的合計;然後一條分隔線。
247 * 有給 money 時,合計那一行靠右寫總花費(小數兩位),沒給就不寫任何金額。
248 * helper 有任何量、或 money.other 至少 0.005 美金時,合計下面多一行:左邊「幫手另計」的 token 數(helper 有量才寫),
249 * 右邊「其他約 $0.12」(other 夠大才寫)。
250 * entries 是空的時候寫「還沒有送出任何請求」。否則照時間先後列最後幾筆(最新的在最下面),
251 * 放得下幾筆看 rows 扣掉上面幾行還剩多少,最少 1 筆;每筆是送出、等待、收到三行,筆跟筆之間空一行。
252 * 還在等的那筆:送出的數字是「…」、等待那行寫「等待回應中… N 秒」,沒有收到那行。
253 * 中斷的那筆:送出的數字是「…」、收到那行改成「✕ 中斷,沒收到帳單」。
254 * 每一筆的 cost 不是 null 時,收到那行靠右寫「約 $0.031」(小數三位),不到 0.0005 寫「不到 $0.001」;
255 * 中斷的那筆只有 cost 大於 0 才寫。
256 * 欄寬不夠時先裁左邊的字,靠右的金額保留。
257 * total 是全部記過的筆數(含已經從 entries 丟掉的舊筆)。now 是現在的時間,毫秒。
258 */
259export function linesOf(
260 entries: readonly Entry[],
261 total: number,
262 main: Totals,
263 helper: Totals,
264 now: number,
265 columns: number,
266 rows: number,
267 money?: Money,
268): Line[] {
269 const isHelperShown = helper.sent > 0 || helper.received > 0
270 // 不到半美分寫出來會是「$0.00」,不如不寫
271 const isOtherShown = money !== undefined && money.other >= 0.005
272 const headerRows = isHelperShown || isOtherShown ? 4 : 3
273 // 最後一筆後面不用空白行,所以多算 1 列
274 const room = Math.max(1, Math.floor((rows - headerRows + 1) / ROWS_PER_ENTRY))
275 const shown = entries.slice(-room)
276 const lines: Line[] = [
277 line(
278 [{ text: 'Token 往來', bold: true }],
279 entries.length === 0 ? undefined : { text: `最近 ${shown.length} 筆,共 ${total} 筆`, dim: true },
280 columns,
281 ),
282 line(
283 [
284 { text: '合計 ' },
285 { text: '↑ 送出 ', color: SENT_COLOR },
286 { text: `${countOf(main.sent)} ` },
287 { text: '↓ 收到 ', color: RECEIVED_COLOR },
288 { text: countOf(main.received) },
289 ],
290 money === undefined ? undefined : { text: usdOf(money.spent, 2), bold: true },
291 columns,
292 ),
293 ]
294
295 if (isHelperShown || isOtherShown) {
296 lines.push(
297 line(
298 isHelperShown ? [{ text: `(幫手另計 ↑ ${countOf(helper.sent)} ↓ ${countOf(helper.received)})`, dim: true }] : [],
299 isOtherShown ? { text: `其他約 ${usdOf(money.other, 2)}`, dim: true } : undefined,
300 columns,
301 ),
302 )
303 }
304
305 lines.push([{ text: '─'.repeat(Math.max(0, columns)), dim: true }])
306
307 if (entries.length === 0) {
308 lines.push([{ text: fit('還沒有送出任何請求', columns), dim: true }])
309
310 return lines
311 }
312
313 shown.forEach((entry, at) => {
314 if (at > 0) {
315 lines.push([])
316 }
317
318 lines.push(...entryLines(entry, now, columns, money !== undefined))
319 })
320
321 return lines
322}
323
324// #region AI-NOTES
325// AI-NOTES:agent 專用備忘。當時為真、非契約、非指令;改到相關程式碼時重驗,錯了就刪。
326// 2026-10-03 widthOf、fit 從 timeline 複製;mod 之間不能互相 import。
327// 2026-10-03 「送出」= input + cache_read + cache_creation,是整包送過去的量;只有 input 那部分是沒快取的。
328// 主人看的是「送了多少」不是「付了多少」,所以不分開列,只用「快取 N%」標出已經記住的比例;錢另外用 Money 顯示。
329// 2026-10-03 符號只用 ↑ ↓ ⋯ ✕ ─:沙漏 ⏳ 在終端機佔 2 格,但 widthOf 會算成 1 格,對不齊,所以等待中只換顏色不換符號。
330// #endregion
331