opencode-tokenwatch 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sidebar.jsx DELETED
@@ -1,604 +0,0 @@
1
- import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
2
- import { RGBA } from "@opentui/core";
3
- import { formatTokens, formatCost, formatDuration } from "./formatter.js";
4
- import { t as baseT, setLanguage } from "./i18n.js";
5
- const DEFAULT_CONFIG = {
6
- sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
7
- language: "auto",
8
- };
9
- function progressBarWidth(percent, width) {
10
- if (percent >= 100)
11
- return width;
12
- return Math.floor((percent / 100) * width);
13
- }
14
- function progressFilled(percent, width) {
15
- return "█".repeat(Math.max(0, progressBarWidth(percent, width)));
16
- }
17
- function progressRemaining(percent, width) {
18
- return "░".repeat(Math.max(0, width - progressBarWidth(percent, width)));
19
- }
20
- function getVisualWidth(str) {
21
- let w = 0;
22
- for (const c of str) {
23
- const code = c.codePointAt(0) ?? 0;
24
- if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3040 && code <= 0x30FF) ||
25
- (code >= 0xAC00 && code <= 0xD7A3) || (code >= 0x1100 && code <= 0x11FF) ||
26
- (code >= 0x2E80 && code <= 0x2EFF)) {
27
- w += 2;
28
- }
29
- else {
30
- w += 1;
31
- }
32
- }
33
- return w;
34
- }
35
- function centerAlign(text, width) {
36
- const visualW = getVisualWidth(text);
37
- if (visualW >= width)
38
- return text;
39
- const left = Math.floor((width - visualW) / 2);
40
- const right = width - visualW - left;
41
- return " ".repeat(left) + text + " ".repeat(right);
42
- }
43
- function hitRateColor(rate) {
44
- if (rate >= 85)
45
- return RGBA.fromInts(76, 175, 80, 255);
46
- if (rate >= 70)
47
- return RGBA.fromInts(255, 193, 7, 255);
48
- return RGBA.fromInts(244, 67, 54, 255);
49
- }
50
- /** 各 Token 分布角色的颜色 */
51
- function distRoleColor(role) {
52
- const map = {
53
- system: RGBA.fromInts(130, 80, 255, 255),
54
- user: RGBA.fromInts(88, 166, 255, 255),
55
- toolCall: RGBA.fromInts(210, 153, 34, 255),
56
- toolResult: RGBA.fromInts(219, 109, 40, 255),
57
- output: RGBA.fromInts(63, 185, 80, 255),
58
- other: RGBA.fromInts(72, 79, 88, 255),
59
- };
60
- return map[role] ?? RGBA.fromInts(72, 79, 88, 255);
61
- }
62
- function estimateTokens(text) {
63
- if (!text || text.length === 0)
64
- return 0;
65
- let ascii = 0, cjk = 0;
66
- for (const c of text) {
67
- const code = c.codePointAt(0) ?? 0;
68
- if ((code >= 0x4E00 && code <= 0x9FFF) || (code >= 0x3040 && code <= 0x30FF) ||
69
- (code >= 0xAC00 && code <= 0xD7A3) || (code >= 0x1100 && code <= 0x11FF) ||
70
- (code >= 0x2E80 && code <= 0x2EFF))
71
- cjk++;
72
- else
73
- ascii++;
74
- }
75
- const trimmed = text.trimStart();
76
- const jsonLike = (trimmed.startsWith("{") || trimmed.startsWith("[")) && /"[^"]+"\s*:/.test(text);
77
- const codeLike = !jsonLike && /```|^import |^export |^function |^const |^let |^var |^class |^interface |^type |^def |^fn |^pub |^use |^mod |^package /m.test(text);
78
- const asciiPerToken = jsonLike ? 2 : codeLike ? 2.5 : 4;
79
- return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1.5));
80
- }
81
- function loadCollapseState(api) {
82
- try {
83
- return api.kv?.get?.("tokenwatch-collapse") ?? { global: false, models: {}, subBlocks: {} };
84
- }
85
- catch {
86
- return { global: false, models: {}, subBlocks: {} };
87
- }
88
- }
89
- function saveCollapseState(api, state) {
90
- try {
91
- api.kv?.set?.("tokenwatch-collapse", state);
92
- }
93
- catch { /* non-critical */ }
94
- }
95
- export function loadConfig(api) {
96
- const base = { sidebar: { ...DEFAULT_CONFIG.sidebar }, language: DEFAULT_CONFIG.language };
97
- try {
98
- const pluginCfg = api.config?.pluginConfig?.["opencode-tokenwatch"];
99
- if (pluginCfg?.sidebar)
100
- Object.assign(base.sidebar, pluginCfg.sidebar);
101
- if (pluginCfg?.language)
102
- base.language = pluginCfg.language;
103
- const overrides = api.kv?.get?.("tokenwatch-config");
104
- if (overrides?.sidebar)
105
- Object.assign(base.sidebar, overrides.sidebar);
106
- if (overrides?.language)
107
- base.language = overrides.language;
108
- }
109
- catch { /* defaults */ }
110
- return base;
111
- }
112
- export function TokenWatchPanel(props) {
113
- const { api, theme, perfTracker } = props;
114
- const getMessages = () => props.messages();
115
- const [config, setConfig] = createSignal(loadConfig(api));
116
- // 同步初始化语言以防首帧渲染使用错误的 detectLanguage 默认值
117
- setLanguage(config().language);
118
- let knownCfgVer = api.kv?.get?.("tokenwatch-config-version");
119
- // ── 响应式翻译函数 ──
120
- const t = (key) => {
121
- // 显式订阅 config 的变化以建立 SolidJS 追踪依赖
122
- void config().language;
123
- return baseT(key);
124
- };
125
- // 检测是否是纯英文标签,用于全大写转换
126
- const isEnglish = (str) => /^[a-zA-Z\s\.\/]+$/.test(str);
127
- createEffect(() => {
128
- const timer = setInterval(() => {
129
- const v = api.kv?.get?.("tokenwatch-config-version");
130
- if (v != null && v !== knownCfgVer) {
131
- knownCfgVer = v;
132
- setConfig(loadConfig(api));
133
- }
134
- }, 500);
135
- onCleanup(() => clearInterval(timer));
136
- });
137
- const [collapse, setCollapse] = createSignal(loadCollapseState(api));
138
- // ── 真实面板宽度:通过 ref + onSizeChange 从渲染引擎获取 ──
139
- // 初始值给一个合理默认,渲染后立即更新为实际值
140
- const [panelWidth, setPanelWidth] = createSignal(38);
141
- let outerBoxRef = null;
142
- createEffect(() => setLanguage(config().language));
143
- // ── 颜色 helpers ──
144
- const primaryColor = () => theme.current.primary;
145
- const mutedColor = () => theme.current.textMuted;
146
- const dimColor = () => RGBA.fromInts(72, 79, 88, 255);
147
- const greenColor = () => RGBA.fromInts(63, 185, 80, 255);
148
- const borderColor = () => RGBA.fromInts(55, 65, 80, 255);
149
- // ── 数据聚合 ──
150
- const modelStats = createMemo(() => {
151
- const map = new Map();
152
- const msgs = props.allTokenMessages();
153
- for (let i = 0; i < msgs.length; i++) {
154
- const msg = msgs[i];
155
- const key = `${msg.providerID}/${msg.modelID}`;
156
- let e = map.get(key);
157
- if (!e) {
158
- e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0, lastMessageIndex: -1 };
159
- map.set(key, e);
160
- }
161
- e.totalInput += msg.inputTokens;
162
- e.totalOutput += msg.outputTokens;
163
- e.totalReasoning += msg.reasoningTokens;
164
- e.cacheRead += msg.cacheRead;
165
- e.cacheWrite += msg.cacheWrite;
166
- e.totalCost += msg.cost;
167
- e.requestCount++;
168
- e.lastMessageIndex = i; // 追踪该模型最后一条消息的索引(越大 = 越近调用)
169
- }
170
- return Array.from(map.entries())
171
- // 过滤掉全零无效模型(如会话失败导致 token 全为 0 的记录)
172
- .filter(([, s]) => s.totalInput + s.totalOutput + s.totalReasoning + s.cacheRead + s.cacheWrite > 0)
173
- // 按最近调用时间降序:最后一条消息在数组中索引越大越靠前
174
- .sort((a, b) => b[1].lastMessageIndex - a[1].lastMessageIndex);
175
- });
176
- const sessionTotals = createMemo(() => {
177
- let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
178
- for (const [, s] of modelStats()) {
179
- i += s.totalInput;
180
- o += s.totalOutput;
181
- ir += s.totalReasoning;
182
- cr += s.cacheRead;
183
- cw += s.cacheWrite;
184
- r += s.requestCount;
185
- c += s.totalCost;
186
- }
187
- return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
188
- });
189
- const globalHitRate = createMemo(() => {
190
- const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
191
- return denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : -1;
192
- });
193
- const modelHitRate = createMemo(() => {
194
- return modelStats().map(([key, stat]) => {
195
- const denom = stat.totalInput + stat.cacheRead;
196
- if (denom === 0)
197
- return { key, rate: 0, msgs: [] };
198
- const msgs = [];
199
- for (const msg of props.allTokenMessages()) {
200
- const pk = `${msg.providerID}/${msg.modelID}`;
201
- if (pk !== key)
202
- continue;
203
- msgs.push(msg);
204
- }
205
- return { key, rate: (stat.cacheRead / denom) * 100, msgs };
206
- });
207
- });
208
- const modelTrend = createMemo(() => {
209
- return modelHitRate().map(({ key, msgs }) => {
210
- if (msgs.length < 6)
211
- return { key, trend: null };
212
- const sumSlice = (start, end) => {
213
- let sumCache = 0, sumTotal = 0;
214
- for (let i = start; i < end && i < msgs.length; i++) {
215
- sumCache += msgs[i].cacheRead;
216
- sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
217
- }
218
- return { sumCache, sumTotal };
219
- };
220
- const n = msgs.length;
221
- const recent = sumSlice(n - 3, n);
222
- const prev = sumSlice(n - 6, n - 3);
223
- const rateRecent = recent.sumTotal > 0 ? (recent.sumCache / recent.sumTotal) * 100 : 0;
224
- const ratePrev = prev.sumTotal > 0 ? (prev.sumCache / prev.sumTotal) * 100 : 0;
225
- return { key, trend: rateRecent - ratePrev };
226
- });
227
- });
228
- const [partVersion, setPartVersion] = createSignal(0);
229
- const perfStats = createMemo(() => {
230
- void props.allTokenMessages();
231
- void partVersion();
232
- return perfTracker.getSessionStats();
233
- });
234
- const tokenDistribution = createMemo(() => {
235
- void props.allTokenMessages();
236
- void partVersion();
237
- const dist = {};
238
- try {
239
- const cfg = api.state.config;
240
- const agents = cfg?.agent;
241
- if (agents) {
242
- for (const ac of Object.values(agents)) {
243
- const a = ac;
244
- if (typeof a?.prompt === "string" && a.prompt) {
245
- dist.system = estimateTokens(a.prompt);
246
- break;
247
- }
248
- }
249
- }
250
- }
251
- catch { }
252
- for (const msg of getMessages()) {
253
- const role = msg.role;
254
- if (role === "user") {
255
- if (msg.system)
256
- dist.system = (dist.system ?? 0) + estimateTokens(msg.system);
257
- let parts = [];
258
- try {
259
- parts = api.state.part(msg.id);
260
- }
261
- catch {
262
- continue;
263
- }
264
- for (const p of parts) {
265
- if (p.type === "text" && !p.synthetic && !p.ignored) {
266
- dist.user = (dist.user ?? 0) + estimateTokens(p.text ?? "");
267
- }
268
- else if (p.type === "file" && p.source?.text?.value) {
269
- dist.user = (dist.user ?? 0) + estimateTokens(p.source.text.value);
270
- }
271
- }
272
- }
273
- else if (role === "assistant") {
274
- let parts = [];
275
- try {
276
- parts = api.state.part(msg.id);
277
- }
278
- catch {
279
- continue;
280
- }
281
- let msgEstimatedOutput = 0;
282
- for (const p of parts) {
283
- if (p.type === "tool") {
284
- let rawInput = "";
285
- try {
286
- rawInput = p.state?.raw ?? JSON.stringify(p.state?.input);
287
- }
288
- catch {
289
- try {
290
- rawInput = JSON.stringify(p.state);
291
- }
292
- catch { }
293
- }
294
- if (rawInput)
295
- dist.toolCall = (dist.toolCall ?? 0) + estimateTokens(rawInput);
296
- if (p.state?.status === "completed" && p.state?.output) {
297
- dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.output);
298
- }
299
- else if (p.state?.status === "error" && p.state?.error) {
300
- dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
301
- }
302
- }
303
- else if (p.type === "text" && p.text) {
304
- msgEstimatedOutput += estimateTokens(p.text);
305
- }
306
- else if (p.type === "reasoning") {
307
- msgEstimatedOutput += estimateTokens(p.text ?? "");
308
- }
309
- else if (p.type === "subtask") {
310
- msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
311
- }
312
- }
313
- const tokens = msg.tokens;
314
- if (tokens?.output !== undefined || tokens?.reasoning !== undefined) {
315
- dist.output = (dist.output ?? 0) + (tokens?.output ?? 0) + (tokens?.reasoning ?? 0);
316
- }
317
- else {
318
- dist.output = (dist.output ?? 0) + msgEstimatedOutput;
319
- }
320
- }
321
- }
322
- const realInput = sessionTotals().totalInput;
323
- if (realInput > 0) {
324
- const estimated = (dist.system ?? 0) + (dist.user ?? 0)
325
- + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
326
- const other = realInput - estimated;
327
- if (other > 50)
328
- dist.other = other;
329
- }
330
- return dist;
331
- });
332
- // ── 折叠状态 toggle ──
333
- const toggle = {
334
- global: () => setCollapse(p => { const n = { ...p, global: !p.global }; saveCollapseState(api, n); return n; }),
335
- model: (k) => setCollapse(p => { const n = { ...p, models: { ...p.models, [k]: !p.models[k] } }; saveCollapseState(api, n); return n; }),
336
- sub: (k) => setCollapse(p => { const n = { ...p, subBlocks: { ...p.subBlocks, [k]: !p.subBlocks[k] } }; saveCollapseState(api, n); return n; }),
337
- };
338
- onMount(() => {
339
- const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
340
- onCleanup(() => { try {
341
- unsubPart?.();
342
- }
343
- catch { } });
344
- });
345
- // ── 宽度派生值 ──
346
- // innerWidth:边框内可用列数 = panelWidth - 2(左右边框各1格)
347
- // barWidth:进度条宽 = innerWidth - paddingX(1*2) - "Cache: "(7) - " XX%"(4) - "↑X.X%"(最多6) = innerWidth - 19
348
- // 分隔线:innerWidth - paddingX(1*2) = innerWidth - 2
349
- const innerWidth = () => panelWidth() - 2;
350
- const barWidth = () => Math.max(8, innerWidth() - 19);
351
- const divider = () => {
352
- const w = innerWidth();
353
- if (w <= 2)
354
- return "─".repeat(w);
355
- return " " + "─".repeat(w - 2) + " ";
356
- };
357
- return (<box
358
- // ── 不设置固定 width,让外层容器决定宽度 ──
359
- // ref + onSizeChange:布局完成后获取真实宽度,用于内部字符宽度计算
360
- ref={(el) => { outerBoxRef = el; }} onSizeChange={() => {
361
- if (outerBoxRef)
362
- setPanelWidth(outerBoxRef.width);
363
- }} flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
364
-
365
- {/* ══════════════════════════════════════
366
- 面板 Header:▾ TokenWatch 89.1% hit
367
- justifyContent="space-between" 左右分布
368
- ══════════════════════════════════════ */}
369
- <box flexDirection="row" justifyContent="space-between" onMouseDown={toggle.global} paddingX={1}>
370
- <text fg={primaryColor()}>
371
- {collapse().global ? "▶" : "▾"} {t("panelTitle")}
372
- </text>
373
- <text fg={mutedColor()}>
374
- {collapse().global ? (<>
375
- {formatTokens(sessionTotals().totalTokens)}
376
- {globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
377
- {` (${globalHitRate().toFixed(1)}% hit)`}
378
- </span>) : ""}
379
- </>) : (globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
380
- {`${globalHitRate().toFixed(1)}% hit`}
381
- </span>) : "")}
382
- </text>
383
- </box>
384
-
385
- <Show when={!collapse().global}>
386
-
387
- {/* 标题下分隔线 */}
388
- <text fg={borderColor()}>{divider()}</text>
389
-
390
- {/* ══════════════════════════════════════
391
- 全局统计:Total / Req / Input / Output 均匀分行排布
392
- ══════════════════════════════════════ */}
393
- <box flexDirection="row" paddingX={1}>
394
- <For each={[
395
- { val: formatTokens(sessionTotals().totalTokens), lbl: t("total") },
396
- { val: sessionTotals().totalRequests.toString(), lbl: t("requests") },
397
- { val: formatTokens(sessionTotals().totalInput), lbl: t("input") },
398
- { val: formatTokens(sessionTotals().totalOutput), lbl: t("output") }
399
- ]}>
400
- {(item, idx) => {
401
- const colW = () => {
402
- const totalW = panelWidth() - 4;
403
- const base = Math.floor(totalW / 4);
404
- return idx() === 3 ? totalW - base * 3 : base;
405
- };
406
- return (<box width={colW()} flexDirection="column">
407
- <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
408
- <text fg={dimColor()}>
409
- {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
410
- </text>
411
- </box>);
412
- }}
413
- </For>
414
- </box>
415
-
416
- {/* 成本展示 */}
417
- <Show when={config().sidebar.showPricing && sessionTotals().totalCost > 0}>
418
- <box flexDirection="row" justifyContent="center" marginTop={1}>
419
- <text fg={mutedColor()}>
420
- {t("cost")}:{" "}
421
- <span style={{ fg: greenColor() }}>
422
- {formatCost(sessionTotals().totalCost)}
423
- </span>
424
- </text>
425
- </box>
426
- </Show>
427
-
428
- {/* ══════════════════════════════════════
429
- 各模型块:无分隔线,用 marginTop=1 隔开
430
- ══════════════════════════════════════ */}
431
- <For each={modelStats()}>
432
- {([key, stat]) => {
433
- const isExpanded = () => collapse().models[key] !== true;
434
- const hitDenom = stat.totalInput + stat.cacheRead;
435
- const hitRate = hitDenom > 0 ? (stat.cacheRead / hitDenom) * 100 : 0;
436
- const trendStr = () => {
437
- if (!config().sidebar.showTrend)
438
- return "";
439
- const td = modelTrend().find(h => h.key === key);
440
- if (!td?.trend || td.trend === 0)
441
- return "";
442
- return td.trend > 0
443
- ? ` ${t("trendUp")}${td.trend.toFixed(1)}%`
444
- : ` ${t("trendDown")}${Math.abs(td.trend).toFixed(1)}%`;
445
- };
446
- const trendColor = () => {
447
- const td = modelTrend().find(h => h.key === key);
448
- return (td?.trend ?? 0) >= 0
449
- ? RGBA.fromInts(63, 185, 80, 255)
450
- : RGBA.fromInts(244, 67, 54, 255);
451
- };
452
- // 供应商名称截断:超过12字符则省略
453
- const MAX_PROVIDER_LEN = 12;
454
- let providerDisplay = stat.providerID;
455
- if (providerDisplay.length > MAX_PROVIDER_LEN) {
456
- providerDisplay = providerDisplay.slice(0, MAX_PROVIDER_LEN - 1) + "…";
457
- }
458
- let fullTitle = `${providerDisplay}/${stat.modelID}`;
459
- // 去除中间段:格式为 厂商/模型厂商/模型名称 时只留 provider/modelname
460
- if (fullTitle.length > 22) {
461
- const parts = fullTitle.split("/");
462
- if (parts.length >= 3) {
463
- fullTitle = `${parts[0]}/${parts[parts.length - 1]}`;
464
- }
465
- }
466
- // 模型名截断:内容宽 - paddingX(2) - "● "(2) - " ×NNN ▾"(最多8) = innerWidth - 12
467
- const maxNameLen = Math.max(8, innerWidth() - 12);
468
- const shortTitle = fullTitle.length > maxNameLen
469
- ? fullTitle.slice(0, maxNameLen - 1) + "…"
470
- : fullTitle;
471
- // 折叠:右侧显示总 token;展开:右侧显示请求数
472
- const modelHeaderRight = () => {
473
- if (!isExpanded()) {
474
- const total = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
475
- return `${formatTokens(total)} ▶`;
476
- }
477
- return `×${stat.requestCount} ▾`;
478
- };
479
- // 计算模型总 tokens
480
- const modelTotalTokens = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
481
- // 计算对齐标签 (使用 getter 以保持响应式切换)
482
- const targetW = () => {
483
- const cacheLabel = t("cache") + ":";
484
- const costLabel = t("cost") + ":";
485
- return Math.max(getVisualWidth(cacheLabel), getVisualWidth(costLabel));
486
- };
487
- const paddedCachePrefix = () => {
488
- const label = t("cache") + ":";
489
- return label + " ".repeat(targetW() - getVisualWidth(label));
490
- };
491
- const paddedCostPrefix = () => {
492
- const label = t("cost") + ":";
493
- return label + " ".repeat(targetW() - getVisualWidth(label));
494
- };
495
- // 缓存进度条宽度:可用宽度 panelWidth() - 4 减去前缀 targetW(),减去百分比(4),减去趋势(6)
496
- const modelBarWidth = () => Math.max(8, (panelWidth() - 4) - targetW() - 11);
497
- return (
498
- // marginTop=1 提供模型间视觉间距(TUI最小单位为1行)
499
- <box flexDirection="column" marginTop={1}>
500
-
501
- {/* 模型 Header:左侧 ● 名称,右侧 统计+箭头 */}
502
- <box flexDirection="row" justifyContent="space-between" onMouseDown={() => toggle.model(key)} paddingX={1}>
503
- <text fg={mutedColor()}>
504
- <span style={{ fg: hitRateColor(hitRate) }}>●</span>
505
- {" "}
506
- <span style={{ fg: primaryColor() }}>{shortTitle}</span>
507
- </text>
508
- <text fg={mutedColor()}>{modelHeaderRight()}</text>
509
- </box>
510
-
511
- <Show when={isExpanded()}>
512
- <box flexDirection="column" paddingX={1}>
513
-
514
- {/* 模型指标三列网格,外带圆角边框 (取消上下间距) */}
515
- <box flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
516
- <box flexDirection="row">
517
- <For each={[
518
- { val: formatTokens(modelTotalTokens), lbl: t("total") },
519
- { val: formatTokens(stat.totalInput), lbl: t("input") },
520
- { val: formatTokens(stat.totalOutput), lbl: t("output") }
521
- ]}>
522
- {(item, idx) => {
523
- const colW = () => {
524
- const totalW = panelWidth() - 6; // 边框占用 2 列
525
- const base = Math.floor(totalW / 3);
526
- return idx() === 2 ? totalW - base * 2 : base;
527
- };
528
- return (<box width={colW()} flexDirection="column">
529
- <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
530
- <text fg={dimColor()}>
531
- {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
532
- </text>
533
- </box>);
534
- }}
535
- </For>
536
- </box>
537
- </box>
538
-
539
- {/* 缓存进度条 */}
540
- <text fg={mutedColor()}>
541
- {paddedCachePrefix()}
542
- <span style={{ fg: hitRateColor(hitRate) }}>
543
- {progressFilled(hitRate, modelBarWidth())}{progressRemaining(hitRate, modelBarWidth())}{" "}{hitRate.toFixed(0)}%
544
- </span>
545
- {trendStr()
546
- ? <span style={{ fg: trendColor() }}>{trendStr()}</span>
547
- : null}
548
- </text>
549
-
550
- {/* 性能指标 */}
551
- <Show when={config().sidebar.showPerformance && !!perfStats().models[key]}>
552
- <text fg={mutedColor()} marginTop={1}>
553
- {t("ttft")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgTTFT ?? null)}</span>
554
- {" "}{t("tps")} <span style={{ fg: primaryColor() }}>{perfStats().models[key]?.avgTPS?.toFixed(1) ?? "—"}</span>
555
- {" "}{t("lat")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgLatency ?? null)}</span>
556
- </text>
557
- </Show>
558
-
559
- {/* 成本 */}
560
- <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
561
- <text fg={mutedColor()}>{paddedCostPrefix()}{formatCost(stat.totalCost)}</text>
562
- </Show>
563
-
564
- </box>
565
- </Show>
566
- </box>);
567
- }}
568
- </For>
569
-
570
- {/* ══════════════════════════════════════
571
- Token 分布区块:左右 space-between 对齐布局 (取消进度条)
572
- ══════════════════════════════════════ */}
573
- <Show when={config().sidebar.showTokenDistribution}>
574
- <box flexDirection="column" marginTop={1}>
575
-
576
- {/* 分隔线 */}
577
- <text fg={borderColor()}>{divider()}</text>
578
-
579
- {/* Header */}
580
- <box flexDirection="row" onMouseDown={() => toggle.sub("token-dist")} paddingX={1}>
581
- <text fg={greenColor()}>
582
- {!collapse().subBlocks["token-dist"] ? "▾" : "▶"} {t("tokenDistribution")}
583
- </text>
584
- </box>
585
-
586
- <Show when={!collapse().subBlocks["token-dist"]}>
587
- <box flexDirection="column" paddingX={1} marginTop={1}>
588
- <For each={Object.entries(tokenDistribution()).filter(([_, val]) => val > 0)}>
589
- {([role, val]) => (<box flexDirection="row" justifyContent="space-between">
590
- <box flexDirection="row">
591
- <text fg={distRoleColor(role)}>█ </text>
592
- <text fg={mutedColor()}>{t(role)}</text>
593
- </box>
594
- <text fg={mutedColor()}>{formatTokens(val)}</text>
595
- </box>)}
596
- </For>
597
- </box>
598
- </Show>
599
- </box>
600
- </Show>
601
-
602
- </Show>
603
- </box>);
604
- }