opencode-tokenwatch 0.3.0 → 0.3.2

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,30 +135,43 @@ 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
+ const msgs = props.allTokenMessages();
153
+ for (let i = 0; i < msgs.length; i++) {
154
+ const msg = msgs[i];
101
155
  const key = `${msg.providerID}/${msg.modelID}`;
102
156
  let e = map.get(key);
103
157
  if (!e) {
104
- // Bug fix: 初始化时加入 totalReasoning 字段
105
- e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0 };
158
+ e = { providerID: msg.providerID, modelID: msg.modelID, totalInput: 0, totalOutput: 0, totalReasoning: 0, cacheRead: 0, cacheWrite: 0, totalCost: 0, requestCount: 0, lastMessageIndex: -1 };
106
159
  map.set(key, e);
107
160
  }
108
161
  e.totalInput += msg.inputTokens;
109
162
  e.totalOutput += msg.outputTokens;
110
- e.totalReasoning += msg.reasoningTokens; // Bug fix: 聚合 reasoning
163
+ e.totalReasoning += msg.reasoningTokens;
111
164
  e.cacheRead += msg.cacheRead;
112
165
  e.cacheWrite += msg.cacheWrite;
113
166
  e.totalCost += msg.cost;
114
167
  e.requestCount++;
168
+ e.lastMessageIndex = i; // 追踪该模型最后一条消息的索引(越大 = 越近调用)
115
169
  }
116
- return Array.from(map.entries()).sort((a, b) => (b[1].totalInput + b[1].totalOutput) - (a[1].totalInput + a[1].totalOutput));
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);
117
175
  });
118
176
  const sessionTotals = createMemo(() => {
119
177
  let i = 0, o = 0, ir = 0, cr = 0, cw = 0, r = 0, c = 0;
@@ -126,16 +184,19 @@ export function TokenWatchPanel(props) {
126
184
  r += s.requestCount;
127
185
  c += s.totalCost;
128
186
  }
129
- // Bug fix: totalTokens 改为 5 分量(含 reasoning),与官方一致
130
187
  return { totalInput: i, totalOutput: o, totalReasoning: ir, totalCacheRead: cr, totalCacheWrite: cw, totalRequests: r, totalCost: c, totalTokens: i + o + ir + cr + cw };
131
188
  });
