SLOPSHOPPER

tokens

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

newpanecommandtimer
v0.1.0MITupdated 2026-10-05jessetsai1024/claude-tokens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokens
│ ┃ tokens ✕ › fix the failing auth test and add an audit log call │ ┃ Token 往來 │ ┃ 合計 ↑ 送出 0 ↓ 收到 0 … ⏺ Read(src/auth.ts) │ ┃ … ⎿ Read 6 lines │ ┃ ───────────────────────────────────────────… ⏺ Update(src/auth.ts) │ ┃ 還沒有送出任何請求 ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /tokens │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tokens
Token 往來 合計 ↑ 送出 0 ↓ 收到 0 $0.42 其他約 $0.42 ──────────────────────────────────────────────────────── 還沒有送出任何請求
README

tokens:token 往來

一個 Claude Code 的 mod。側邊欄列出主對話每次送給模型多少 token、等了多久、收回多少、大約花多少美金;最上面是合計、總花費(跟 /cost 同一個數字)與快取比例。訂閱方案看到的是照 API 價錢換算的等值金額。/tokens 開或關。

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

注意

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

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.

Source 2 files
hooks/register.ts 344 lines
1import 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
344
hooks/view.ts 331 lines
1/** 一行裡的一小段字,帶自己的顏色與粗細。 */
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