SLOPSHOPPER

usage-mod

在输入框上方常驻显示上下文占用、5 小时额度、每周额度和重置时间,带一键压缩上下文的按钮

newbandtoasttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-mod
› fix the failing auth test and add an audit log call ⏺ 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 🗜 Context 49% · 97k / 200k Context 49% · 97k / 200k 5-hour 31% · · 02:53 5-hour 31% · · 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🗜 Context 49% · 97k / 200k Context 49% · 97k / 200k 5-hour 31% · · 02:53 5
README

usage-mod

English | 简体中文 | 繁體中文 | 日本語

A small mod for Claude Code. It shows three usage figures in a row above the prompt, adds a one-click button to compact the context, and can expand a line chart of the token usage of every reply.

usage-mod screenshot: three usage bars above the prompt, with an icon button at each end

Left to right: the compact button, context usage, the 5-hour limit, the weekly limit, and the details button. Click the icon on the far right to expand the token chart above the row (see "Token chart" below); click again to collapse it.

Features

  • Context usage: percent used and the token count.
  • 5-hour limit: percent used, time until reset, and the exact reset time.
  • Weekly limit: the same.
  • One-click compact: the line icon on the far left (four arrows pointing inward). One click compacts the context right away, the same as typing /compact. A tooltip appears on hover.
  • Token details: the chart icon on the far right. One click expands a line chart of the token usage of every reply above the row; click again to collapse. See "Token chart" below.
  • Three equal-width blocks. The orange (#D77757) fill shows how much is used; the color is fixed and does not change with usage.
  • A toast appears once when a figure reaches 90%.
  • Figures refresh every 5 seconds, so you don't have to wait for a reply to finish.

Token chart

Click the chart icon on the far right of the row to expand it, and again to collapse it.

usage-mod screenshot: the expanded token chart

From top to bottom the expanded panel has:

  • First row: a title, five totals and the number of turns. The totals add up every reply the mod has recorded in this session:
  • Fresh input: input tokens that were sent to the model fresh, not served from the cache.
  • Output: tokens the model generated.
  • Cache write: tokens newly written to the prompt cache.
  • Cache read: tokens read from the prompt cache, which is much cheaper than fresh input.
  • Cache hit rate: cache read ÷ (fresh input + cache write + cache read).
  • Second row: a toggle for each of the four lines; click a name in the legend to show or hide that line. Cache read is usually orders of magnitude larger than the others, which flattens them against the axis; hide it and the rest become readable.
  • The chart: one point per reply, in time order (the earliest and latest times are labelled at the bottom corners), with token counts on the vertical axis. It keeps the last 50 turns.
  • Hover a turn: its column lights up and a semi-transparent card appears beside it with the start time → end time, how long it took, the model, and the four figures for that turn. Replies from sub-agents are marked after the model name.

Things to know:

  • The data comes from the current session only, and only from replies after the mod was loaded. It starts over after a restart or /reload-plugins, and is not saved to a file.
  • "Took" is wall-clock time, including any time it sat waiting for you: step away for an hour and that turn shows over an hour. The start time is worked out as "end time − duration".
  • The chart exists only where an image can be drawn (the Desktop app). In the terminal the expand button only shows the totals, with no chart.
  • The card jumps from column to column; it does not follow the pointer smoothly. These charts cannot run scripts, so the mod never learns the exact pointer position.
  • The chart may flicker now and then while it is open: a chart that reacts to hover has to be drawn in its own small frame, and that frame is rebuilt every time the row redraws (for example while figures change during a reply). To flicker less, the once-a-minute countdown refresh is paused while the panel is open and catches up when you close it.
  • When the window is too narrow for the first row, things give way in order: the title, then the turn count moves to the second row, then the cache hit rate and fresh input are dropped from the totals. Text lengths differ a lot between the four languages, so this is worked out from the actual text width in the current language.
  • It does not show "what percent of the 5-hour limit each turn used": the engine only reports whole percents, and the limit is shared by the whole account (other sessions and the web app count too), so subtracting between turns would be meaningless.
  • The line icons on the buttons are from Lucide (ISC license).

Language

Simplified Chinese (zh), Traditional Chinese (zh-TW), English (en) and Japanese (ja).

By default (auto) it follows the computer's language: Simplified Chinese on a Simplified Chinese system, Traditional Chinese (Taiwan, Hong Kong, Macau) on a Traditional one, Japanese on a Japanese one, and English for everything else.

To pin one language, run /plugin in Claude Code, find usage-mod and change Language / 语言. Or set it in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "usage-mod": { "options": { "language": "en" } }
  }
}

The mod reloads itself after the change. auto, no setting, or an unknown language code all follow the computer's language. (When installed from a marketplace, use the key name that /plugin shows for pluginConfigs; changing it in /plugin directly is the safest way.)

To add another language, open hooks/i18n.ts, copy a dictionary and translate it, register it in DICTS, then add the language code to userConfig.language.options in .claude-plugin/plugin.json.

Requirements

Mods need Claude Code 2.1.287 or later, where they are on by default. Check with claude --version.

This mod was developed on 2.1.286. On 2.1.286 and earlier you also need the environment variable CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (see "Load it manually" below). From 2.1.287 on, that variable is ignored and not needed.

Install

Option 1: from the marketplace

Run these commands in a terminal, not by typing them into the Claude Code chat box. Any terminal works: PowerShell or Windows Terminal (Terminal on macOS / Linux), or the Terminal panel beside the conversation in the Desktop app.

claude plugin marketplace add qingyashizi/claude-usage-mod
claude plugin install usage-mod@usage-mod
  • First line: tells Claude Code to register the GitHub repository qingyashizi/claude-usage-mod (the format is user/repo) as a "plugin marketplace", a list it can pick plugins from. This only registers it; nothing is installed yet.
  • Second line: installs the plugin from the marketplace you just added. usage-mod@usage-mod has the form plugin@marketplace; the two names happen to be the same here: the first is the plugin, the second is the marketplace.

In a session that is already open, run /reload-plugins to load it; otherwise it loads the next time you start Claude Code.

Option 2: load it manually

Put the whole usage-mod folder somewhere you like, for example ~/.claude/mods/usage-mod.

Try it for one run (only for that launch):

claude --plugin-dir ~/.claude/mods/usage-mod

Keep it permanently: add an env block to ~/.claude/settings.json with your own path. New sessions pick it up.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:\\Users\\your-name\\.claude\\mods\\usage-mod"
  }
}

On 2.1.286 and earlier, also add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" to env. To reload automatically when you edit the code, also add "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1".

See what it does before you install it

A mod is code that runs inside Claude Code with your permissions, and it is not sandboxed. Before installing, list which events it handles and what it asks Claude Code to do:

claude plugin validate ./usage-mod

The hooks: and calls: lines in the output are the answer. This mod uses: reading usage ($.session.usage), noting the usage of each reply when it finishes (the turn.complete event; read-only, kept in memory only), running the /compact command to compact the context ($.command.run), reading and writing cache/limits.json inside its own folder ($.fs.read / $.fs.write), toasts ($.ui.toast) and timers ($.clock). It makes no network requests and reads no other files.