189
+ const globalHitRate = createMemo(() => {
190
+ const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
191
+ return denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : -1;
192
+ });
132
193
  const modelHitRate = createMemo(() => {
133
194
  return modelStats().map(([key, stat]) => {
134
195
  const denom = stat.totalInput + stat.cacheRead;
135
196
  if (denom === 0)
136
197
  return { key, rate: 0, msgs: [] };
137
198
  const msgs = [];
138
- for (const msg of props.allTokenMessages) {
199
+ for (const msg of props.allTokenMessages()) {
139
200
  const pk = `${msg.providerID}/${msg.modelID}`;
140
201
  if (pk !== key)
141
202
  continue;
@@ -151,10 +212,8 @@ export function TokenWatchPanel(props) {
151
212
  const sumSlice = (start, end) => {
152
213
  let sumCache = 0, sumTotal = 0;
153
214
  for (let i = start; i < end && i < msgs.length; i++) {
154
- const input = msgs[i].inputTokens;
155
- const cache = msgs[i].cacheRead;
156
- sumCache += cache;
157
- sumTotal += input + cache;
215
+ sumCache += msgs[i].cacheRead;
216
+ sumTotal += msgs[i].inputTokens + msgs[i].cacheRead;
158
217
  }
159
218
  return { sumCache, sumTotal };
160
219
  };
@@ -167,7 +226,13 @@ export function TokenWatchPanel(props) {
167
226
  });
168
227
  });
169
228
  const [partVersion, setPartVersion] = createSignal(0);
229
+ const perfStats = createMemo(() => {
230
+ void props.allTokenMessages();
231
+ void partVersion();
232
+ return perfTracker.getSessionStats();
233
+ });
170
234
  const tokenDistribution = createMemo(() => {
235
+ void props.allTokenMessages();
171
236
  void partVersion();
172
237
  const dist = {};
173
238
  try {
@@ -213,6 +278,7 @@ export function TokenWatchPanel(props) {
213
278
  catch {
214
279
  continue;
215
280
  }
281
+ let msgEstimatedOutput = 0;
216
282
  for (const p of parts) {
217
283
  if (p.type === "tool") {
218
284
  let rawInput = "";
@@ -235,161 +301,304 @@ export function TokenWatchPanel(props) {
235
301
  }
236
302
  }
237
303
  else if (p.type === "text" && p.text) {
238
- // Bug fix: assistant text part(主要 LLM 回复内容)之前未计入分布
239
- // 此处用 estimateTokens 估算,最终 dist.output 会被下方真实 tokens.output 覆盖
240
- dist.output = (dist.output ?? 0) + estimateTokens(p.text);
304
+ msgEstimatedOutput += estimateTokens(p.text);
241
305
  }
242
306
  else if (p.type === "reasoning") {
243
- dist.agent = (dist.agent ?? 0) + estimateTokens(p.text ?? "");
307
+ msgEstimatedOutput += estimateTokens(p.text ?? "");
244
308
  }
245
309
  else if (p.type === "subtask") {
246
- dist.agent = (dist.agent ?? 0) + estimateTokens(p.prompt || p.description || "");
310
+ msgEstimatedOutput += estimateTokens(p.prompt || p.description || "");
247
311
  }
248
312
  }
249
313
  const tokens = msg.tokens;
250
- // 真实 output token 数优先:覆盖上面的 text part 估算
251
- if (tokens?.output)
252
- dist.output = (dist.output ?? 0) + tokens.output;
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
+ }
253
320
  }
254
321
  }
255
- // Bug fix: 添加 other 兜底桶(参考官方 session-context-breakdown.ts)
256
- // 使用真实 input token 数减去各桶估算值,确保分布总和对齐
257
322
  const realInput = sessionTotals().totalInput;
258
323
  if (realInput > 0) {
259
324
  const estimated = (dist.system ?? 0) + (dist.user ?? 0)
260
- + (dist.agent ?? 0) + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
325
+ + (dist.toolCall ?? 0) + (dist.toolResult ?? 0);
261
326
  const other = realInput - estimated;
262
- // 只在差值超过 50 token 时展示,避免估算误差噪音
263
327
  if (other > 50)
264
328
  dist.other = other;
265
329
  }
266
330
  return dist;
267
331
  });
332
+ // ── 折叠状态 toggle ──
268
333
  const toggle = {
269
334
  global: () => setCollapse(p => { const n = { ...p, global: !p.global }; saveCollapseState(api, n); return n; }),
270
335
  model: (k) => setCollapse(p => { const n = { ...p, models: { ...p.models, [k]: !p.models[k] } }; saveCollapseState(api, n); return n; }),
271
336
  sub: (k) => setCollapse(p => { const n = { ...p, subBlocks: { ...p.subBlocks, [k]: !p.subBlocks[k] } }; saveCollapseState(api, n); return n; }),
272
337
  };
273
338
  onMount(() => {
274
- // Risk fix: 移除 message.updated 的重复订阅。
275
- // tui.tsx 已通过 setSidebarRevision() 驱动 sidebar 整体重渲,
276
- // sidebar 内部只需订阅 part.updated 来触发 tokenDistribution 重算。
277
339
  const unsubPart = api.event?.on?.("message.part.updated", () => setPartVersion(v => v + 1));
278
340
  onCleanup(() => { try {
279
341
  unsubPart?.();
280
342
  }
281
343
  catch { } });
282
344
  });
283
- return (<box flexDirection="column" width={panelWidth()}>
284
- <box onMouseDown={toggle.global}>
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}>
285
370
  <text fg={primaryColor()}>
286
- {collapse().global ? "▶" : ""} {t("panelTitle")}
287
- {collapse().global ? ` ${t("cacheRead")}:${formatTokens(sessionTotals().totalCacheRead)} ${t("requests")}:${sessionTotals().totalRequests}` : ""}
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>) : "")}
288
382
  </text>
289
383
  </box>
290
384
 
291
385
  <Show when={!collapse().global}>
292
- <text fg={mutedColor()}>
293
- {t("total")}:{formatTokens(sessionTotals().totalTokens)} {t("requests")}:{sessionTotals().totalRequests}
294
- </text>
295
- <text fg={mutedColor()}>
296
- {t("input")}:{formatTokens(sessionTotals().totalInput)} {t("output")}:{formatTokens(sessionTotals().totalOutput)} {t("cacheRead")}:{formatTokens(sessionTotals().totalCacheRead)}
297
- </text>
298
- {/* 全局加权缓存命中率(按 token 加权:totalCacheRead / (totalCacheRead + totalInput)) */}
299
- <Show when={(sessionTotals().totalInput + sessionTotals().totalCacheRead) > 0}>
300
- <text fg={mutedColor()}>
301
- {t("cache")}Hit:{(() => {
302
- const denom = sessionTotals().totalInput + sessionTotals().totalCacheRead;
303
- const rate = denom > 0 ? (sessionTotals().totalCacheRead / denom) * 100 : 0;
304
- return rate.toFixed(1) + "%";
305
- })()}(global)
306
- </text>
307
- </Show>
308
- <Show when={sessionTotals().totalCost > 0}>
309
- <text fg={mutedColor()}>
310
- {t("cost")}:{formatCost(sessionTotals().totalCost)}
311
- </text>
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>
312
426
  </Show>
313
427
 
428
+ {/* ══════════════════════════════════════
429
+ 各模型块:无分隔线,用 marginTop=1 隔开
430
+ ══════════════════════════════════════ */}
314
431
  <For each={modelStats()}>
