pi-cc-extensions 0.9.12 → 1.0.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/README.en.md CHANGED
@@ -63,6 +63,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
63
63
  "expandedCardBackground": "userMessageBg", // expanded card background slot: userMessageBg (same as user prompts) / toolPendingBg (neutral) etc.
64
64
  "toolLabelClip": 0, // tool summary path/command clip length; 0 = fit width
65
65
  "showStartupHeader": true, // startup header (logo + tips) toggle
66
+ "stickyUserMessage": true, // fullscreen: pin a user message's first line once it scrolls off the top edge, until the next one takes over
66
67
 
67
68
  // diff
68
69
  "diffViewMode": "auto", // layout: auto / split / unified
@@ -80,6 +81,8 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
80
81
 
81
82
  // footer
82
83
  "enableCustomFooter": true, // custom status bar
84
+ "footerTokenRate": true, // output rate chip; false hides it
85
+ "footerTokenRateMode": "average", // average: whole-prompt avg; live: trailing 1s rate
83
86
  "footerCurrency": "USD", // display currency for the footer cost
84
87
  "footerCurrencyRate": null, // display units per 1 cost unit; null changes the symbol only
85
88
  "footerNerdIcons": true, // Nerd Font glyphs for git/cache; false = plain text
@@ -92,7 +95,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
92
95
  ```
93
96
 
94
97
  > [!TIP]
95
- > **Fullscreen**: click `click to show more` to expand tool cards, thinking, Skill, and compact summaries. An expanded diff always shows every line; when expanded Input/Output exceeds the line cap, the footer `… +N more lines · click to show more` opens a full preview. Click to collapse (dragging inside the card selects text).
98
+ > **Fullscreen**: click `click to show more` to expand tool cards, thinking, Skill, and compact summaries. An expanded diff always shows every line; when expanded Input/Output exceeds the line cap, the footer `… +N more lines · click to show more` opens a full preview. Click to collapse.
96
99
 
97
100
  > [!NOTE]
98
101
  > **Mermaid rendering**: set `markdown.mermaid` to `final` via `~/.pi/agent/settings.json` or the Mermaid diagrams option in `/settings`. Default `streaming` redraws per frame; `final` renders once at completion.
package/README.md CHANGED
@@ -63,6 +63,7 @@ pi install git:github.com/minuque/pi-cc-extensions
63
63
  "expandedCardBackground": "userMessageBg", // 展开卡背景槽位;userMessageBg 与用户消息同色,toolPendingBg 为中性灰
64
64
  "toolLabelClip": 0, // 工具摘要 path/command 折叠字符数;0 = 按可用宽度
65
65
  "showStartupHeader": true, // 启动头(logo + tips)开关
66
+ "stickyUserMessage": true, // fullscreen:userMessage 正文首行滚出视口上沿后钉住,直到下一条指令接手
66
67
 
67
68
  // diff
68
69
  "diffViewMode": "auto", // 布局:auto / split / unified
@@ -80,6 +81,8 @@ pi install git:github.com/minuque/pi-cc-extensions
80
81
 
81
82
  // footer
82
83
  "enableCustomFooter": true, // 自定义状态栏
84
+ "footerTokenRate": true, // 输出速率芯片;false 隐藏
85
+ "footerTokenRateMode": "average", // average:整段指令平均(avg);live:最近 1 秒瞬时(live)
83
86
  "footerCurrency": "USD", // 底栏费用的显示币种
84
87
  "footerCurrencyRate": null, // 1 单位费用换成多少显示币种;null 只换符号、不乘倍率
85
88
  "footerNerdIcons": true, // git/缓存用 Nerd Font 图标;false 为纯文本
@@ -92,7 +95,7 @@ pi install git:github.com/minuque/pi-cc-extensions
92
95
  ```
93
96
 
94
97
  > [!TIP]
95
- > **全屏模式**:单击 `click to show more` 展开工具卡、思考、Skill 和 compact 摘要;工具卡展开态 diff 全量显示,Input/Output 超行时末行 `… +N more lines · click to show more` 打开全量预览,单击收起(卡内拖动为选中文本)。
98
+ > **全屏模式**:单击 `click to show more` 展开工具卡、思考、Skill 和 compact 摘要;工具卡展开态 diff 全量显示,Input/Output 超行时末行 `… +N more lines · click to show more` 打开全量预览,单击收起。
96
99
 