Where it runs

  • claude in a terminal: the row and the compact button show; the token chart does not (images cannot be drawn in a terminal), and the expand button falls back to a text symbol that opens only the totals.
  • The Code tab of the Desktop app: the row, both buttons and the chart all show (plugins are not available in WSL sessions).
  • The VS Code extension, claude -p, and cloud sessions: the mod runs, but the row is not drawn.

Customize

Colors, icons and the chart style are constants at the top of hooks/register.tsx; all displayed text (including the hover tooltip, tip) is in hooks/i18n.ts:

ConstantWhat it does
ORANGEFill color of the used part
WARMBackground color of the unused part
DARK / LIGHTText color inside / outside the fill
ICON_PATHSThe two buttons' line icons (Lucide shape data); swap the shapes to change an icon
ICON_BG / ICON_LINEIcon background (must match the row's background) and line color
ICONThe text symbol the compact button uses where no image can be drawn (the terminal)
SERIESColors of the chart's four lines
CARD / GRID / MUTEDThe chart's background, grid lines and axis text
TIP_BGBackground of the hover details card
MAX_TURNSHow many turns to keep, 50 by default
WARNPercentage that triggers the warning toast, 90 by default

Known limitations

  • The mods API is in early access and changes between Claude Code versions. This mod was developed and tested on 2.1.286; other versions may fail to load it or draw it differently.
  • Switching to a session that has not been opened since the app started, the row takes a few seconds to appear. Measured in the Desktop app on Claude Code 2.1.289 (about ten runs): 2.5 to 5.5 seconds, around 4 on average; going back to a session that is already open is instant. Almost all of that time is spent inside the engine, which a mod cannot affect: about 1 second from the mod loading until its start hook runs, then about 1.7 seconds from the hook finishing until the row is first drawn. The mod's own setup is a fraction of a second. Making that setup non-blocking changed nothing measurable (4.3 s vs 4.0 s on average, within the run-to-run spread).
  • The 5-hour and weekly figures only have real values after this process has received one reply. A new session first shows the figures saved last time (in cache/limits.json inside the mod folder); any whose reset time has passed are dropped and shown as "—". This follows from how the engine supplies the data.
  • The padding above and below the row belongs to the app, and a mod cannot change it.
  • In the Desktop app the hover tooltip is drawn by the app as a dark card, so its text is light; in the terminal it is dark text on orange.
  • When the window is narrower than 78 columns, the row falls back to one short line, with neither button, so the chart cannot be expanded.
  • The chart width is estimated from the number of columns in the row (an image only accepts a width in pixels, and the engine does not tell a mod how many pixels the row has). Too large and the app shrinks the whole image, which shows a strip of background; too small and the side margins grow. At other font sizes you may need to adjust CHART_PX_PER_CELL.
  • Compacting replaces the earlier conversation with a summary, so detail is lost. A click runs it immediately, with no confirmation step.
  • Compacting waits for the model to summarize the conversation, which takes a while when the context is large. Measured: about 63 seconds at roughly 410k tokens. A click first shows "Compacting context…", then "Context compacted" when done; there is no progress display in between, and clicking again does not help.
  • Sessions in the Desktop app are SDK (headless) sessions, where the engine does not let a mod call the compaction API ($.session.compact) directly, so the button runs the /compact command for you instead ($.command.run). This was only verified in the Desktop app; it has not been tried in the terminal.
  • Clicking while Claude is replying: per the engine docs the command is queued and runs once the turn ends. This has not been verified here.
  • The author has only tested loading with --plugin-dir and CLAUDE_CODE_PLUGIN_DIRS. Installing from a marketplace is written from the official documentation and has not been tried first-hand.

Uninstall

Installed from the marketplace (also run in a terminal):

claude plugin uninstall usage-mod@usage-mod

Run /reload-plugins in an open session, or it takes effect on the next start. You can also run /plugin inside Claude Code and manage usage-mod from the list. To switch it off for now and keep it for later, use claude plugin disable usage-mod@usage-mod (and enable to bring it back).

To remove the marketplace you added as well:

claude plugin marketplace remove usage-mod

Loaded manually: remove the CLAUDE_CODE_PLUGIN_DIRS entry from settings.json (or change the path) and delete the folder.

If you changed the language, settings.json may still hold a usage-mod entry under pluginConfigs. It does no harm; delete it by hand if you want a clean file.

License

MIT. See LICENSE.

Source 3 files
hooks/register.tsx 596 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit, Stats, TurnRecord, Usage } from '../types'
5import { pickDict } from './i18n'
6import type { Dict } from './i18n'
7
8const usage = atom({ plugin: 'usage-mod', key: 'usage' } as const, { limits: [] } as Usage)
9const now = atom({ plugin: 'usage-mod', key: 'now' } as const, 0)
10// 本会话每轮回复的 token 用量:合计一直累加,明细只留最近 MAX_TURNS 轮。
11const stats = atom({ plugin: 'usage-mod', key: 'stats' } as const, {
12  total: { turns: 0, input: 0, output: 0, cacheRead: 0, cacheCreate: 0 },
13  recent: [],
14} as Stats)
15
16// 折线图里被用户点掉的曲线,以及折线图当前是不是展开着。
17const hiddenSeries = atom({ plugin: 'usage-mod', key: 'hidden' } as const, [] as string[])
18const statsOpen = atom({ plugin: 'usage-mod', key: 'open' } as const, false)
19
20const MAX_TURNS = 50
21
22// 5 小时/每周窗口的用量是账号级的,存到 mod 自己文件夹下的文件里,同一份安装的所有会话共用。
23const cachePath = ($: EngineInterface) => `${$.plugin.root}/cache/limits.json`
24
25const WARN = 90
26const warned = new Set<string>()
27
28const ORANGE = '#D77757'
29const WARM = '#3f3f46'
30const DARK = '#161616'
31const LIGHT = '#ececec'
32
33// 压缩按钮:点一下直接压缩。明细按钮:点一下在横条上方展开 / 收起 token 折线图。
34// 两个都画成线条图标;ICON 是没有 Svg 元素的界面(终端)里的替代符号。
35const ICON = '🗜'
36const BUTTON_CELLS = 3
37/**
38 * 两个按钮的图标:Lucide 的 fold-vertical(压缩)和 chart-line(展开明细),
39 * ISC 许可,Copyright (c) Lucide Contributors,https://lucide.dev。单色细线条,和 Claude 界面自己的图标同一种风格。
40 * 画成一小块方形的图,底色和横条的背景一样(应用把 SVG 当不透明图片画,不画底就是白的);按钮处于"开"的状态时底色和线条都亮一点。
41 */
42const ICON_BG = '#212121'
43const ICON_BG_ON = '#3a3a3a'
44const ICON_LINE = '#a3a3a3'
45const ICON_LINE_ON = '#ececec'
46const ICON_PATHS = {
47  compact: '<path d="M12 22v-6"/><path d="M12 8V2"/><path d="M4 12H2"/><path d="M10 12H8"/><path d="M16 12h-2"/><path d="M22 12h-2"/><path d="m15 19-3-3-3 3"/><path d="m15 5-3 3-3-3"/>',
48  stats: '<path d="M3 3v16a2 2 0 0 0 2 2h16"/><path d="m19 9-5 5-4-4-3 3"/>',
49}
50const lineIconSvg = (paths: string, isOn: boolean) =>
51  `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="22" viewBox="0 0 24 22"><rect width="24" height="22" fill="${isOn ? ICON_BG_ON : ICON_BG}"/><g transform="translate(3 2) scale(.75)" fill="none" stroke="${isOn ? ICON_LINE_ON : ICON_LINE}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</g></svg>`
52
53const pad = (n: number) => String(n).padStart(2, '0')
54
55/** token 数的短写法:834、35.5k、1.2M;和上下文那块用的 tokensText 不同,小数字不取整到 k。 */
56const countText = (n: number) => {
57  if (n >= 1000000) return `${Number((n / 1000000).toFixed(1))}M`
58  if (n >= 1000) return `${Number((n / 1000).toFixed(1))}k`
59
60  return String(n)
61}
62
63/** 缓存命中率:命中的输入占全部输入(新增、缓存创建、缓存命中三项之和)的比例。 */
64const hitRate = (input: number, cacheRead: number, cacheCreate: number) => {
65  const all = input + cacheRead + cacheCreate
66
67  return all === 0 ? '—' : `${((cacheRead / all) * 100).toFixed(1)}%`
68}
69
70const modelName = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
71
72/** 折线图的四条曲线,颜色和 CC Switch 一致:缓存命中紫、缓存创建橙、新增输入蓝、输出绿。 */
73type SeriesKey = 'input' | 'output' | 'cacheCreate' | 'cacheRead'
74/** 折线图画成自带底色的深色卡片:应用把 SVG 当不透明图片画,不画底就是一块白纸。底色取应用窗口的背景色 rgb(33,33,33),网格线和文字也用不偏色的中性灰,不然会偏蓝。 */
75const CARD = '#212121'
76const GRID = '#3a3a3a'
77const MUTED = '#a3a3a3'
78
79/**
80 * 折线图的画布宽度和最大高度(CSS 像素)。Svg 元素要显式给宽高,不然应用自己挑,会缩得很小。
81 * 宽度跟着横条的格数走(Svg 只收像素,量不了横条的实际像素宽):一格按 7.6 像素算。设得比横条实际能放的宽,应用会把整张图等比缩小,
82 * 图就比框矮一截,上下露出白底(实测一格按 8.4 时就出现了),反推实际约 7.9,所以取 7.6 留余量;最外层的底色也铺成深色,万一缩了露出来的也不是白的;
83 * 高度按横条能给的行数再往下缩。
84 */
85const CHART_PX_PER_CELL = 7.6
86/** 一段文字占几格:中日文字符占两格,其余一格。第一行放得下放不下,按各语言实际的文字量算,不能按中文估。 */
87const cellsOf = (text: string) => [...text].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1), 0)
88const CHART_MIN_W = 480
89const CHART_MAX_W = 1400
90const CHART_MAX_H = 260
91
92const SERIES: Array<{ key: SeriesKey; color: string }> = [
93  { key: 'cacheRead', color: '#a855f7' },
94  { key: 'cacheCreate', color: '#f97316' },
95  { key: 'input', color: '#3b82f6' },
96  { key: 'output', color: '#22c55e' },
97]
98
99const seriesLabel = (L: Dict, key: SeriesKey) => ({ input: L.sIn, output: L.sOut, cacheCreate: L.sCreate, cacheRead: L.sHit })[key]
100
101const clockText = (at: number) => `${pad(new Date(at).getHours())}:${pad(new Date(at).getMinutes())}`
102
103const esc = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
104
105/** 纵轴上限取 1、2、5、10 乘以 10 的幂,刻度才好读。 */
106const niceMax = (value: number) => {
107  if (value <= 0) return 1
108  const power = 10 ** Math.floor(Math.log10(value))
109  const m = value / power
110
111  return (m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10) * power
112}
113
114/** 悬停明细卡片的底色和边线:底色比图的底色(33,33,33)稍亮一点,像浮起来的一张卡片,并画成半透明,下面的曲线还隐约看得见。 */
115const TIP_BG = '#383838'
116const TIP_LINE = '#5c5c5c'
117const TIP_TEXT = '#ececec'
118
119/** 图里画曲线的区域离画布左、右边的像素数。左边要放纵轴刻度文字(最宽约 27 像素,如 "1.5M"),加间距共 44;右边留 24,两边的空白大致相当。 */
120const PLOT_LEFT = 44
121const PLOT_RIGHT = 24
122
123/** 一段文字在图里占多宽(像素,12 号字):中日文约 12,其余约 6.7,多留一点余量。SVG 量不了字宽,只能估。 */
124const textPx = (text: string) => [...text].reduce((sum, ch) => sum + ((ch.codePointAt(0) ?? 0) > 0x2e7f ? 12 : 6.7), 0) + 2
125
126/**
127 * 选中那一轮的明细卡片的前两行:开始、结束、用时和模型。
128 * 结束时刻是 Mod 收到"这一轮结束"那一刻的时间,开始时刻是它减去引擎报的这一轮总耗时。
129 * 总耗时是墙上时间,你让它停着等的时间也算在里面。
130 */
131const tipHead = (L: Dict, r: TurnRecord): [string, string] => [
132  `${L.startLabel} ${stampText(r.at - r.ms)}  →  ${L.endLabel} ${stampText(r.at)}`,
133  `${L.elapsed(r.ms)}  ·  ${modelName(r.model)}${r.isSub ? ` (${L.sub})` : ''}`,
134]
135
136/**
137 * 折线图,画成一段 SVG。横轴是最近几轮回复,纵轴是 token 数。
138 * 每一轮一个悬停组(class="c"):鼠标移到这一列上,列高亮、出竖线和放大的点,并弹出明细卡片。
139 * 沙盒里不能跑脚本,全靠 CSS :hover;卡片放在这一列的右边,右边放不下就放左边,底色半透明,不挡住被指的这一列。
140 * 这样的 SVG 要放进沙盒小窗口(isInteractive)画,横条每次重画窗口都会重建,所以会偶尔闪一下。
141 */
142const chartSvg = (L: Dict, turns: TurnRecord[], hidden: string[], width: number, height: number) => {
143  const W = width
144  const H = height
145  const left = PLOT_LEFT
146  const right = PLOT_RIGHT
147  const top = 12
148  const bottom = 26
149  const pw = W - left - right
150  const ph = H - top - bottom
151  const shown = SERIES.filter(one => !hidden.includes(one.key))
152  const max = niceMax(Math.max(0, ...shown.flatMap(one => turns.map(r => r[one.key]))))
153  const n = turns.length
154  const step = n === 1 ? pw : pw / (n - 1)
155  const x = (i: number) => (n === 1 ? left + pw / 2 : left + step * i)
156  const y = (v: number) => top + ph * (1 - v / max)
157  const f = (v: number) => v.toFixed(1)
158
159  const out: string[] = [
160    `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="background:${CARD}" font-family="sans-serif" font-size="12">`,
161    '<style>.c .h,.c .t{display:none}.c:hover .h,.c:hover .t{display:inline}.c:hover .band{fill-opacity:.08}</style>',
162    `<rect width="${W}" height="${H}" fill="${CARD}"/>`,
163  ]
164  for (let i = 0; i <= 4; i += 1) {
165    const v = (max * i) / 4
166    out.push(
167      `<line x1="${left}" x2="${W - right}" y1="${f(y(v))}" y2="${f(y(v))}" stroke="${GRID}"/>`,
168      `<text x="${left - 6}" y="${f(y(v) + 4)}" text-anchor="end" fill="${MUTED}">${countText(v)}</text>`,
169    )
170  }
171  for (const one of shown) {
172    const points = turns.map((r, i) => `${f(x(i))},${f(y(r[one.key]))}`)
173    out.push(`<polyline points="${points.join(' ')}" fill="none" stroke="${one.color}" stroke-width="2" stroke-linejoin="round"/>`)
174    for (const point of points) {
175      const [px, py] = point.split(',')
176      out.push(`<circle cx="${px}" cy="${py}" r="2.5" fill="${one.color}"/>`)
177    }
178  }
179  out.push(
180    `<text x="${left}" y="${H - 6}" fill="${MUTED}">${clockText(turns[0]!.at)}</text>`,
181    `<text x="${W - right}" y="${H - 6}" text-anchor="end" fill="${MUTED}">${clockText(turns[n - 1]!.at)}</text>`,
182  )
183
184  // 悬停组放在最后,卡片才画在曲线和文字上面。
185  const rowH = 18
186  const inset = 10
187  turns.forEach((r, i) => {
188    const cx = x(i)
189    const bandX = n === 1 ? left : i === 0 ? left : cx - step / 2
190    const bandW = n === 1 ? pw : i === 0 || i === n - 1 ? step / 2 : step
191    const head = tipHead(L, r)
192    const cells = SERIES.map(one => `${seriesLabel(L, one.key)} ${countText(r[one.key])}`)
193    const col1 = Math.max(textPx(cells[0]!), textPx(cells[2]!)) + 14
194    const col2 = Math.max(textPx(cells[1]!), textPx(cells[3]!)) + 14
195    const cardW = Math.round(Math.max(...head.map(textPx), col1 + 16 + col2) + inset * 2)
196    const cardH = inset * 2 + rowH * 4
197    const gap = 14
198    const cardX = Math.round(cx + gap + cardW <= W - 4 ? cx + gap : Math.max(4, cx - gap - cardW))
199    const cardY = Math.round(Math.min(H - 4 - cardH, top + 4))
200
201    out.push(
202      '<g class="c">',
203      `<rect class="band" x="${f(bandX)}" y="${top}" width="${f(bandW)}" height="${f(ph)}" fill="#ffffff" fill-opacity="0" pointer-events="all"/>`,
204      '<g class="h">',
205      `<line x1="${f(cx)}" x2="${f(cx)}" y1="${top}" y2="${f(top + ph)}" stroke="#ffffff" stroke-opacity=".35" stroke-dasharray="3 3"/>`,
206      ...shown.map(one => `<circle cx="${f(cx)}" cy="${f(y(r[one.key]))}" r="4.5" fill="${one.color}" stroke="${CARD}" stroke-width="1.5"/>`),
207      '</g>',
208      '<g class="t">',
209      `<rect x="${cardX}" y="${cardY}" width="${cardW}" height="${cardH}" rx="6" fill="${TIP_BG}" fill-opacity=".82" stroke="${TIP_LINE}"/>`,
210      `<text x="${cardX + inset}" y="${cardY + inset + 12}" fill="${TIP_TEXT}">${esc(head[0])}</text>`,
211      `<text x="${cardX + inset}" y="${cardY + inset + 12 + rowH}" fill="${MUTED}">${esc(head[1])}</text>`,
212      ...SERIES.flatMap((one, k) => {
213        const tx = cardX + inset + (k % 2 === 0 ? 0 : col1 + 16)
214        const ty = cardY + inset + 12 + rowH * (2 + Math.floor(k / 2))
215
216        return [`<circle cx="${tx + 4}" cy="${ty - 4}" r="4" fill="${one.color}"/>`, `<text x="${tx + 14}" y="${ty}" fill="${TIP_TEXT}">${esc(cells[k]!)}</text>`]
217      }),
218      '</g>',
219      '</g>',
220    )
221  })
222  out.push('</svg>')
223
224  return out.join('')
225}
226
227/** 精确到秒的时刻;不是今天的前面带上月/日。 */
228const stampText = (at: number) => {
229  const d = new Date(at)
230  const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
231
232  return startOfDay(d) === startOfDay(new Date()) ? time : `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${time}`
233}
234
235
236const remaining = (L: Dict, resetsAt: string | undefined, at: number) => {
237  if (resetsAt === undefined) return ''
238  const ms = Date.parse(resetsAt) - at
239  if (Number.isNaN(ms)) return ''
240  if (ms <= 0) return L.resetSoon
241  const minutes = Math.ceil(ms / 60000)
242  const days = Math.floor(minutes / 1440)
243  const hours = Math.floor((minutes % 1440) / 60)
244  const mins = minutes % 60
245  if (days > 0) return L.inDays(days, hours)
246  if (hours > 0) return L.inHours(hours, mins)
247
248  return L.inMins(mins)
249}
250
251const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
252
253const clock = (L: Dict, resetsAt: string | undefined, at: number) => {
254  if (resetsAt === undefined) return ''
255  const target = new Date(resetsAt)
256  if (Number.isNaN(target.getTime())) return ''
257  const days = Math.round((startOfDay(target) - startOfDay(new Date(at))) / 86400000)
258  const time = `${pad(target.getHours())}:${pad(target.getMinutes())}`
259  if (days === 0) return time
260  if (days === 1) return `${L.tomorrow} ${time}`
261  if (days > 1 && days < 7) return `${L.weekday(target.getDay())} ${time}`
262
263  return `${L.date(target.getMonth() + 1, target.getDate())} ${time}`
264}
265
266const tokensText = (n: number | undefined) => {
267  if (n === undefined) return '—'
268  if (n >= 1000000) return `${Number((n / 1000000).toFixed(1))}M`
269
270  return `${Math.round(n / 1000)}k`
271}
272
273const find = (limits: Limit[], kind: string) => limits.find(one => one.kind === kind)
274
275/** 窗口还没到重置时间,存下来的数据才还算数。 */
276const isLive = (one: Limit, at: number) => one.resetsAt === undefined || Date.parse(one.resetsAt) > at
277
278const loadCache = async ($: EngineInterface, at: number): Promise<Limit[]> => {
279  try {
280    const saved: unknown = JSON.parse(String(await $.fs.read(cachePath($))))
281
282    return Array.isArray(saved) ? (saved as Limit[]).filter(one => isLive(one, at)) : []
283  } catch {
284    return []
285  }
286}
287
288const saveCache = async ($: EngineInterface, limits: Limit[]) => {
289  try {
290    await $.fs.write(cachePath($), JSON.stringify(limits))
291  } catch {
292    // 写不进去就算了,只是下个会话启动时没有上次的数据可用。
293  }
294}
295
296/** 读一次引擎当前的用量,有变化才更新,避免白白重画。 */
297const refresh = async ($: EngineInterface) => {
298  const t = await $.clock.now()
299  const current = await $.session.usage()
300  const old = await read($, usage)
301  let limits = old.limits.filter(one => isLive(one, t))
302  if (current.rateLimits.length > 0) {
303    limits = current.rateLimits
304    if (JSON.stringify(limits) !== JSON.stringify(old.limits)) await saveCache($, limits)
305  } else if (limits.length === 0) {
306    // 这个会话还没收到过回复:先用文件里别的会话存下的。
307    limits = await loadCache($, t)
308  }
309  const fresh: Usage = {
310    context: current.context.percent,
311    tokens: current.context.tokens,
312    window: current.context.window,
313    limits,
314  }
315  if (JSON.stringify(fresh) !== JSON.stringify(old)) await update($, usage, () => fresh)
316  // 重置倒计时精确到分钟,每过一分钟更新一次时间。
317  // 明细图展开时不更新:横条每重画一次,交互 SVG 的小窗口就重建一次,图会闪;倒计时晚几分钟更新没关系,合上就补上。
318  if (Math.floor(t / 60000) !== Math.floor((await read($, now)) / 60000) && !(await read($, statsOpen))) await update($, now, () => t)
319}
320
321export const register: Register = (on, options) => {
322  const L = pickDict((options as Record<string, unknown>).language)
323
324  on('session.start', async ($, e, next) => {
325    await refresh($)
326    // 不只在每轮回复结束时更新:每 5 秒主动读一次,回复进行中、刚切到这个会话时数字也是新的。
327    $.clock.every(5000, () => refresh($))
328
329    return next(e)
330  })
331
332  on('session.measure', async ($, e, next) => {
333    await refresh($)
334
335    const checks: Array<[string, string, number | undefined]> = [
336      ['ctx', L.ctx, e.context.percent],
337      ['five', L.fiveWindow, find(e.rateLimits, 'five_hour')?.percentUsed],
338      ['week', L.weekWindow, find(e.rateLimits, 'seven_day')?.percentUsed],
339    ]
340    for (const [key, name, percent] of checks) {
341      if (percent === undefined) continue
342      if (percent >= WARN && !warned.has(key)) {
343        warned.add(key)
344        $.ui.toast(L.warn(name, Math.round(percent)))
345      } else if (percent < WARN - 5) {
346        warned.delete(key)
347      }
348    }
349
350    return next(e)
351  })
352
353  // 只观察:先让这一轮正常收尾,再记下它的用量。记不进去也不影响回复。
354  on('turn.complete', async ($, e, next) => {
355    const result = await next(e)
356    const u = e.usage
357    if (u === undefined) return result
358    try {
359      const record: TurnRecord = {
360        at: await $.clock.now(),
361        model: u.model,
362        input: u.input_tokens,
363        output: u.output_tokens,
364        cacheRead: u.cache_read_input_tokens,
365        cacheCreate: u.cache_creation_input_tokens,
366        ms: e.durationMs,
367        isSub: e.agentId !== undefined,
368      }
369      await update($, stats, old => ({
370        total: {
371          turns: old.total.turns + 1,
372          input: old.total.input + record.input,
373          output: old.total.output + record.output,
374          cacheRead: old.total.cacheRead + record.cacheRead,
375          cacheCreate: old.total.cacheCreate + record.cacheCreate,
376        },
377        recent: [...old.recent, record].slice(-MAX_TURNS),
378      }))
379    } catch {
380      // 少记一轮而已。
381    }
382
383    return result
384  })
385
386  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
387    if (e.props.hasSurvey) return next(e)
388
389    const data = await read($, usage)
390    const at = await read($, now)
391    const isOpen = await read($, statsOpen)
392    const { total, recent } = await read($, stats)
393    const hidden = await read($, hiddenSeries)
394
395    const five = find(data.limits, 'five_hour')
396    const week = find(data.limits, 'seven_day')
397    const elements = $.ui.resolve(e)
398    const { Box, Button, Text } = elements
399    // 折线图是 SVG,只有桌面端等远程界面有这个元素。
400    const Svg = 'Svg' in elements ? elements.Svg : undefined
401    const isTerminal = e.surface === 'terminal'
402
403    if (e.props.bodyColumns < 78) {
404      const short = [
405        data.context === undefined ? `${L.shortCtx} —` : `${L.shortCtx} ${Math.round(data.context)}%`,
406        five === undefined ? `${L.shortFive} —` : `${L.shortFive} ${Math.round(five.percentUsed)}%`,
407        week === undefined ? `${L.shortWeek} —` : `${L.shortWeek} ${Math.round(week.percentUsed)}%`,
408      ].join(' · ')
409
410      return (
411        <Box width="100%" justifyContent="center">
412          <Text>{short}</Text>
413        </Box>
414      )
415    }
416
417    // 三块等宽,块之间各留一格;减去的是两个按钮和四处间隙占的格数,不再额外留边距。
418    const cells = Math.max(20, Math.floor((e.props.bodyColumns - 4 - 2 * BUTTON_CELLS) / 3))
419
420    // 几何全部交给 Box:底色块铺满整块,橙色填充按百分比取宽度。
421    // 文字画两层、各裁一半:填充内是深色字,填充外是浅色字,在填充边界处切开。
422    // 不用空格撑宽度,因为桌面端字体不是等宽的,空格撑出来的宽度和 Box 的宽度对不上。
423    const block = (key: string, label: string, percent: number | undefined, detail: string) => {
424      const text = percent === undefined ? `${label} —` : `${label} ${Math.round(percent)}%${detail === '' ? '' : ` · ${detail}`}`
425      const filled = percent === undefined ? 0 : percent <= 0 ? 0 : Math.min(cells, Math.max(1, Math.round((percent / 100) * cells)))
426      const layer = (color: string, shift: number) => (
427        <Box position="absolute" top={0} left={shift} width={cells} height={1} justifyContent="center">
428          <Text color={color} wrap="truncate-end">
429            {text}
430          </Text>
431        </Box>
432      )
433
434      return (
435        <Box key={key} width={cells} height={1} backgroundColor={WARM}>
436          {filled > 0 && (
437            <Box position="absolute" top={0} left={0} width={filled} height={1} backgroundColor={ORANGE} overflow="hidden">
438              {layer(DARK, 0)}
439            </Box>
440          )}
441          {filled < cells && (
442            <Box position="absolute" top={0} left={filled} width={cells - filled} height={1} overflow="hidden">
443              {layer(LIGHT, -filled)}
444            </Box>
445          )}
446        </Box>
447      )
448    }
449
450    const ctxDetail = data.tokens === undefined ? '' : `${tokensText(data.tokens)} / ${tokensText(data.window)}`
451    const fiveDetail = five === undefined ? '' : `${remaining(L, five.resetsAt, at)} · ${clock(L, five.resetsAt, at)}`
452    const weekDetail = week === undefined ? '' : `${remaining(L, week.resetsAt, at)} · ${clock(L, week.resetsAt, at)}`
453
454    const compact = async () => {
455      $.ui.toast(L.compacting)
456      try {
457        // 不用 $.session.compact():桌面应用的会话是 SDK 无头会话,引擎不让插件直接调它。
458        // 改成像用户输入 /compact 那样运行这条命令;引擎文档说回复进行中会排队到这一轮结束。
459        await $.command.run({ command: 'compact' })
460        $.ui.toast(L.compacted)
461      } catch (err) {
462        $.ui.toast(L.notCompacted(err instanceof Error ? err.message : String(err)))
463      }
464    }
465
466    const toggleStats = () => void update($, statsOpen, open => !open)
467    const toggleSeries = (key: SeriesKey) =>
468      void update($, hiddenSeries, old => (old.includes(key) ? old.filter(one => one !== key) : [...old, key]))
469
470    // 展开的明细:顶上是合计,中间是折线图,下面是曲线开关。画在横条里、用量条的上方,位置固定。
471    const stat = (key: string, label: string, value: string) => (
472      <Box key={key} gap={1}>
473        <Text dimColor>{label}</Text>
474        <Text bold>{value}</Text>
475      </Box>
476    )
477
478    // 盖在折线图上的一排透明感应区,每轮一个,宽度按比例(flexGrow)和图里的点对齐(图是固定像素宽,Box 的宽度是格数;
479    // 百分比宽度只收整数,量不准,所以按像素数当比例分)。鼠标移到某一轮上,同组(scope)的东西一起亮:这一列变成淡淡的高亮,图下面固定一行里显示那一轮的明细。
480    // 悬停是应用自己处理的,没有任何东西回到插件,所以不会触发横条重画,图也就不会闪。
481    // 横条的高度是应用定的(最多窗口的一半,放不下就滚动),所以图的高度跟着可用行数走,版面压紧:
482    // 标题、曲线开关、关闭按钮一行,合计一行,其余都给图。一行大约按 20 像素算。
483    const chartW = Math.round(Math.min(CHART_MAX_W, Math.max(CHART_MIN_W, e.props.bodyColumns * CHART_PX_PER_CELL)))
484    const chartH = Math.max(140, Math.min(CHART_MAX_H, (e.props.maxRows - 5) * 20))
485
486    // 第一行:合计在整行里居中,标题贴左、共几轮贴右(都不占位置)。放不下的东西按顺序让位:
487    // 先去掉标题,再把"共几轮"挪到第二行,还不够就去掉合计里的命中率、新增输入。英文、日文的文字比中文长得多,所以按实际文字量算。
488    const cols = e.props.bodyColumns
489    const GAP = 3
490    let items: Array<[string, string, string]> = [
491      ['in', L.sIn, countText(total.input)],
492      ['out', L.sOut, countText(total.output)],
493      ['create', L.sCreate, countText(total.cacheCreate)],
494      ['hit', L.sHit, countText(total.cacheRead)],
495      ['rate', L.sRate, hitRate(total.input, total.cacheRead, total.cacheCreate)],
496    ]
497    const rowCells = (list: typeof items) => list.reduce((n, [, label, value]) => n + cellsOf(label) + 1 + cellsOf(value), 0) + GAP * (list.length - 1)
498    for (const drop of ['rate', 'in']) if (rowCells(items) + 4 > cols) items = items.filter(([key]) => key !== drop)
499    const freeSide = (cols - rowCells(items)) / 2
500    const turnsText = L.statsTurns(total.turns)
501    const showTitle = total.turns === 0 || freeSide >= cellsOf(L.statsTitle) + 2
502    const showTurnsTop = freeSide >= cellsOf(turnsText) + 2
503
504    const panel = (
505      <Box key="stats-panel" flexDirection="column" width="100%" alignItems="center">
506        {/* 第一行:五项合计居中,标题贴左、共几轮贴右;第二行:四条曲线的开关,居中。没有关闭按钮,收起靠右下角的图标。放不下时的让位顺序见上面。 */}
507        <Box width="100%" justifyContent="center">
508          {showTitle && (
509            <Box position="absolute" top={0} left={0}>
510              <Text bold>{L.statsTitle}</Text>
511            </Box>
512          )}
513          {total.turns > 0 && (
514            <Box gap={GAP}>{items.map(([key, label, value]) => stat(key, label, value))}</Box>
515          )}
516          {total.turns > 0 && showTurnsTop && (
517            <Box position="absolute" top={0} right={0}>
518              <Text bold>{turnsText}</Text>
519            </Box>
520          )}
521        </Box>
522        {total.turns === 0 ? (
523          <Text dimColor>{L.statsEmpty}</Text>
524        ) : (
525          <Box flexDirection="column" width="100%" alignItems="center">
526            {Svg !== undefined && (
527              <Box width="100%" justifyContent="center" gap={2}>
528                {!showTurnsTop && (
529                  <Box position="absolute" top={0} right={0}>
530                    <Text bold>{turnsText}</Text>
531                  </Box>
532                )}
533                {SERIES.map(one => (
534                  <Box key={one.key} gap={1}>
535                    <Text color={one.color} dimColor={hidden.includes(one.key)}>
536                      ●
537                    </Text>
538                    <Button key={`toggle-${one.key}`} label={seriesLabel(L, one.key)} plain dimColor={hidden.includes(one.key)} onPress={() => toggleSeries(one.key)} />
539                  </Box>
540                ))}
541              </Box>
542            )}
543            {Svg === undefined ? (
544              <Text dimColor>{L.chartDesktopOnly}</Text>
545            ) : (
546              <Box flexDirection="column" width="100%" alignItems="center">
547                <Svg key="chart" source={chartSvg(L, recent, hidden, chartW, chartH)} alt={`${L.statsTitle} · ${L.statsAxis(recent.length)}`} width={chartW} height={chartH} isInteractive />
548              </Box>
549            )}
550          </Box>
551        )}
552      </Box>
553    )
554
555    // 图标按钮。图标是 Svg(画不了点击),所以在它上面盖一个同样大小、看不见的 Button 接收点击;没有 Svg 的界面(终端)直接用符号当按钮。
556    // 悬停提示:平时不显示,鼠标移到图标上时才出现,向 side 指的那一侧展开(向左展开用负的 left,不用 right:实测 right 会让提示从图标往右跑出窗口)。桌面端应用自己把它画成深色卡片,用浅色字;终端里是橙底深字。
557    const iconButton = (key: string, glyph: string, paths: string, isOn: boolean, tip: string, tipCells: number, side: "left" | "right", onPress: () => void) => (
558      <Box key={`${key}-wrap`} width={BUTTON_CELLS} height={1} position="relative" justifyContent="center">
559        {Svg !== undefined && <Svg key={`${key}-icon`} source={lineIconSvg(paths, isOn)} alt={tip} width={24} height={22} />}
560        {Svg !== undefined && (
561          <Box position="absolute" top={0} left={0} width={BUTTON_CELLS} height={1}>
562            <Button key={key} label={'\u00a0'.repeat(BUTTON_CELLS)} plain onPress={onPress} />
563          </Box>
564        )}
565        {Svg === undefined && <Button key={key} label={glyph} plain onPress={onPress} />}
566        <Box
567          position="absolute"
568          top={0}
569          left={side === "right" ? BUTTON_CELLS : -tipCells}
570          width={tipCells}
571          height={1}
572          justifyContent="center"
573          backgroundColor={isTerminal ? ORANGE : undefined}
574          display="none"
575          hover={{ display: 'flex' }}
576        >
577          <Text color={isTerminal ? DARK : LIGHT}>{tip}</Text>
578        </Box>
579      </Box>
580    )
581
582    return (
583      <Box width="100%" flexDirection="column" gap={1}>
584        {isOpen && panel}
585        <Box width="100%" justifyContent="center" gap={1}>
586          {iconButton('compact', ICON, ICON_PATHS.compact, false, L.tip, L.tipCells, 'right', compact)}
587          {block('ctx', L.ctx, data.context, ctxDetail)}
588          {block('five', L.five, five?.percentUsed, fiveDetail)}
589          {block('week', L.week, week?.percentUsed, weekDetail)}
590          {iconButton('stats', '≡', ICON_PATHS.stats, isOpen, L.statsTip, L.statsTipCells, 'left', toggleStats)}
591        </Box>
592      </Box>
593    )
594  })
595}
596
hooks/i18n.ts 262 lines
1// 所有显示出来的文字都在这里。想加一种语言:复制一份下面的词典,改成目标语言,
2// 在 DICTS 里登记,再把语言代码加进 .claude-plugin/plugin.json 的 userConfig.language.options。
3
4/** 把毫秒拆成时、分、秒,各语言的"用时"文字共用。 */
5const split = (ms: number) => {
6  const total = Math.max(0, Math.round(ms / 1000))
7
8  return { h: Math.floor(total / 3600), m: Math.floor((total % 3600) / 60), s: total % 60 }
9}
10
11export type Dict = {
12  /** 三块的名字,以及窄窗口时的简短写法 */
13  ctx: string
14  five: string
15  week: string
16  shortCtx: string
17  shortFive: string
18  shortWeek: string
19  /** 弹提醒时用的窗口名 */
20  fiveWindow: string
21  weekWindow: string
22  warn: (name: string, percent: number) => string
23  /** 距离重置还有多久:按天+时、时+分、分三档 */
24  inDays: (days: number, hours: number) => string
25  inHours: (hours: number, mins: number) => string
26  inMins: (mins: number) => string
27  resetSoon: string
28  /** 重置时刻:明天、一周内的星期几、更远的日期 */
29  tomorrow: string
30  weekday: (index: number) => string
31  date: (month: number, day: number) => string
32  /** 压缩按钮 */
33  compacting: string
34  compacted: string
35  notCompacted: (reason: string) => string
36  tip: string
37  /** 悬停提示占的格数(中日文每字占两格) */
38  tipCells: number
39  /** token 明细按钮和面板 */
40  statsTip: string
41  statsTipCells: number
42  statsTitle: string
43  statsEmpty: string
44  statsTurns: (n: number) => string
45  sIn: string
46  sOut: string
47  sCreate: string
48  sHit: string
49  sRate: string
50  sub: string
51  statsAxis: (n: number) => string
52  startLabel: string
53  endLabel: string
54  elapsed: (ms: number) => string
55  chartDesktopOnly: string
56}
57
58const zh: Dict = {
59  ctx: '上下文',
60  five: '5小时',
61  week: '本周',
62  shortCtx: '上下文',
63  shortFive: '5时',
64  shortWeek: '周',
65  fiveWindow: '5 小时窗口',
66  weekWindow: '每周窗口',
67  warn: (name, percent) => `${name}已用 ${percent}%`,
68  inDays: (d, h) => `${d}天${h}时后`,
69  inHours: (h, m) => `${h}时${m}分后`,
70  inMins: m => `${m}分后`,
71  resetSoon: '即将重置',
72  tomorrow: '明天',
73  weekday: i => `周${'日一二三四五六'[i]}`,
74  date: (m, d) => `${m}月${d}日`,
75  compacting: '正在压缩上下文…(内容多时要一两分钟)',
76  compacted: '已压缩上下文',
77  notCompacted: reason => `没有压缩:${reason}`,
78  tip: '点击压缩上下文',
79  tipCells: 18,
80  statsTip: '展开/收起 token 趋势',
81  statsTipCells: 24,
82  statsTitle: '本会话 token 明细',
83  statsEmpty: '还没有记录。从装上这个 Mod 起,每轮回复结束时开始统计。',
84  statsTurns: n => `共 ${n} 轮`,
85  sIn: '新增输入',
86  sOut: '输出',
87  sCreate: '缓存创建',
88  sHit: '缓存命中',
89  sRate: '缓存命中率',
90  sub: '子代理',
91  statsAxis: n => `最近 ${n} 轮`,
92  startLabel: '开始',
93  endLabel: '结束',
94  elapsed: ms => {
95    const { h, m, s } = split(ms)
96
97    return h > 0 ? `用时 ${h}小时${m}分` : m > 0 ? `用时 ${m}分${s}秒` : `用时 ${s}秒`
98  },
99  chartDesktopOnly: '折线图只在桌面应用里显示。',
100}
101
102const zhTW: Dict = {
103  ctx: '上下文',
104  five: '5小時',
105  week: '本週',
106  shortCtx: '上下文',
107  shortFive: '5時',
108  shortWeek: '週',
109  fiveWindow: '5 小時視窗',
110  weekWindow: '每週視窗',
111  warn: (name, percent) => `${name}已用 ${percent}%`,
112  inDays: (d, h) => `${d}天${h}時後`,
113  inHours: (h, m) => `${h}時${m}分後`,
114  inMins: m => `${m}分後`,
115  resetSoon: '即將重置',
116  tomorrow: '明天',
117  weekday: i => `週${'日一二三四五六'[i]}`,
118  date: (m, d) => `${m}月${d}日`,
119  compacting: '正在壓縮上下文…(內容多時要一兩分鐘)',
120  compacted: '已壓縮上下文',
121  notCompacted: reason => `沒有壓縮:${reason}`,
122  tip: '點擊壓縮上下文',
123  tipCells: 18,
124  statsTip: '展開/收起 token 趨勢',
125  statsTipCells: 24,
126  statsTitle: '本會話 token 明細',
127  statsEmpty: '還沒有記錄。從裝上這個 Mod 起,每輪回覆結束時開始統計。',
128  statsTurns: n => `共 ${n} 輪`,
129  sIn: '新增輸入',
130  sOut: '輸出',
131  sCreate: '快取建立',
132  sHit: '快取命中',
133  sRate: '快取命中率',
134  sub: '子代理',
135  statsAxis: n => `最近 ${n} 輪`,
136  startLabel: '開始',
137  endLabel: '結束',
138  elapsed: ms => {
139    const { h, m, s } = split(ms)
140
141    return h > 0 ? `耗時 ${h}小時${m}分` : m > 0 ? `耗時 ${m}分${s}秒` : `耗時 ${s}秒`
142  },
143  chartDesktopOnly: '折線圖只在桌面應用程式裡顯示。',
144}
145
146const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
147const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
148
149const en: Dict = {
150  ctx: 'Context',
151  five: '5-hour',
152  week: 'Weekly',
153  shortCtx: 'Context',
154  shortFive: '5h',
155  shortWeek: 'Week',
156  fiveWindow: '5-hour limit',
157  weekWindow: 'Weekly limit',
158  warn: (name, percent) => `${name} at ${percent}%`,
159  inDays: (d, h) => `in ${d}d ${h}h`,
160  inHours: (h, m) => `in ${h}h ${m}m`,
161  inMins: m => `in ${m}m`,
162  resetSoon: 'resets soon',
163  tomorrow: 'tomorrow',
164  weekday: i => WEEKDAYS_EN[i] ?? '',
165  date: (m, d) => `${MONTHS[m - 1] ?? ''} ${d}`,
166  compacting: 'Compacting context… (can take a minute or two)',
167  compacted: 'Context compacted',
168  notCompacted: reason => `Not compacted: ${reason}`,
169  tip: 'Click to compact context',
170  tipCells: 26,
171  statsTip: 'Show / hide token trend',
172  statsTipCells: 26,
173  statsTitle: 'Token breakdown (this session)',
174  statsEmpty: 'Nothing recorded yet. Counting starts with the first reply after this mod loads.',
175  statsTurns: n => `${n} turn${n === 1 ? '' : 's'}`,
176  sIn: 'Fresh input',
177  sOut: 'Output',
178  sCreate: 'Cache write',
179  sHit: 'Cache read',
180  sRate: 'Cache hit rate',
181  sub: 'sub-agent',
182  statsAxis: n => `last ${n} turn${n === 1 ? '' : 's'}`,
183  startLabel: 'Start',
184  endLabel: 'End',
185  elapsed: ms => {
186    const { h, m, s } = split(ms)
187
188    return h > 0 ? `took ${h}h ${m}m` : m > 0 ? `took ${m}m ${s}s` : `took ${s}s`
189  },
190  chartDesktopOnly: 'The chart is only drawn in the Desktop app.',
191}
192
193const ja: Dict = {
194  ctx: 'コンテキスト',
195  five: '5時間',
196  week: '週間',
197  shortCtx: 'コンテキスト',
198  shortFive: '5時間',
199  shortWeek: '週',
200  fiveWindow: '5時間枠',
201  weekWindow: '週間枠',
202  warn: (name, percent) => `${name}が${percent}%に達しました`,
203  inDays: (d, h) => `${d}日${h}時間後`,
204  inHours: (h, m) => `${h}時間${m}分後`,
205  inMins: m => `${m}分後`,
206  resetSoon: 'まもなくリセット',
207  tomorrow: '明日',
208  weekday: i => `${'日月火水木金土'[i]}曜`,
209  date: (m, d) => `${m}月${d}日`,
210  compacting: 'コンテキストを圧縮中…(長いと1〜2分かかります)',
211  compacted: 'コンテキストを圧縮しました',
212  notCompacted: reason => `圧縮しませんでした:${reason}`,
213  tip: 'クリックでコンテキストを圧縮',
214  tipCells: 28,
215  statsTip: 'トークン推移を表示/非表示',
216  statsTipCells: 28,
217  statsTitle: 'トークン内訳(このセッション)',
218  statsEmpty: 'まだ記録がありません。このModを読み込んだ後の最初の応答から集計します。',
219  statsTurns: n => `${n} ターン`,
220  sIn: '新規入力',
221  sOut: '出力',
222  sCreate: 'キャッシュ作成',
223  sHit: 'キャッシュ読取',
224  sRate: 'キャッシュヒット率',
225  sub: 'サブエージェント',
226  statsAxis: n => `直近 ${n} ターン`,
227  startLabel: '開始',
228  endLabel: '終了',
229  elapsed: ms => {
230    const { h, m, s } = split(ms)
231
232    return h > 0 ? `所要 ${h}時間${m}分` : m > 0 ? `所要 ${m}分${s}秒` : `所要 ${s}秒`
233  },
234  chartDesktopOnly: 'グラフはデスクトップアプリでのみ表示されます。',
235}
236
237export const DICTS: Record<string, Dict> = { zh, 'zh-TW': zhTW, en, ja }
238
239/**
240 * 按电脑的语言挑词典:繁体中文(台湾、香港、澳门,或写明 Hant)用繁体,其余中文用简体,
241 * 日语用日语,别的一律英文。拿不到系统语言时也是英文。
242 */
243export const languageOf = (locale: string): string => {
244  const lower = locale.toLowerCase().replace('_', '-')
245
246  if (lower.startsWith('zh')) return /^zh-(tw|hk|mo|hant)/.test(lower) ? 'zh-TW' : 'zh'
247  if (lower.startsWith('ja')) return 'ja'
248
249  return 'en'
250}
251
252export const detectLanguage = (): string => {
253  try {
254    return languageOf(Intl.DateTimeFormat().resolvedOptions().locale)
255  } catch {
256    return 'en'
257  }
258}
259
260/** 设置里选了具体语言就用它;选"自动"、没设、或语言代码写错了,按电脑的语言来。 */
261export const pickDict = (language: unknown): Dict => DICTS[String(language)] ?? DICTS[detectLanguage()] ?? en
262
types/index.d.ts 23 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Usage = { context?: number; tokens?: number; window?: number; limits: Limit[] }
3
4/** 一轮回复的用量:四项 token 是 API 报的数,ms 是这一轮花的时间。 */
5export type TurnRecord = {
6  at: number
7  model: string
8  input: number
9  output: number
10  cacheRead: number
11  cacheCreate: number
12  ms: number
13  isSub: boolean
14}
15export type Totals = { turns: number; input: number; output: number; cacheRead: number; cacheCreate: number }
16export type Stats = { total: Totals; recent: TurnRecord[] }
17
18declare module 'claude-code' {
19  interface PluginState {
20    'usage-mod': { usage: Usage; now: number; stats: Stats; hidden: string[]; open: boolean }
21  }
22}
23