315
432
  {([key, stat]) => {
316
- const modelCollapsed = () => collapse().models[key] !== true;
317
- const totalInput = stat.totalInput + stat.cacheRead;
318
- const hitRate = totalInput > 0 ? (stat.cacheRead / totalInput) * 100 : 0;
319
- const trendData = modelTrend().find(h => h.key === key);
320
- const trendStr = trendData?.trend !== null && trendData?.trend !== undefined && trendData.trend !== 0
321
- ? (trendData.trend >= 0 ? `${t("trendUp")}${trendData.trend.toFixed(1)}%` : `${t("trendDown")}${Math.abs(trendData.trend).toFixed(1)}%`)
322
- : "";
323
- const title = `${stat.providerID}/${stat.modelID}`;
324
- const shortTitle = title.length > 32 ? title.slice(0, 30) + "" : title;
325
- return (<box flexDirection="column">
326
- <box onMouseDown={() => toggle.model(key)}>
327
- <text fg={primaryColor()}>{modelCollapsed() ? "▼" : "▶"} {shortTitle}</text>
328
- </box>
329
- <text fg={mutedColor()}>
330
- {/* Bug fix: total 改为 5 分量(含 reasoning) */}
331
- {t("total")}:{formatTokens(stat.totalInput + stat.totalOutput + stat.totalReasoning + stat.cacheRead + stat.cacheWrite)} {t("requests")}:{stat.requestCount}
332
- </text>
333
- <text fg={mutedColor()}>
334
- {t("input")}:{formatTokens(stat.totalInput)} {t("output")}:{formatTokens(stat.totalOutput)}
335
- </text>
336
- <text fg={mutedColor()}>
337
- {t("cache")}:{formatTokens(stat.cacheRead + stat.cacheWrite)}(
338
- <span style={{ fg: hitRateColor(hitRate) }}>
339
- {progressFilled(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
340
- {progressRemaining(hitRate, Math.max(3, Math.floor(panelWidth() / 3)))}
341
- {" "}{hitRate.toFixed(0)}%
342
- </span>
343
- {trendStr ? ` ${trendStr}` : ""})
344
- </text>
345
- <Show when={stat.totalCost > 0}>
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}>
346
503
  <text fg={mutedColor()}>
347
- {t("cost")}:{formatCost(stat.totalCost)}
504
+ <span style={{ fg: hitRateColor(hitRate) }}>●</span>
505
+ {" "}
506
+ <span style={{ fg: primaryColor() }}>{shortTitle}</span>
348
507
  </text>
349
- </Show>
508
+ <text fg={mutedColor()}>{modelHeaderRight()}</text>
509
+ </box>
350
510
 
351
- <Show when={modelCollapsed()}>
352
- <Show when={config().sidebar.showPerformance}>
353
- <box flexDirection="column">
354
- <box onMouseDown={() => toggle.sub(`perf-${key}`)}>
355
- <text fg={textColor()}>{!collapse().subBlocks[`perf-${key}`] ? "▼" : "▶"} ── {t("performance")} ──</text>
356
- </box>
357
- <Show when={!collapse().subBlocks[`perf-${key}`]}>
358
- <text fg={mutedColor()}>
359
- {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)}
360
- </text>
361
- </Show>
362
- </box>
363
- </Show>
511
+ <Show when={isExpanded()}>
512
+ <box flexDirection="column" paddingX={1}>
364
513
 
365
- <Show when={config().sidebar.showPricing && stat.totalCost > 0}>
366
- <box flexDirection="column">
367
- <box onMouseDown={() => toggle.sub(`pricing-${key}`)}>
368
- <text fg={textColor()}>{!collapse().subBlocks[`pricing-${key}`] ? "▼" : "▶"} ── {t("pricing")} ──</text>
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>
369
536
  </box>
370
- <Show when={!collapse().subBlocks[`pricing-${key}`]}>
371
- <text fg={mutedColor()}> {t("cost")}:{formatCost(stat.totalCost)}</text>
372
- <text fg={mutedColor()}> {t("modelLabel")}:{stat.providerID}/{stat.modelID}</text>
373
- </Show>
374
537
  </box>
375
- </Show>
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>
376
565
  </Show>
377
566
  </box>);
378
567
  }}
379
568
  </For>
380
569
 
570
+ {/* ══════════════════════════════════════
571
+ Token 分布区块:左右 space-between 对齐布局 (取消进度条)
572
+ ══════════════════════════════════════ */}
381
573
  <Show when={config().sidebar.showTokenDistribution}>
382
- <box flexDirection="column">
383
- <box onMouseDown={() => toggle.sub("token-dist")}>
384
- <text fg={primaryColor()}>{!collapse().subBlocks["token-dist"] ? "▼" : "▶"} ── {t("tokenDistribution")} ──</text>
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>
385
584
  </box>
585
+
386
586
  <Show when={!collapse().subBlocks["token-dist"]}>
387
- <For each={Object.entries(tokenDistribution())}>
388
- {([role, tokens]) => (<text fg={mutedColor()}> {t(role)}:{formatTokens(tokens)}</text>)}
389
- </For>
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>
390
598
  </Show>
391
599
  </box>
392
600
  </Show>
601
+
393
602
  </Show>
394
603
  </box>);
395
604
  }
@@ -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[];