97
100
  > [!NOTE]
98
101
  > **Mermaid 渲染**:建议把 `markdown.mermaid` 设为 `final`(`~/.pi/agent/settings.json` 或 `/settings` 面板的 Mermaid diagrams 选项)。默认 `streaming` 逐帧重绘,`final` 渲染最终版更稳定。
@@ -14,6 +14,7 @@ export type CompactStyleMode = "on" | "compact" | "off";
14
14
  export type DiffViewMode = "auto" | "split" | "unified";
15
15
  export type DiffIndicatorMode = "bars" | "classic" | "none";
16
16
  export type FooterGitStatsMode = "working" | "branch";
17
+ export type FooterTokenRateMode = "average" | "live";
17
18
 
18
19
  export interface ToolDisplayConfig {
19
20
  diffViewMode: DiffViewMode;
@@ -65,6 +66,8 @@ export type Config = {
65
66
  animationIntervalMs: number;
66
67
  dimThinkingText: boolean;
67
68
  showStartupHeader: boolean;
69
+ /** fullscreen:userMessage 正文首行滚出视口上沿后钉住,直到下一条指令接手。 */
70
+ stickyUserMessage: boolean;
68
71
  enableSessionReference: boolean;
69
72
  enableSubagentAutocomplete: boolean;
70
73
  enableContextCommand: boolean;
@@ -72,6 +75,10 @@ export type Config = {
72
75
  enableWorkingMessage: boolean;
73
76
  enableAliases: boolean;
74
77
  enableCustomFooter: boolean;
78
+ /** footer 输出速率芯片。关则不显示。 */
79
+ footerTokenRate: boolean;
80
+ /** average:整段指令墙钟平均;live:最近 1 秒瞬时速率,空档回退平均。 */
81
+ footerTokenRateMode: FooterTokenRateMode;
75
82
  /** ISO 4217 显示币种。USD 时不换算。 */
76
83
  footerCurrency: string;
77
84
  /** 1 单位费用对应的显示币种数量。null 只换符号、不乘倍率。 */
@@ -91,6 +98,7 @@ const LEGACY_CONFIG_PATH = join(AGENT_DIR, "claude-code-style.json");
91
98
  export const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
92
99
  export const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
93
100
  export const FOOTER_GIT_STATS_MODES: FooterGitStatsMode[] = ["working", "branch"];
101
+ export const FOOTER_TOKEN_RATE_MODES: FooterTokenRateMode[] = ["average", "live"];
94
102
  export const DIFF_SPLIT_MIN_WIDTH_VALUES = ["80", "100", "120", "140", "160", "180"];
95
103
  export const DIFF_COLLAPSED_LINES_VALUES = ["0", "12", "24", "36", "48", "80", "120"];
96
104
  /** Write collapsed presets. 0 = stats only (`+N -0` + expand hint). */
@@ -153,6 +161,7 @@ export const DEFAULT_CONFIG: Config = {
153
161
  animationIntervalMs: 90,
154
162
  dimThinkingText: false,
155
163
  showStartupHeader: true,
164
+ stickyUserMessage: true,
156
165
  enableSessionReference: true,
157
166
  enableSubagentAutocomplete: true,
158
167
  enableContextCommand: true,
@@ -160,6 +169,8 @@ export const DEFAULT_CONFIG: Config = {
160
169
  enableWorkingMessage: true,
161
170
  enableAliases: true,
162
171
  enableCustomFooter: true,
172
+ footerTokenRate: true,
173
+ footerTokenRateMode: "average",
163
174
  footerCurrency: "USD",
164
175
  footerCurrencyRate: null,
165
176
  footerNerdIcons: true,
@@ -282,6 +293,7 @@ export function normalizeConfig(input: unknown): Config {
282
293
  ),
283
294
  dimThinkingText: source.dimThinkingText === true,
284
295
  showStartupHeader: source.showStartupHeader !== false,
296
+ stickyUserMessage: source.stickyUserMessage !== false,
285
297
  enableSessionReference: source.enableSessionReference !== false,
286
298
  enableSubagentAutocomplete: source.enableSubagentAutocomplete !== false,
287
299
  enableContextCommand: source.enableContextCommand !== false,
@@ -289,6 +301,12 @@ export function normalizeConfig(input: unknown): Config {
289
301
  enableWorkingMessage: source.enableWorkingMessage !== false,
290
302
  enableAliases: source.enableAliases !== false,
291
303
  enableCustomFooter: source.enableCustomFooter !== false,
304
+ footerTokenRate: source.footerTokenRate !== false,
305
+ footerTokenRateMode: pickEnum(
306
+ source.footerTokenRateMode,
307
+ FOOTER_TOKEN_RATE_MODES,
308
+ DEFAULT_CONFIG.footerTokenRateMode,
309
+ ),
292
310
  footerCurrency: normalizeCurrency(source.footerCurrency),
293
311
  footerCurrencyRate: normalizeFooterCurrencyRate(source.footerCurrencyRate),
294
312
  footerNerdIcons: source.footerNerdIcons !== false,
@@ -344,6 +362,7 @@ export function formatConfigStatus(source: Config = config): string {
344
362
  `thinkingAnimation=${source.animationIntervalMs}ms`,
345
363
  `thinkingDim=${source.dimThinkingText ? "on" : "off"}`,
346
364
  `startupHeader=${source.showStartupHeader ? "on" : "off"}`,
365
+ `stickyUserMessage=${source.stickyUserMessage ? "on" : "off"}`,
347
366
  `sessionRef=${source.enableSessionReference ? "on" : "off"}`,
348
367
  `subagentAuto=${source.enableSubagentAutocomplete ? "on" : "off"}`,
349
368
  `context=${source.enableContextCommand ? "on" : "off"}`,
@@ -351,6 +370,8 @@ export function formatConfigStatus(source: Config = config): string {
351
370
  `workingMsg=${source.enableWorkingMessage ? "on" : "off"}`,
352
371
  `aliases=${source.enableAliases ? "on" : "off"}`,
353
372
  `footer=${source.enableCustomFooter ? "on" : "off"}`,
373
+ `footerTokenRate=${source.footerTokenRate ? "on" : "off"}`,
374
+ `footerTokenRateMode=${source.footerTokenRateMode}`,
354
375
  `footerCurrency=${source.footerCurrency}`,
355
376
  `footerRate=${source.footerCurrencyRate ?? "-"}`,
356
377
  `footerIcons=${source.footerNerdIcons ? "nerd" : "plain"}`,
@@ -43,6 +43,7 @@ import {
43
43
  EXPANDED_OUTPUT_MAX_LINES_VALUES,
44
44
  EXPANDED_PREVIEW_MAX_LINES_VALUES,
45
45
  FOOTER_GIT_STATS_MODES,
46
+ FOOTER_TOKEN_RATE_MODES,
46
47
  formatExcludeRenderers,
47
48
  getCompactThinkingConfig,
48
49
  pickPositiveInt,
@@ -58,6 +59,7 @@ import {
58
59
  type DiffIndicatorMode,
59
60
  type DiffViewMode,
60
61
  type FooterGitStatsMode,
62
+ type FooterTokenRateMode,
61
63
  } from "./config.ts";
62
64
 
63
65
  /** renderer 注入的渲染副作用,面板自身不触碰渲染状态。 */
@@ -68,7 +70,7 @@ export type CcstylePanelHooks = {
68
70
 
69
71
  function modeSettingDescription(mode: CompactStyleMode): string {
70
72
  if (mode === "compact") {
71
- return "(Experimental) One summary line per assistant round; edit/write reuse the same Diff preview settings as on.";
73
+ return "One summary line per assistant round; edit/write reuse the same Diff preview settings as on.";
72
74
  }
73
75
  if (mode === "off") {
74
76
  return "Pi native tool rendering. Diff options below still apply independently.";
@@ -87,6 +89,18 @@ function customFooterDescription(enabled: boolean): string {
87
89
  return "Custom status bar with model, context, cache, cost, git, and plugin chips.";
88
90
  }
89
91
 
92
+ function footerTokenRateDescription(enabled: boolean): string {
93
+ return enabled
94
+ ? "Output rate chip after the cache hit rate. Turn off to hide it."
95
+ : "Output rate chip hidden.";
96
+ }
97
+
98
+ function footerTokenRateModeDescription(mode: FooterTokenRateMode): string {
99
+ return mode === "live"
100
+ ? "Sliding 1s rate while generating (live). Tool waits are excluded. Falls back to the run average between chunks and after the prompt."
101
+ : "Average over the whole user prompt (avg), including tool waits. Stays until the next prompt.";
102
+ }
103
+
90
104
  function footerGitStatsDescription(mode: FooterGitStatsMode): string {
91
105
  return mode === "branch"
92
106
  ? "Net changes since the merge base, including uncommitted edits. Base: origin/HEAD, then local main/master. Untracked files excluded; unavailable stats hidden."
@@ -422,8 +436,8 @@ export async function showCcstylePanel(
422
436
  id: "mode",
423
437
  label: "Mode",
424
438
  description: modeSettingDescription(config.mode),
425
- currentValue: config.mode === "compact" ? "compact (Experimental)" : config.mode,
426
- values: ["on", "compact (Experimental)", "off"],
439
+ currentValue: config.mode,
440
+ values: ["on", "compact", "off"],
427
441
  };
428
442
  // Tracks whether a nested submenu is open so Tab switches sections
429
443
  // only at the top level (mirrors Zentui settings: Tab = switch sections).
@@ -595,6 +609,15 @@ export async function showCcstylePanel(
595
609
  currentValue: config.showStartupHeader ? "on" : "off",
596
610
  values: ["on", "off"],
597
611
  };
612
+ const stickyUserMessageSetting = {
613
+ id: "stickyUserMessage",
614
+ label: "Sticky user message",
615
+ description: config.stickyUserMessage
616
+ ? "Fullscreen: pin a user message's first line once it scrolls off the top edge, until the next one takes over."
617
+ : "User messages scroll normally.",
618
+ currentValue: config.stickyUserMessage ? "on" : "off",
619
+ values: ["on", "off"],
620
+ };
598
621
 
599
622
  // 额外功能开关:注册于扩展加载期,切换后需重启(/reload)生效。
600
623
  const sessionReferenceToggle = featureToggleSetting(
@@ -721,6 +744,20 @@ export async function showCcstylePanel(
721
744
  currentValue: config.footerGitStatsMode,
722
745
  values: FOOTER_GIT_STATS_MODES,
723
746
  };
747
+ const footerTokenRateSetting = {
748
+ id: "footerTokenRate",
749
+ label: "Token rate",
750
+ description: footerTokenRateDescription(config.footerTokenRate),
751
+ currentValue: config.footerTokenRate ? "on" : "off",
752
+ values: ["on", "off"],
753
+ };
754
+ const footerTokenRateModeSetting = {
755
+ id: "footerTokenRateMode",
756
+ label: "Token rate mode",
757
+ description: footerTokenRateModeDescription(config.footerTokenRateMode),
758
+ currentValue: config.footerTokenRateMode,
759
+ values: FOOTER_TOKEN_RATE_MODES,
760
+ };
724
761
  const pluginChipsSetting = {
725
762
  id: "footerPluginChips",
726
763
  label: "Plugin chips",
@@ -793,6 +830,23 @@ export async function showCcstylePanel(
793
830
  ctx.ui.notify(`Updated ${id}: ${value}`, "info");
794
831
  return;
795
832
  }
833
+ if (id === "footerTokenRate") {
834
+ const enabled = value === "on";
835
+ updateConfig({ footerTokenRate: enabled });
836
+ footerTokenRateSetting.currentValue = enabled ? "on" : "off";
837
+ footerTokenRateSetting.description = footerTokenRateDescription(enabled);
838
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
839
+ return;
840
+ }
841
+ if (id === "footerTokenRateMode") {
842
+ updateConfig({ footerTokenRateMode: value as FooterTokenRateMode });
843
+ footerTokenRateModeSetting.currentValue = config.footerTokenRateMode;
844
+ footerTokenRateModeSetting.description = footerTokenRateModeDescription(
845
+ config.footerTokenRateMode,
846
+ );
847
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
848
+ return;
849
+ }
796
850
  if (id === "footerNerdIcons") {
797
851
  const enabled = value === "on";
798
852
  updateConfig({ footerNerdIcons: enabled });
@@ -819,9 +873,7 @@ export async function showCcstylePanel(
819
873
  toolLabelClipSetting.currentValue = String(config.toolLabelClip);
820
874
  break;
821
875
  case "mode": {
822
- // 选项值带 Experimental 标记,选择后还原为真实 mode 值。
823
- const mode: CompactStyleMode =
824
- value === "compact (Experimental)" ? "compact" : (value as CompactStyleMode);
876
+ const mode = value as CompactStyleMode;
825
877
  modeSetting.description = modeSettingDescription(mode);
826
878
  hooks.applyStyleMode(mode, ctx, toolGrouping);
827
879
  return;
@@ -930,6 +982,13 @@ export async function showCcstylePanel(
930
982
  ? "Thinking text uses the theme's dim color."
931
983
  : "Keep the default thinking text color.";
932
984
  break;
985
+ case "stickyUserMessage": {
986
+ updateConfig({ stickyUserMessage: value === "on" });
987
+ stickyUserMessageSetting.description = config.stickyUserMessage
988
+ ? "Fullscreen: pin a user message's first line once it scrolls off the top edge, until the next one takes over."
989
+ : "User messages scroll normally.";
990
+ break;
991
+ }
933
992
  case "showStartupHeader": {
934
993
  const enabled = value === "on";
935
994
  updateConfig({ showStartupHeader: enabled });
@@ -975,6 +1034,7 @@ export async function showCcstylePanel(
975
1034
  expandedMaxSetting,
976
1035
  expandedCardBgSetting,
977
1036
  toolLabelClipSetting,
1037
+ stickyUserMessageSetting,
978
1038
  startupHeaderSetting,
979
1039
  ],
980
1040
  },
@@ -1005,6 +1065,8 @@ export async function showCcstylePanel(
1005
1065
  label: "Footer",
1006
1066
  items: [
1007
1067
  customFooterSetting,
1068
+ footerTokenRateSetting,
1069
+ footerTokenRateModeSetting,
1008
1070
  footerCurrencySetting,
1009
1071
  footerCurrencyRateSetting,
1010
1072
  footerNerdIconsSetting,
@@ -21,6 +21,7 @@ import { pathToFileURL } from "node:url";
21
21
  import { config } from "../../config/config.ts";
22
22
  import { stripAnsi } from "../../utils/ansi-text.ts";
23
23
  import { formatFooterCost } from "./footer-currency.ts";
24
+ import { formatTokenCount } from "../../utils/format.ts";
24
25
  import {
25
26
  PI_USAGE_KEY,
26
27
  isSkippedFooterStatusKey,
@@ -30,6 +31,7 @@ import {
30
31
  } from "./footer-layout.ts";
31
32
  import { createGitStatsRefresher, readGitStats, type GitStats } from "./git-stats.ts";
32
33
  import { MCP_STATUS_KEY, buildMcpChip } from "./mcp-chip.ts";
34
+ import { currentTtftMs, displayTokenRate, formatTokenRate } from "./token-rate.ts";
33
35
 
34
36
  export { parseGitStats } from "./git-stats.ts";
35
37
 
@@ -51,6 +53,46 @@ export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string;
51
53
  : { git: "", cache: "", mcp: "" };
52
54
  }
53
55
 
56
+ /** 思考等级的填充圆标:等级越高填得越满。 */
57
+ const EFFORT_SYMBOL: Record<string, string> = {
58
+ minimal: "\u25cc",
59
+ low: "\u25cb",
60
+ medium: "\u25d4",
61
+ high: "\u25d1",
62
+ xhigh: "\u25d5",
63
+ max: "\u25cf",
64
+ };
65
+
66
+ /** 模型名后直接接圆标和等级,中间不用分隔点。 */
67
+ export function modelChip(
68
+ theme: { fg: (color: string, text: string) => string },
69
+ modelLabel: string,
70
+ level: string,
71
+ thinkingColor: (text: string) => string,
72
+ ): string {
73
+ const symbol = EFFORT_SYMBOL[level];
74
+ if (!symbol) return theme.fg("accent", modelLabel);
75
+ return `${theme.fg("accent", modelLabel)} ${thinkingColor(symbol)} ${theme.fg("dim", level)}`;
76
+ }
77
+
78
+ /** git 状态符号按职能分色:冲突/落后 error,修改 warning,未跟踪 muted,领先 success。 */
79
+ export function colorGitStatus(
80
+ theme: { fg: (color: string, text: string) => string },
81
+ status: string,
82
+ ): string {
83
+ let out = "";
84
+ let color = "dim";
85
+ for (const ch of status) {
86
+ if (ch === "=" || ch === "\u21e3") color = "error";
87
+ else if (ch === "!") color = "warning";
88
+ else if (ch === "?") color = "muted";
89
+ else if (ch === "\u21e1") color = "success";
90
+ else if (!/[0-9]/.test(ch)) color = "dim";
91
+ out += theme.fg(color, ch);
92
+ }
93
+ return out;
94
+ }
95
+
54
96
  type XaiFooterReport = {
55
97
  buckets?: Array<{
56
98
  id?: string;
@@ -303,13 +345,7 @@ const createCustomFooterFactory =
303
345
  tui.requestRender();
304
346
  });
305
347
 
306
- const fmt = (n: number): string => {
307
- if (n < 1000) return `${n}`;
308
- if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
309
- if (n < 1000000) return `${Math.round(n / 1000)}k`;
310
- if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
311
- return `${Math.round(n / 1000000)}M`;
312
- };
348
+ const fmt = formatTokenCount;
313
349
 
314
350
  // ---- pi-cc-status 风格:上下文条形图 ----
315
351
  const BAR_WIDTH = 10;
@@ -400,6 +436,14 @@ const createCustomFooterFactory =
400
436
  const glyphs = footerGlyphs(config.footerNerdIcons);
401
437
  const cacheLabel =
402
438
  cachePct > 0 ? `${glyphs.cache ? `${glyphs.cache} ` : ""}${Math.floor(cachePct)}%` : "";
439
+ const rateLabel = config.footerTokenRate
440
+ ? formatTokenRate(
441
+ displayTokenRate(config.footerTokenRateMode),
442
+ currentTtftMs(),
443
+ config.footerTokenRateMode,
444
+ )
445
+ : "";
446
+ const rateChip = rateLabel ? theme.fg("dim", rateLabel) : "";
403
447
  const costChip =
404
448
  cost || usingSubscription
405
449
  ? theme.fg(
@@ -408,10 +452,10 @@ const createCustomFooterFactory =
408
452
  ) + (usingSubscription ? theme.fg("warning", " sub") : "")
409
453
  : "";
410
454
  const line1 = joinChips([
411
- theme.fg("accent", modelLabel),
412
- model?.reasoning ? thinkingColor(thinkingLevelStr) : "",
455
+ modelChip(theme, modelLabel, model?.reasoning ? thinkingLevelStr : "", thinkingColor),
413
456
  barGauge(percent ?? 0) + theme.fg("dim", ` ${pctLabel}/${fmt(contextWindow)}`),
414
457
  cacheLabel ? theme.fg("dim", cacheLabel) : "",
458
+ rateChip,
415
459
  costChip,
416
460
  ...line1Plugins,
417
461
  ]);
@@ -427,7 +471,7 @@ const createCustomFooterFactory =
427
471
  const s = gitStats;
428
472
  let stats = "";
429
473
  if (s && s.status)
430
- stats = theme.fg("dim", " [") + gitColor(s.status) + theme.fg("dim", "]");
474
+ stats = theme.fg("dim", " [") + colorGitStatus(theme, s.status) + theme.fg("dim", "]");
431
475
  if (s && (s.add || s.del))
432
476
  stats +=
433
477
  theme.fg("dim", "(") +
@@ -0,0 +1,192 @@
1
+ /**
2
+ * footer 输出速率的共享状态:working-message 负责采集,footer 负责显示。
3
+ *
4
+ * average:一次用户指令的墙钟平均(工具等待计入分母),显示 `avg`。
5
+ * live:最近 1 秒的瞬时速率,生成期暂停时钟,显示 `live`;指令结束定格为整段平均。
6
+ * token 与 working-message 同源:优先 provider `usage.output`,否则按 delta 增量估算
7
+ * (CJK 1 token,其余 chars/4)。provider 值回填到本段消息窗口。
8
+ * Pi 的 turn 是单次 assistant 消息,工具调用结束会开新 turn。token 跨 turn 累加,
9
+ * 计时与 TTFT 直到下一条用户指令才重置。
10
+ * TTFT:用户指令开始到第一个内容块(text/thinking/toolcall)开始的毫秒数,
11
+ * 同一指令内只记第一次,定格后保留到下一条指令覆盖。
12
+ */
13
+
14
+ export type TokenRateMode = "average" | "live";
15
+
16
+ const LIVE_WINDOW_MS = 1_000;
17
+ const MIN_SPAN_MS = 250;
18
+ const CJK_CHARS =
19
+ /[\u3000-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/g;
20
+
21
+ interface TokenSample {
22
+ t: number;
23
+ cum: number;
24
+ }
25
+
26
+ let runStartTime = 0;
27
+ let carriedTokens = 0;
28
+ let currentTokens = 0;
29
+ let pauseStartedAt = 0;
30
+ /** 并行工具调用数;>0 时瞬时采样暂停。 */
31
+ let activeTools = 0;
32
+ let lastRate = 0;
33
+ let lastLiveRate = 0;
34
+ let firstTokenAt = 0;
35
+ let samples: TokenSample[] = [];
36
+ let usageAnchorTime = 0;
37
+ let usageAnchorTokens = 0;
38
+ let countedUsage = 0;
39
+
40
+ /** 新的用户指令:重置计时、累计 token、定格值与 TTFT。 */
41
+ export function resetTokenRate(startAt = 0): void {
42
+ runStartTime = startAt;
43
+ carriedTokens = 0;
44
+ currentTokens = 0;
45
+ pauseStartedAt = 0;
46
+ activeTools = 0;
47
+ lastRate = 0;
48
+ lastLiveRate = 0;
49
+ firstTokenAt = 0;
50
+ samples = [];
51
+ usageAnchorTime = 0;
52
+ usageAnchorTokens = 0;
53
+ countedUsage = 0;
54
+ }
55
+
56
+ /**
57
+ * 一段 assistant 回复结束:保留计时与 TTFT,把本段已计 token 结算进累计。
58
+ * 下一段的 token 从 0 重计,采样时加回来。
59
+ */
60
+ export function carryTokenRate(): void {
61
+ if (currentTokens > 0) carriedTokens += currentTokens;
62
+ currentTokens = 0;
63
+ countedUsage = 0;
64
+ }
65
+
66
+ /** 工具开始执行:计数 +1,第一个工具才真正暂停瞬时采样。 */
67
+ export function pauseTokenRate(now = Date.now()): void {
68
+ if (!runStartTime) return;
69
+ activeTools += 1;
70
+ if (activeTools === 1 && !pauseStartedAt) pauseStartedAt = now;
71
+ }
72
+
73
+ /** 工具执行结束:计数 -1,最后一个工具结束才恢复采样。 */
74
+ export function resumeTokenRate(): void {
75
+ activeTools = Math.max(0, activeTools - 1);
76
+ if (activeTools === 0 && pauseStartedAt) {
77
+ pauseStartedAt = 0;
78
+ }
79
+ }
80
+
81
+ /** 本指令第一个内容块开始时调用;同一指令后续消息不覆盖。 */
82
+ export function markFirstToken(now = Date.now()): void {
83
+ if (runStartTime && !firstTokenAt) firstTokenAt = now;
84
+ }
85
+
86
+ function estimateTokens(text: string): number {
87
+ if (!text) return 0;
88
+ const cjk = text.match(CJK_CHARS)?.length ?? 0;
89
+ return Math.ceil(cjk + (text.length - cjk) / 4);
90
+ }
91
+
92
+ function pushSample(time: number, cum: number): void {
93
+ samples.push({ t: time, cum });
94
+ const cutoff = time - LIVE_WINDOW_MS;
95
+ while (samples.length > 1 && samples[0]!.t < cutoff) samples.shift();
96
+ }
97
+
98
+ function updateRates(now: number): void {
99
+ const total = carriedTokens + currentTokens;
100
+ const elapsed = (now - runStartTime) / 1000;
101
+ if (elapsed > 0) lastRate = total / elapsed;
102
+ if (samples.length < 2) return;
103
+ const first = samples[0]!;
104
+ const last = samples[samples.length - 1]!;
105
+ const span = last.t - first.t;
106
+ if (span >= MIN_SPAN_MS) {
107
+ lastLiveRate = ((last.cum - first.cum) * 1000) / span;
108
+ }
109
+ }
110
+
111
+ /** 每个文本增量采样一次。工具暂停期的增量只累计 token,不进窗口。 */
112
+ export function sampleTokenDelta(delta: string, now = Date.now()): void {
113
+ if (!runStartTime) return;
114
+ const added = estimateTokens(delta);
115
+ if (added <= 0) return;
116
+ currentTokens += added;
117
+ if (pauseStartedAt) return;
118
+ pushSample(now, carriedTokens + currentTokens);
119
+ updateRates(now);
120
+ }
121
+
122
+ /**
123
+ * 记录本段 provider `usage.output`。usage 按锚点摊回窗口:
124
+ * 首次报告从消息起点铺到现在,之后每次把增量摊在上次报告之后。
125
+ */
126
+ export function recordProviderUsage(output: number, now = Date.now()): void {
127
+ if (!runStartTime || output <= 0) return;
128
+ const firstReport = countedUsage === 0;
129
+ const jump = output - countedUsage;
130
+ countedUsage = output;
131
+ // provider 是权威计数;首次报告以它为准,之后增量接管。
132
+ currentTokens = output;
133
+ if (firstReport) {
134
+ samples = samples.filter((sample) => sample.t < usageAnchorTime);
135
+ pushSample(usageAnchorTime, carriedTokens + usageAnchorTokens);
136
+ }
137
+ const span = Math.max(now - usageAnchorTime, 1);
138
+ const count = Math.min(Math.max(Math.ceil(jump / 16), 1), 32);
139
+ const base = firstReport
140
+ ? carriedTokens + usageAnchorTokens
141
+ : carriedTokens + currentTokens - jump;
142
+ for (let i = 1; i <= count; i++) {
143
+ pushSample(usageAnchorTime + (span * i) / count, base + (jump * i) / count);
144
+ }
145
+ usageAnchorTime = now;
146
+ usageAnchorTokens = currentTokens;
147
+ updateRates(now);
148
+ }
149
+
150
+ /** 本段消息开始:重置 provider 锚点。 */
151
+ export function startMessageSegment(now = Date.now()): void {
152
+ if (!runStartTime) return;
153
+ usageAnchorTime = now;
154
+ usageAnchorTokens = currentTokens;
155
+ countedUsage = 0;
156
+ }
157
+
158
+ /** 整段指令的累计 token 数:已结转段 + 当前段。 */
159
+ export function totalTokens(): number {
160
+ return carriedTokens + currentTokens;
161
+ }
162
+
163
+ /** average 的当前值:指令中为实时平均,指令外为上一段定格值。 */
164
+ export function currentTokenRate(): number {
165
+ return lastRate;
166
+ }
167
+
168
+ /**
169
+ * 按显示模式取值。live 在生成中取窗口速率,窗口数据不足或指令结束后回到整段平均。
170
+ * 工具暂停期间时钟不动,显示上一条窗口值。
171
+ */
172
+ export function displayTokenRate(mode: TokenRateMode): number {
173
+ if (mode === "live" && samples.length >= 2 && lastLiveRate > 0) {
174
+ return lastLiveRate;
175
+ }
176
+ return lastRate;
177
+ }
178
+
179
+ /** 本指令 TTFT(毫秒);未出首 token 或无活动指令返回 0。定格后保留。 */
180
+ export function currentTtftMs(): number {
181
+ if (!runStartTime || !firstTokenAt) return 0;
182
+ return Math.max(0, firstTokenAt - runStartTime);
183
+ }
184
+
185
+ /** `avg 1.2k tok/s (TTFT: 812ms)`;无数据显示空串,未出首 token 时无括号段。 */
186
+ export function formatTokenRate(rate: number, ttftMs = 0, mode: TokenRateMode = "average"): string {
187
+ if (!Number.isFinite(rate) || rate <= 0) return "";
188
+ const value = rate >= 1000 ? `${(rate / 1000).toFixed(1)}k` : `${Math.round(rate)}`;
189
+ const ttft = Number.isFinite(ttftMs) && ttftMs > 0 ? ` (TTFT: ${Math.round(ttftMs)}ms)` : "";
190
+ const label = mode === "live" ? "live" : "avg";
191
+ return `${label} ${value} tok/s${ttft}`;
192
+ }