opencode-tokenwatch 0.2.0 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createSignal, createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
2
2
  import { RGBA } from "@opentui/core";
3
3
  import { formatTokens, formatCost, formatDuration } from "./formatter.js";
4
- import { t, setLanguage } from "./i18n.js";
4
+ import { t as baseT, setLanguage } from "./i18n.js";
5
5
  const DEFAULT_CONFIG = {
6
6
  sidebar: { showPerformance: true, showPricing: true, showTokenDistribution: true, showTrend: true },
7
7
  language: "auto",
@@ -17,6 +17,29 @@ function progressFilled(percent, width) {
17
17
  function progressRemaining(percent, width) {
18
18
  return "░".repeat(Math.max(0, width - progressBarWidth(percent, width)));
19
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
+ }
20
43
  function hitRateColor(rate) {
21
44
  if (rate >= 85)
22
45
  return RGBA.fromInts(76, 175, 80, 255);
@@ -24,6 +47,18 @@ function hitRateColor(rate) {
24
47
  return RGBA.fromInts(255, 193, 7, 255);
25
48
  return RGBA.fromInts(244, 67, 54, 255);
26
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
+ }
27
62
  function estimateTokens(text) {
28
63
  if (!text || text.length === 0)
29
64
  return 0;
@@ -76,9 +111,19 @@ export function loadConfig(api) {
76
111
  }
77
112
  export function TokenWatchPanel(props) {
78
113
  const { api, theme, perfTracker } = props;
79
- const getMessages = () => props.messages;
114
+ const getMessages = () => props.messages();
80
115
  const [config, setConfig] = createSignal(loadConfig(api));
116
+ // 同步初始化语言以防首帧渲染使用错误的 detectLanguage 默认值
117
+ setLanguage(config().language);
81
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);
82
127
  createEffect(() => {
83
128
  const timer = setInterval(() => {
84
129
  const v = api.kv?.get?.("tokenwatch-config-version");
@@ -90,22 +135,30 @@ export function TokenWatchPanel(props) {
90
135
  onCleanup(() => clearInterval(timer));
91
136
  });
92
137
  const [collapse, setCollapse] = createSignal(loadCollapseState(api));
93
- const [panelWidth, setPanelWidth] = createSignal(40);
138
+ // ── 真实面板宽度:通过 ref + onSizeChange 从渲染引擎获取 ──
139
+ // 初始值给一个合理默认,渲染后立即更新为实际值
140
+ const [panelWidth, setPanelWidth] = createSignal(38);
141
+ let outerBoxRef = null;
94
142
  createEffect(() => setLanguage(config().language));
143
+ // ── 颜色 helpers ──
95
144
  const primaryColor = () => theme.current.primary;
96
145
  const mutedColor = () => theme.current.textMuted;
97
- const textColor = () => theme.current.text;
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
+ // ── 数据聚合 ──
98
150
  const modelStats = createMemo(() => {
99
151
  const map = new Map();
100
- for (const msg of props.allTokenMessages) {
152
+ for (const msg of props.allTokenMessages()) {
101
153
  const key = `${msg.providerID}/${msg.modelID}`;
102
154
  let e = map.get(key);
103
155
  if (!e) {
104
- e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
156
+ e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
105
157
  map.set(key, e);
106
158
  }
107
159
  e.totalInput += msg.inputTokens;
108
160
  e.totalOutput += msg.outputTokens;
161
+ e.totalReasoning += msg.reasoningTokens;
109
162
  e.cacheRead += msg.cacheRead;
110
163
  e.cacheWrite += msg.cacheWrite;
111
164
  e.totalCost += msg.cost;
@@ -114,16 +167,21 @@ export function TokenWatchPanel(props) {
114
167
  return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
115
168
  });
116
169
  const sessionTotals = createMemo(() => {
117
- let i = 0, o = 0, cr = 0, cw = 0, r = 0, c = 0;
170
+ let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
118
171
  for (const [, s] of modelStats()) {
119
172
  i += s.totalInput;
120
173
  o += s.totalOutput;
174
+ ir += s.totalReasoning;
121
175
  cr += s.cacheRead;
122
176
  cw += s.cacheWrite;
123
177
  r += s.requestCount;
124
178
  c += s.totalCost;
125
179
  }
126
- return { totalInput: i, totalOutput: o, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + cr + cw };
180
+ return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
181
+ });
182
+ const globalHitRate = createMemo(() => {
183
+ const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
184
+ return denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : -1;
127
185
  });
128
186
  const modelHitRate = createMemo(() => {
129
187
  return modelStats().map(([key, stat]) => {
@@ -131,7 +189,7 @@ export function TokenWatchPanel(props) {
131
189
  if (denom === 0)
132
190
  return { key, rate: 0, msgs: [] };
133
191
  const msgs = [];
134
- for (const msg of props.allTokenMessages) {
192
+ for (const msg of props.allTokenMessages()) {
135
193
  const pk = `${msg.providerID}/${msg.modelID}`;
136
194
  if (pk !== key)
137
195
  continue;
@@ -147,10 +205,8 @@ export function TokenWatchPanel(props) {
147
205
  const sumSlice = (start, end) => {
148
206
  let sumCache = 0, sumTotal = 0;
149
207
  for (let i = start; i < end && i < msgs.length; i++) {
150
- const input = msgs[i].inputTokens;
151
- const cache = msgs[i].cacheRead;
152
- sumCache += cache;
153
- sumTotal += input + cache;
208
+ sumCache += msgs[i].cacheRead;
209
+ sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
154
210
  }
155
211
  return { sumCache, sumTotal };
156
212
  };
@@ -163,7 +219,13 @@ export function TokenWatchPanel(props) {
163
219
  });
164
220
  });
165
221
  const [partVersion, setPartVersion] = createSignal(0);
222
+ const perfStats = createMemo(() => {
223
+ void props.allTokenMessages();
224
+ void partVersion();
225
+ return perfTracker.getSessionStats();
226
+ });
166
227
  const tokenDistribution = createMemo(() => {
228
+ void props.allTokenMessages();
167
229
  void partVersion();
168
230
  const dist = {};
169
231
  try {
@@ -209,6 +271,7 @@ export function TokenWatchPanel(props) {
209
271
  catch {
210
272
  continue;
211
273
  }
274
+ let msgEstimatedOutput = 0;
212
275
  for (const p of parts) {
213
276
  if (p.type === "tool") {
214
277
  let rawInput = "";
@@ -230,20 +293,36 @@ export function TokenWatchPanel(props) {
230
293
  dist.toolResult = (dist.toolResult ?? 0) + estimateTokens(p.state.error);
231
294
  }
232
295
  }
296
+ else if (p.type === "text" && p.text) {
297
+ msgEstimatedOutput += estimateTokens(p.text);
298
+ }
233
299
  else if (p.type === "reasoning") {
234
- dist.agent = (dist.agent ?? 0) + estimateTokens(p.text ?? "");
300
+ msgEstimatedOutput += estimateTokens(p.text ?? "");
235
301
  }
236
302
  else if (p.type === "subtask") {
237
- dist.agent = (dist.agent ?? 0) + estimateTokens(p.prompt || p.description || "");
303
+ msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
238
304
  }
239
305
  }
240
306
  const tokens = msg.tokens;
241
- if (tokens?.output)
242
- dist.output = (dist.output ?? 0) + tokens.output;
307
+ if (tokens?.output !== undefined || tokens?.reasoning !== undefined) {
308
+ dist.output = (dist.output ?? 0) + (tokens?.output ?? 0) + (tokens?.reasoning ?? 0);
309
+ }
310
+ else {
311
+ dist.output = (dist.output ?? 0) + msgEstimatedOutput;
312
+ }
243
313
  }
244
314
  }
315
+ const realInput = sessionTotals().totalInput;
316
+ if (realInput > 0) {
317
+ const estimated = (dist.system ?? 0) + (dist.user ?? 0)
318
+ + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
319
+ const other = realInput - estimated;
320
+ if (other > 50)
321
+ dist.other = other;
322
+ }
245
323
  return dist;
246
324
  });
325
+ // ── 折叠状态 toggle ──
247
326
  const toggle = {
248
327
  global: () => setCollapse(p => { const n = { ...p, global: !p.global }; saveCollapseState(api, n); return n; }),
249
328
  model: (k) => setCollapse(p => { const n = { ...p, models: { ...p.models, [k]: !p.models[k] } }; saveCollapseState(api, n); return n; }),
@@ -251,112 +330,262 @@ export function TokenWatchPanel(props) {
251
330
  };
252
331
  onMount(() => {
253
332
  const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
254
- const unsubMsg = api.event?.on?.("message.updated", () => setPartVersion(v => v + 1));
255
333
  onCleanup(() => { try {
256
334
  unsubPart?.();
257
- unsubMsg?.();
258
335
  }
259
336
  catch { } });
260
337
  });
261
- return (<box flexDirection="column" width={panelWidth()}>
262
- <box onMouseDown={toggle.global}>
338
+ // ── 宽度派生值 ──
339
+ // innerWidth:边框内可用列数 = panelWidth - 2(左右边框各1格)
340
+ // barWidth:进度条宽 = innerWidth - paddingX(1*2) - "Cache: "(7) - " XX%"(4) - "↑X.X%"(最多6) = innerWidth - 19
341
+ // 分隔线:innerWidth - paddingX(1*2) = innerWidth - 2
342
+ const innerWidth = () => panelWidth() - 2;
343
+ const barWidth = () => Math.max(8, innerWidth() - 19);
344
+ const divider = () => {
345
+ const w = innerWidth();
346
+ if (w <= 2)
347
+ return "─".repeat(w);
348
+ return " " + "─".repeat(w - 2) + " ";
349
+ };
350
+ return (<box
351
+ // ── 不设置固定 width,让外层容器决定宽度 ──
352
+ // ref + onSizeChange:布局完成后获取真实宽度,用于内部字符宽度计算
353
+ ref={(el) => { outerBoxRef = el; }} onSizeChange={() => {
354
+ if (outerBoxRef)
355
+ setPanelWidth(outerBoxRef.width);
356
+ }} flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
357
+
358
+ {/* ══════════════════════════════════════
359
+ 面板 Header:▾ TokenWatch 89.1% hit
360
+ justifyContent="space-between" 左右分布
361
+ ══════════════════════════════════════ */}
362
+ <box flexDirection="row" justifyContent="space-between" onMouseDown={toggle.global} paddingX={1}>
263
363
  <text fg={primaryColor()}>
264
- {collapse().global ? "▶" : ""} {t("panelTitle")}
265
- {collapse().global ? ` ${t("cacheRead")}:${formatTokens(sessionTotals().totalCacheRead)} ${t("requests")}:${sessionTotals().totalRequests}` : ""}
364
+ {collapse().global ? "▶" : ""} {t("panelTitle")}
365
+ </text>
366
+ <text fg={mutedColor()}>
367
+ {collapse().global ? (<>
368
+ {formatTokens(sessionTotals().totalTokens)}
369
+ {globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
370
+ {` (${globalHitRate().toFixed(1)}% hit)`}
371
+ </span>) : ""}
372
+ </>) : (globalHitRate() >= 0 ? (<span style={{ fg: hitRateColor(globalHitRate()) }}>
373
+ {`${globalHitRate().toFixed(1)}% hit`}
374
+ </span>) : "")}
266
375
  </text>
267
376
  </box>
268
377
 
269
378
  <Show when={!collapse().global}>
270
- <text fg={mutedColor()}>
271
- {t("total")}:{formatTokens(sessionTotals().totalTokens)} {t("requests")}:{sessionTotals().totalRequests}
272
- </text>
273
- <text fg={mutedColor()}>
274
- {t("input")}:{formatTokens(sessionTotals().totalInput)} {t("output")}:{formatTokens(sessionTotals().totalOutput)} {t("cacheRead")}:{formatTokens(sessionTotals().totalCacheRead)}
275
- </text>
276
- <Show when={sessionTotals().totalCost > 0}>
277
- <text fg={mutedColor()}>
278
- {t("cost")}:{formatCost(sessionTotals().totalCost)}
279
- </text>
379
+
380
+ {/* 标题下分隔线 */}
381
+ <text fg={borderColor()}>{divider()}</text>
382
+
383
+ {/* ══════════════════════════════════════
384
+ 全局统计:Total / Req / Input / Output 均匀分行排布
385
+ ══════════════════════════════════════ */}
386
+ <box flexDirection="row" paddingX={1}>
387
+ <For each={[
388
+ { val: formatTokens(sessionTotals().totalTokens), lbl: t("total") },
389
+ { val: sessionTotals().totalRequests.toString(), lbl: t("requests") },
390
+ { val: formatTokens(sessionTotals().totalInput), lbl: t("input") },
391
+ { val: formatTokens(sessionTotals().totalOutput), lbl: t("output") }
392
+ ]}>
393
+ {(item, idx) => {
394
+ const colW = () => {
395
+ const totalW = panelWidth() - 4;
396
+ const base = Math.floor(totalW / 4);
397
+ return idx() === 3 ? totalW - base * 3 : base;
398
+ };
399
+ return (<box width={colW()} flexDirection="column">
400
+ <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
401
+ <text fg={dimColor()}>
402
+ {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
403
+ </text>
404
+ </box>);
405
+ }}
406
+ </For>
407
+ </box>
408
+
409
+ {/* 成本展示 */}
410
+ <Show when={config().sidebar.showPricing && sessionTotals().totalCost > 0}>
411
+ <box flexDirection="row" justifyContent="center" marginTop={1}>
412
+ <text fg={mutedColor()}>
413
+ {t("cost")}:{" "}
414
+ <span style={{ fg: greenColor() }}>
415
+ {formatCost(sessionTotals().totalCost)}
416
+ </span>
417
+ </text>
418
+ </box>
280
419
  </Show>
281
420
 
421
+ {/* ══════════════════════════════════════
422
+ 各模型块:无分隔线,用 marginTop=1 隔开
423
+ ══════════════════════════════════════ */}
282
424
  <For each={modelStats()}>
283
425
  {([key, stat]) => {
284
- const modelCollapsed = () => collapse().models[key] !== true;
285
- const totalInput = stat.totalInput + stat.cacheRead;
286
- const hitRate = totalInput > 0 ? (stat.cacheRead / totalInput) * 100 : 0;
287
- const trendData = modelTrend().find(h => h.key === key);
288
- const trendStr = trendData?.trend !== null && trendData?.trend !== undefined && trendData.trend !== 0
289
- ? (trendData.trend >= 0 ? `${t("trendUp")}${trendData.trend.toFixed(1)}%` : `${t("trendDown")}${Math.abs(trendData.trend).toFixed(1)}%`)
290
- : "";
291
- const title = `${stat.providerID}/${stat.modelID}`;
292
- const shortTitle = title.length > 32 ? title.slice(0, 30) + "" : title;
293
- return (<box flexDirection="column">
294
- <box onMouseDown={() => toggle.model(key)}>
295
- <text fg={primaryColor()}>{modelCollapsed() ? "▼" : "▶"} {shortTitle}</text>
296
- </box>
297
- <text fg={mutedColor()}>
298
- {t("total")}:{formatTokens(stat.totalInput + stat.totalOutput + stat.cacheRead + stat.cacheWrite)} {t("requests")}:{stat.requestCount}
299
- </text>
300
- <text fg={mutedColor()}>
301
- {t("input")}:{formatTokens(stat.totalInput)} {t("output")}:{formatTokens(stat.totalOutput)}
302
- </text>
303
- <text fg={mutedColor()}>
304
- {t("cache")}:{formatTokens(stat.cacheRead + stat.cacheWrite)}(
305
- <span style={{ fg: hitRateColor(hitRate) }}>
306
- {progressFilled(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
307
- {progressRemaining(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
308
- {" "}{hitRate.toFixed(0)}%
309
- </span>
310
- {trendStr ? ` ${trendStr}` : ""})
311
- </text>
312
- <Show when={stat.totalCost > 0}>
426
+ const isExpanded = () => collapse().models[key] !== true;
427
+ const hitDenom = stat.totalInput + stat.cacheRead;
428
+ const hitRate = hitDenom > 0 ? (stat.cacheRead / hitDenom) * 100 : 0;
429
+ const trendStr = () => {
430
+ if (!config().sidebar.showTrend)
431
+ return "";
432
+ const td = modelTrend().find(h => h.key === key);
433
+ if (!td?.trend || td.trend === 0)
434
+ return "";
435
+ return td.trend > 0
436
+ ? ` ${t("trendUp")}${td.trend.toFixed(1)}%`
437
+ : ` ${t("trendDown")}${Math.abs(td.trend).toFixed(1)}%`;
438
+ };
439
+ const trendColor = () => {
440
+ const td = modelTrend().find(h => h.key === key);
441
+ return (td?.trend ?? 0) >= 0
442
+ ? RGBA.fromInts(63, 185, 80, 255)
443
+ : RGBA.fromInts(244, 67, 54, 255);
444
+ };
445
+ // 模型名处理:假如总长超过22字符,且格式为 厂商/模型厂商/模型名称,去除中间模型厂商,只留 provider/modelname 格式
446
+ let fullTitle = `${stat.providerID}/${stat.modelID}`;
447
+ if (fullTitle.length > 22) {
448
+ const parts = fullTitle.split("/");
449
+ if (parts.length >= 3) {
450
+ fullTitle = `${parts[0]}/${parts[parts.length - 1]}`;
451
+ }
452
+ }
453
+ // 模型名截断:内容宽 - paddingX(2) - "● "(2) - " ×NNN ▾"(最多8) = innerWidth - 12
454
+ const maxNameLen = Math.max(8, innerWidth() - 12);
455
+ const shortTitle = fullTitle.length > maxNameLen
456
+ ? fullTitle.slice(0, maxNameLen - 1) + "…"
457
+ : fullTitle;
458
+ // 折叠:右侧显示总 token;展开:右侧显示请求数
459
+ const modelHeaderRight = () => {
460
+ if (!isExpanded()) {
461
+ const total = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
462
+ return `${formatTokens(total)} ▶`;
463
+ }
464
+ return `×${stat.requestCount} ▾`;
465
+ };
466
+ // 计算模型总 tokens
467
+ const modelTotalTokens = stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite;
468
+ // 计算对齐标签 (使用 getter 以保持响应式切换)
469
+ const targetW = () => {
470
+ const cacheLabel = t("cache") + ":";
471
+ const costLabel = t("cost") + ":";
472
+ return Math.max(getVisualWidth(cacheLabel), getVisualWidth(costLabel));
473
+ };
474
+ const paddedCachePrefix = () => {
475
+ const label = t("cache") + ":";
476
+ return label + " ".repeat(targetW() - getVisualWidth(label));
477
+ };
478
+ const paddedCostPrefix = () => {
479
+ const label = t("cost") + ":";
480
+ return label + " ".repeat(targetW() - getVisualWidth(label));
481
+ };
482
+ // 缓存进度条宽度:可用宽度 panelWidth() - 4 减去前缀 targetW(),减去百分比(4),减去趋势(6)
483
+ const modelBarWidth = () => Math.max(8, (panelWidth() - 4) - targetW() - 11);
484
+ return (
485
+ // marginTop=1 提供模型间视觉间距(TUI最小单位为1行)
486
+ <box flexDirection="column" marginTop={1}>
487
+
488
+ {/* 模型 Header:左侧 ● 名称,右侧 统计+箭头 */}
489
+ <box flexDirection="row" justifyContent="space-between" onMouseDown={() => toggle.model(key)} paddingX={1}>
313
490
  <text fg={mutedColor()}>
314
- {t("cost")}:{formatCost(stat.totalCost)}
491
+ <span style={{ fg: hitRateColor(hitRate) }}>●</span>
492
+ {" "}
493
+ <span style={{ fg: primaryColor() }}>{shortTitle}</span>
315
494
  </text>
316
- </Show>
495
+ <text fg={mutedColor()}>{modelHeaderRight()}</text>
496
+ </box>
317
497
 
318
- <Show when={modelCollapsed()}>
319
- <Show when={config().sidebar.showPerformance}>
320
- <box flexDirection="column">
321
- <box onMouseDown={() => toggle.sub(`perf-${key}`)}>
322
- <text fg={textColor()}>{!collapse().subBlocks[`perf-${key}`] ? "▼" : "▶"} ── {t("performance")} ──</text>
323
- </box>
324
- <Show when={!collapse().subBlocks[`perf-${key}`]}>
325
- <text fg={mutedColor()}>
326
- {t("ttft")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgTTFT ?? null)} {t("tps")}:{perfTracker.getSessionStats().models[key]?.avgTPS?.toFixed(1) ?? "—"} {t("latency")}:{formatDuration(perfTracker.getSessionStats().models[key]?.avgLatency ?? null)}
327
- </text>
328
- </Show>
329
- </box>
330
- </Show>
498
+ <Show when={isExpanded()}>
499
+ <box flexDirection="column" paddingX={1}>
331
500
 
332
- <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
333
- <box flexDirection="column">
334
- <box onMouseDown={() => toggle.sub(`pricing-${key}`)}>
335
- <text fg={textColor()}>{!collapse().subBlocks[`pricing-${key}`] ? "▼" : "▶"} ── {t("pricing")} ──</text>
501
+ {/* 模型指标三列网格,外带圆角边框 (取消上下间距) */}
502
+ <box flexDirection="column" border={true} borderStyle="rounded" borderColor={borderColor()}>
503
+ <box flexDirection="row">
504
+ <For each={[
505
+ { val: formatTokens(modelTotalTokens), lbl: t("total") },
506
+ { val: formatTokens(stat.totalInput), lbl: t("input") },
507
+ { val: formatTokens(stat.totalOutput), lbl: t("output") }
508
+ ]}>
509
+ {(item, idx) => {
510
+ const colW = () => {
511
+ const totalW = panelWidth() - 6; // 边框占用 2 列
512
+ const base = Math.floor(totalW / 3);
513
+ return idx() === 2 ? totalW - base * 2 : base;
514
+ };
515
+ return (<box width={colW()} flexDirection="column">
516
+ <text fg={primaryColor()}>{centerAlign(item.val, colW())}</text>
517
+ <text fg={dimColor()}>
518
+ {centerAlign(isEnglish(item.lbl) ? item.lbl.toUpperCase() : item.lbl, colW())}
519
+ </text>
520
+ </box>);
521
+ }}
522
+ </For>
336
523
  </box>
337
- <Show when={!collapse().subBlocks[`pricing-${key}`]}>
338
- <text fg={mutedColor()}> {t("cost")}:{formatCost(stat.totalCost)}</text>
339
- <text fg={mutedColor()}> {t("modelLabel")}:{stat.providerID}/{stat.modelID}</text>
340
- </Show>
341
524
  </box>
342
- </Show>
525
+
526
+ {/* 缓存进度条 */}
527
+ <text fg={mutedColor()}>
528
+ {paddedCachePrefix()}
529
+ <span style={{ fg: hitRateColor(hitRate) }}>
530
+ {progressFilled(hitRate, modelBarWidth())}{progressRemaining(hitRate, modelBarWidth())}{" "}{hitRate.toFixed(0)}%
531
+ </span>
532
+ {trendStr()
533
+ ? <span style={{ fg: trendColor() }}>{trendStr()}</span>
534
+ : null}
535
+ </text>
536
+
537
+ {/* 性能指标 */}
538
+ <Show when={config().sidebar.showPerformance && !!perfStats().models[key]}>
539
+ <text fg={mutedColor()} marginTop={1}>
540
+ {t("ttft")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgTTFT ?? null)}</span>
541
+ {" "}{t("tps")} <span style={{ fg: primaryColor() }}>{perfStats().models[key]?.avgTPS?.toFixed(1) ?? "—"}</span>
542
+ {" "}{t("lat")} <span style={{ fg: primaryColor() }}>{formatDuration(perfStats().models[key]?.avgLatency ?? null)}</span>
543
+ </text>
544
+ </Show>
545
+
546
+ {/* 成本 */}
547
+ <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
548
+ <text fg={mutedColor()}>{paddedCostPrefix()}{formatCost(stat.totalCost)}</text>
549
+ </Show>
550
+
551
+ </box>
343
552
  </Show>
344
553
  </box>);
345
554
  }}
346
555
  </For>
347
556
 
557
+ {/* ══════════════════════════════════════
558
+ Token 分布区块:左右 space-between 对齐布局 (取消进度条)
559
+ ══════════════════════════════════════ */}
348
560
  <Show when={config().sidebar.showTokenDistribution}>
349
- <box flexDirection="column">
350
- <box onMouseDown={() => toggle.sub("token-dist")}>
351
- <text fg={primaryColor()}>{!collapse().subBlocks["token-dist"] ? "▼" : "▶"} ── {t("tokenDistribution")} ──</text>
561
+ <box flexDirection="column" marginTop={1}>
562
+
563
+ {/* 分隔线 */}
564
+ <text fg={borderColor()}>{divider()}</text>
565
+
566
+ {/* Header */}
567
+ <box flexDirection="row" onMouseDown={() => toggle.sub("token-dist")} paddingX={1}>
568
+ <text fg={greenColor()}>
569
+ {!collapse().subBlocks["token-dist"] ? "▾" : "▶"} {t("tokenDistribution")}
570
+ </text>
352
571
  </box>
572
+
353
573
  <Show when={!collapse().subBlocks["token-dist"]}>
354
- <For each={Object.entries(tokenDistribution())}>
355
- {([role, tokens]) => (<text fg={mutedColor()}> {t(role)}:{formatTokens(tokens)}</text>)}
356
- </For>
574
+ <box flexDirection="column" paddingX={1} marginTop={1}>
575
+ <For each={Object.entries(tokenDistribution()).filter(([_, val]) => val > 0)}>
576
+ {([role, val]) => (<box flexDirection="row" justifyContent="space-between">
577
+ <box flexDirection="row">
578
+ <text fg={distRoleColor(role)}>█ </text>
579
+ <text fg={mutedColor()}>{t(role)}</text>
580
+ </box>
581
+ <text fg={mutedColor()}>{formatTokens(val)}</text>
582
+ </box>)}
583
+ </For>
584
+ </box>
357
585
  </Show>
358
586
  </box>
359
587
  </Show>
588
+
360
589
  </Show>
361
590
  </box>);
362
591
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * stats-store.ts — 持久化聚合统计存储
3
+ *
4
+ * 设计目标:将性能指标的"聚合统计"与"原始 JSONL 日志"彻底解耦。
5
+ * - 每次请求完成时,通过 updatePersistedStats() 增量写入 JSON 统计文件
6
+ * - 统计文件永久累积,不受 JSONL 日志轮转/窗口限制影响
7
+ * - 百分位数采用 Reservoir Sampling 保持有界内存占用
8
+ * - 首次启动时自动从现有 JSONL 日志迁移,不丢失历史数据
9
+ */
10
+ import type { LogEntry, ModelPerfStats } from "./formatter.js";
11
+ /**
12
+ * 将一条新的日志条目增量更新到持久化统计文件。
13
+ * 在 perf-tracker.ts 的 appendLog() 之后调用。
14
+ *
15
+ * 设计原则:本函数只做增量更新,迁移逻辑由 readPersistedStats() 负责。
16
+ * 这样可以避免迁移与增量更新之间的竞态问题。
17
+ */
18
+ export declare function updatePersistedStats(entry: LogEntry): void;
19
+ /**
20
+ * 读取所有持久化统计,返回 ModelPerfStats 数组(含分位数)。
21
+ * 用于 HTML 报告生成,替代 aggregatePerfStats(readLogs(N)) 的有限窗口方案。
22
+ */
23
+ export declare function readPersistedStats(): ModelPerfStats[];