@herbertgao/pi-extensions 2026.10.4 → 2026.10.5

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.
Files changed (30) hide show
  1. package/README.md +1 -1
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +12 -7
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +12 -7
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +67 -16
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +240 -48
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +92 -59
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +192 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -65
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +113 -25
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +160 -24
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +30 -1
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +26 -24
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +402 -0
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
  17. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -8
  18. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
  19. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
  20. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
  21. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +35 -9
  22. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +101 -31
  23. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +3 -3
  24. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +106 -15
  25. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
  26. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
  27. package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
  28. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
  29. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +1 -1
  30. package/package.json +2 -2
@@ -11,7 +11,12 @@ import {
11
11
  applyCustomFooter,
12
12
  clearCustomFooter,
13
13
  getFooterStatusSnapshot,
14
+ refreshFooterGitStats,
14
15
  } from "../feature/shell/footer.ts";
16
+ import {
17
+ FOOTER_CURRENCY_PRESETS,
18
+ presetFooterCurrencyRate,
19
+ } from "../feature/shell/footer-currency.ts";
15
20
  import {
16
21
  footerChipDescription,
17
22
  footerLineOfKey,
@@ -37,21 +42,24 @@ import {
37
42
  EXPANDED_INPUT_MAX_LINES_VALUES,
38
43
  EXPANDED_OUTPUT_MAX_LINES_VALUES,
39
44
  EXPANDED_PREVIEW_MAX_LINES_VALUES,
45
+ FOOTER_GIT_STATS_MODES,
46
+ FOOTER_TOKEN_RATE_MODES,
40
47
  formatExcludeRenderers,
41
48
  getCompactThinkingConfig,
42
- pickInputClip,
43
49
  pickPositiveInt,
44
50
  pickPositiveNumber,
45
- SCROLL_STEP_LINES_VALUES,
51
+ pickToolLabelClip,
46
52
  THINKING_ANIMATION_INTERVAL_VALUES,
47
53
  THINKING_PREVIEW_LINES_VALUES,
48
- INPUT_CLIP_VALUES,
54
+ TOOL_LABEL_CLIP_VALUES,
49
55
  WRITE_DIFF_COLLAPSED_LINES_VALUES,
50
56
  updateConfig,
51
57
  type CompactStyleMode,
52
58
  type Config,
53
59
  type DiffIndicatorMode,
54
60
  type DiffViewMode,
61
+ type FooterGitStatsMode,
62
+ type FooterTokenRateMode,
55
63
  } from "./config.ts";
56
64
 
57
65
  /** renderer 注入的渲染副作用,面板自身不触碰渲染状态。 */
@@ -62,7 +70,7 @@ export type CcstylePanelHooks = {
62
70
 
63
71
  function modeSettingDescription(mode: CompactStyleMode): string {
64
72
  if (mode === "compact") {
65
- 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.";
66
74
  }
67
75
  if (mode === "off") {
68
76
  return "Pi native tool rendering. Diff options below still apply independently.";
@@ -81,6 +89,24 @@ function customFooterDescription(enabled: boolean): string {
81
89
  return "Custom status bar with model, context, cache, cost, git, and plugin chips.";
82
90
  }
83
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
+
104
+ function footerGitStatsDescription(mode: FooterGitStatsMode): string {
105
+ return mode === "branch"
106
+ ? "Net changes since the merge base, including uncommitted edits. Base: origin/HEAD, then local main/master. Untracked files excluded; unavailable stats hidden."
107
+ : "Uncommitted net changes relative to HEAD (default). Includes staged and unstaged edits; excludes untracked files.";
108
+ }
109
+
84
110
  function pluginChipsDescription(): string {
85
111
  return `Arrange plugin chips: ${formatFooterChipSummary(config)}. Enter to edit.`;
86
112
  }
@@ -281,10 +307,22 @@ function buildExcludeRenderersSubmenu(
281
307
  };
282
308
  }
283
309
 
284
- /** 数值项手动输入子面板:预填当前值,Space 循环预设,输入数字自定义,Enter 应用,Esc 取消。 */
285
- function buildNumberInputSubmenu(
310
+ function footerCurrencyRateLabel(rate: number | null): string {
311
+ return rate === null ? "-" : String(rate);
312
+ }
313
+
314
+ /**
315
+ * 手动输入子面板。accept 返回字符串则提交,undefined 取消,null 用 invalid 提示。
316
+ */
317
+ function buildValueInputSubmenu(
286
318
  theme: any,
287
- setting: { label: string; values: readonly string[]; currentValue: string },
319
+ options: {
320
+ prompt: string;
321
+ initial: string;
322
+ hint: string;
323
+ accept: (raw: string) => string | null | undefined;
324
+ invalid?: (raw: string) => string;
325
+ },
288
326
  closeSubmenu: (selected?: string) => void,
289
327
  ): {
290
328
  render: (width: number) => string[];
@@ -293,27 +331,28 @@ function buildNumberInputSubmenu(
293
331
  } {
294
332
  const input = new Input();
295
333
  let error = "";
296
- input.setValue(setting.currentValue);
334
+ input.setValue(options.initial);
297
335
  input.onSubmit = (value: string) => {
298
336
  const raw = value.trim();
299
- if (raw === "") {
300
- closeSubmenu(); // 空输入 = 取消
337
+ const next = options.accept(raw);
338
+ if (next === undefined) {
339
+ closeSubmenu();
301
340
  return;
302
341
  }
303
- if (!Number.isFinite(Number(raw))) {
304
- error = `Invalid number: "${raw}"`;
342
+ if (next === null) {
343
+ error = options.invalid?.(raw) ?? "Invalid value";
305
344
  return;
306
345
  }
307
- closeSubmenu(raw);
346
+ closeSubmenu(next);
308
347
  };
309
348
  input.onEscape = () => closeSubmenu();
310
349
  return {
311
350
  render: (width: number) => {
312
351
  const safe = Math.max(0, Math.floor(width));
313
352
  const lines = [
314
- theme.fg("dim", ` ${setting.label} — custom value:`),
353
+ theme.fg("dim", options.prompt),
315
354
  ...input.render(safe),
316
- truncateToWidth(theme.fg("dim", " Enter to apply · Esc to go back"), safe),
355
+ truncateToWidth(theme.fg("dim", options.hint), safe),
317
356
  ];
318
357
  if (error !== "") lines.push(theme.fg("dim", ` ${error}`));
319
358
  return lines;
@@ -323,6 +362,25 @@ function buildNumberInputSubmenu(
323
362
  };
324
363
  }
325
364
 
365
+ /** 数值项:预填当前值,空输入取消,非数字留下错误。 */
366
+ function buildNumberInputSubmenu(
367
+ theme: any,
368
+ setting: { label: string; currentValue: string },
369
+ closeSubmenu: (selected?: string) => void,
370
+ ) {
371
+ return buildValueInputSubmenu(
372
+ theme,
373
+ {
374
+ prompt: ` ${setting.label} — custom value:`,
375
+ initial: setting.currentValue,
376
+ hint: " Enter to apply · Esc to go back",
377
+ accept: (raw) => (raw === "" ? undefined : Number.isFinite(Number(raw)) ? raw : null),
378
+ invalid: (raw) => `Invalid number: "${raw}"`,
379
+ },
380
+ closeSubmenu,
381
+ );
382
+ }
383
+
326
384
  /** Section tabs for /ccstyle — matches Zentui-style "A / B / C" headers. */
327
385
  type CcstyleSection = {
328
386
  id: "style" | "diff" | "thinking" | "ui" | "feature" | "footer";
@@ -378,8 +436,8 @@ export async function showCcstylePanel(
378
436
  id: "mode",
379
437
  label: "Mode",
380
438
  description: modeSettingDescription(config.mode),
381
- currentValue: config.mode === "compact" ? "compact (Experimental)" : config.mode,
382
- values: ["on", "compact (Experimental)", "off"],
439
+ currentValue: config.mode,
440
+ values: ["on", "compact", "off"],
383
441
  };
384
442
  // Tracks whether a nested submenu is open so Tab switches sections
385
443
  // only at the top level (mirrors Zentui settings: Tab = switch sections).
@@ -409,15 +467,15 @@ export async function showCcstylePanel(
409
467
  );
410
468
  },
411
469
  };
412
- const inputClipSetting = {
413
- id: "inputClip",
414
- label: "Input clip",
470
+ const toolLabelClipSetting = {
471
+ id: "toolLabelClip",
472
+ label: "Tool label clip",
415
473
  description:
416
474
  "Max characters for path/command/name in single and grouped tool summaries; 0 fits available width. Enter to type a custom value.",
417
- currentValue: String(config.inputClip),
418
- values: [...INPUT_CLIP_VALUES],
475
+ currentValue: String(config.toolLabelClip),
476
+ values: [...TOOL_LABEL_CLIP_VALUES],
419
477
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
420
- buildNumberInputSubmenu(theme, inputClipSetting, closeSubmenu),
478
+ buildNumberInputSubmenu(theme, toolLabelClipSetting, closeSubmenu),
421
479
  };
422
480
  const diffViewSetting = {
423
481
  id: "diffViewMode",
@@ -447,7 +505,7 @@ export async function showCcstylePanel(
447
505
  id: "editDiffCollapsedLines",
448
506
  label: "Edit collapsed lines",
449
507
  description:
450
- "How many edit/diff body lines to show before the expand hint (Ctrl+O / click). Write uses its own setting below.",
508
+ "Edit diff body lines before the expand hint (Ctrl+O / click). 0 shows only change counts and the hint.",
451
509
  currentValue: String(config.editDiffCollapsedLines),
452
510
  values: [...DIFF_COLLAPSED_LINES_VALUES],
453
511
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
@@ -551,14 +609,14 @@ export async function showCcstylePanel(
551
609
  currentValue: config.showStartupHeader ? "on" : "off",
552
610
  values: ["on", "off"],
553
611
  };
554
- const scrollStepSetting = {
555
- id: "scrollStepLines",
556
- label: "Scroll step",
557
- description: "Mouse wheel scroll lines in fullscreen mode.",
558
- currentValue: String(config.scrollStepLines),
559
- values: [...SCROLL_STEP_LINES_VALUES],
560
- submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
561
- buildNumberInputSubmenu(theme, scrollStepSetting, closeSubmenu),
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"],
562
620
  };
563
621
 
564
622
  // 额外功能开关:注册于扩展加载期,切换后需重启(/reload)生效。
@@ -604,6 +662,65 @@ export async function showCcstylePanel(
604
662
  "Aliases disabled.",
605
663
  config.enableAliases,
606
664
  );
665
+ const footerCurrencySetting = {
666
+ id: "footerCurrency",
667
+ label: "Cost currency",
668
+ description:
669
+ "ISO code for the status-bar cost. Switching currency fills its preset rate. USD clears the rate.",
670
+ currentValue: config.footerCurrency,
671
+ values: [...FOOTER_CURRENCY_PRESETS],
672
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
673
+ nestedSubmenuOpen = true;
674
+ nestedHint = " Enter to apply · Esc back to Footer";
675
+ return buildValueInputSubmenu(
676
+ theme,
677
+ {
678
+ prompt: " Cost currency — ISO code:",
679
+ initial: footerCurrencySetting.currentValue,
680
+ hint: " Enter to apply · Esc to go back",
681
+ accept: (raw) => (raw === "" ? undefined : raw),
682
+ },
683
+ (selected) => {
684
+ nestedSubmenuOpen = false;
685
+ nestedHint = "";
686
+ closeSubmenu(selected);
687
+ },
688
+ );
689
+ },
690
+ };
691
+ const footerCurrencyRateSetting = {
692
+ id: "footerCurrencyRate",
693
+ label: "Cost rate",
694
+ description:
695
+ "Display units per 1 USD of cost. Switching currency replaces this. Empty keeps the number and only changes the symbol.",
696
+ currentValue: footerCurrencyRateLabel(config.footerCurrencyRate),
697
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
698
+ nestedSubmenuOpen = true;
699
+ nestedHint = " Enter to apply · empty clears · Esc back to Footer";
700
+ return buildValueInputSubmenu(
701
+ theme,
702
+ {
703
+ prompt: " Cost rate — display units per 1 cost unit:",
704
+ initial:
705
+ footerCurrencyRateSetting.currentValue === "-"
706
+ ? ""
707
+ : footerCurrencyRateSetting.currentValue,
708
+ hint: " Enter to apply · empty clears conversion · Esc to go back",
709
+ accept: (raw) => {
710
+ if (raw === "" || raw === "-") return "-";
711
+ const n = Number(raw);
712
+ return Number.isFinite(n) && n > 0 ? raw : null;
713
+ },
714
+ invalid: () => "Rate must be a positive number",
715
+ },
716
+ (selected) => {
717
+ nestedSubmenuOpen = false;
718
+ nestedHint = "";
719
+ closeSubmenu(selected);
720
+ },
721
+ );
722
+ },
723
+ };
607
724
  const footerNerdIconsSetting = {
608
725
  id: "footerNerdIcons",
609
726
  label: "Nerd Font icons",
@@ -620,6 +737,27 @@ export async function showCcstylePanel(
620
737
  currentValue: config.enableCustomFooter ? "on" : "off",
621
738
  values: ["on", "off"],
622
739
  };
740
+ const footerGitStatsSetting = {
741
+ id: "footerGitStatsMode",
742
+ label: "Git changes",
743
+ description: footerGitStatsDescription(config.footerGitStatsMode),
744
+ currentValue: config.footerGitStatsMode,
745
+ values: FOOTER_GIT_STATS_MODES,
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
+ };
623
761
  const pluginChipsSetting = {
624
762
  id: "footerPluginChips",
625
763
  label: "Plugin chips",
@@ -663,6 +801,52 @@ export async function showCcstylePanel(
663
801
  ctx.ui.notify(`Updated ${id}: ${value}`, "info");
664
802
  return;
665
803
  }
804
+ if (id === "footerCurrency") {
805
+ updateConfig({
806
+ footerCurrency: value,
807
+ footerCurrencyRate: presetFooterCurrencyRate(value),
808
+ });
809
+ footerCurrencySetting.currentValue = config.footerCurrency;
810
+ footerCurrencyRateSetting.currentValue = footerCurrencyRateLabel(config.footerCurrencyRate);
811
+ if (config.enableCustomFooter) applyCustomFooter(ctx);
812
+ ctx.ui.notify(
813
+ `Updated ${id}: ${config.footerCurrency} · ${footerCurrencyRateSetting.currentValue}`,
814
+ "info",
815
+ );
816
+ return;
817
+ }
818
+ if (id === "footerCurrencyRate") {
819
+ updateConfig({ footerCurrencyRate: value === "-" ? null : Number(value) });
820
+ footerCurrencyRateSetting.currentValue = footerCurrencyRateLabel(config.footerCurrencyRate);
821
+ if (config.enableCustomFooter) applyCustomFooter(ctx);
822
+ ctx.ui.notify(`Updated ${id}: ${footerCurrencyRateSetting.currentValue}`, "info");
823
+ return;
824
+ }
825
+ if (id === "footerGitStatsMode") {
826
+ updateConfig({ footerGitStatsMode: value as FooterGitStatsMode });
827
+ footerGitStatsSetting.currentValue = config.footerGitStatsMode;
828
+ footerGitStatsSetting.description = footerGitStatsDescription(config.footerGitStatsMode);
829
+ refreshFooterGitStats();
830
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
831
+ return;
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
+ }
666
850
  if (id === "footerNerdIcons") {
667
851
  const enabled = value === "on";
668
852
  updateConfig({ footerNerdIcons: enabled });
@@ -682,16 +866,14 @@ export async function showCcstylePanel(
682
866
  return;
683
867
  }
684
868
  switch (id) {
685
- case "inputClip":
869
+ case "toolLabelClip":
686
870
  updateConfig({
687
- inputClip: pickInputClip(value),
871
+ toolLabelClip: pickToolLabelClip(value),
688
872
  });
689
- inputClipSetting.currentValue = String(config.inputClip);
873
+ toolLabelClipSetting.currentValue = String(config.toolLabelClip);
690
874
  break;
691
875
  case "mode": {
692
- // 选项值带 Experimental 标记,选择后还原为真实 mode 值。
693
- const mode: CompactStyleMode =
694
- value === "compact (Experimental)" ? "compact" : (value as CompactStyleMode);
876
+ const mode = value as CompactStyleMode;
695
877
  modeSetting.description = modeSettingDescription(mode);
696
878
  hooks.applyStyleMode(mode, ctx, toolGrouping);
697
879
  return;
@@ -719,7 +901,7 @@ export async function showCcstylePanel(
719
901
  editDiffCollapsedLines: pickPositiveInt(
720
902
  value,
721
903
  DEFAULT_CONFIG.editDiffCollapsedLines,
722
- 1,
904
+ 0,
723
905
  500,
724
906
  ),
725
907
  });
@@ -800,6 +982,13 @@ export async function showCcstylePanel(
800
982
  ? "Thinking text uses the theme's dim color."
801
983
  : "Keep the default thinking text color.";
802
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
+ }
803
992
  case "showStartupHeader": {
804
993
  const enabled = value === "on";
805
994
  updateConfig({ showStartupHeader: enabled });
@@ -810,12 +999,6 @@ export async function showCcstylePanel(
810
999
  else clearStartupHeader(ctx);
811
1000
  break;
812
1001
  }
813
- case "scrollStepLines":
814
- updateConfig({
815
- scrollStepLines: pickPositiveInt(value, DEFAULT_CONFIG.scrollStepLines, 1, 50),
816
- });
817
- scrollStepSetting.currentValue = String(config.scrollStepLines);
818
- break;
819
1002
  default:
820
1003
  return;
821
1004
  }
@@ -850,9 +1033,9 @@ export async function showCcstylePanel(
850
1033
  expandedOutputSetting,
851
1034
  expandedMaxSetting,
852
1035
  expandedCardBgSetting,
853
- inputClipSetting,
1036
+ toolLabelClipSetting,
1037
+ stickyUserMessageSetting,
854
1038
  startupHeaderSetting,
855
- scrollStepSetting,
856
1039
  ],
857
1040
  },
858
1041
  {
@@ -880,7 +1063,16 @@ export async function showCcstylePanel(
880
1063
  {
881
1064
  id: "footer",
882
1065
  label: "Footer",
883
- items: [customFooterSetting, footerNerdIconsSetting, pluginChipsSetting],
1066
+ items: [
1067
+ customFooterSetting,
1068
+ footerTokenRateSetting,
1069
+ footerTokenRateModeSetting,
1070
+ footerCurrencySetting,
1071
+ footerCurrencyRateSetting,
1072
+ footerNerdIconsSetting,
1073
+ footerGitStatsSetting,
1074
+ pluginChipsSetting,
1075
+ ],
884
1076
  },
885
1077
  ];
886
1078
 
@@ -0,0 +1,29 @@
1
+ const KNOWN_CURRENCIES = new Set(Intl.supportedValuesOf("currency"));
2
+
3
+ /** 1 美元费用对应的显示币种数量。面板切换币种时写入。 */
4
+ export const FOOTER_CURRENCY_RATES = {
5
+ CNY: 7.12,
6
+ EUR: 0.92,
7
+ JPY: 150,
8
+ GBP: 0.76,
9
+ INR: 96.25,
10
+ HKD: 7.78,
11
+ } as const;
12
+
13
+ export const FOOTER_CURRENCY_PRESETS = ["USD", ...Object.keys(FOOTER_CURRENCY_RATES)] as const;
14
+
15
+ /** 不在表里的币种,包括 USD,返回 null。 */
16
+ export function presetFooterCurrencyRate(currency: string): number | null {
17
+ const code = currency.trim().toUpperCase();
18
+ return FOOTER_CURRENCY_RATES[code as keyof typeof FOOTER_CURRENCY_RATES] ?? null;
19
+ }
20
+
21
+ /** 底栏费用文本。USD 或不支持的币种保持美元写法。没有倍率时只换符号。 */
22
+ export function formatFooterCost(cost: number, currency: string, rate: number | null): string {
23
+ const usd = `$${cost.toFixed(2)}`;
24
+ if (currency === "USD" || !KNOWN_CURRENCIES.has(currency)) return usd;
25
+ const formatted = new Intl.NumberFormat("en", { style: "currency", currency }).format(
26
+ rate === null ? cost : cost * rate,
27
+ );
28
+ return rate === null ? formatted : `≈${formatted}`;
29
+ }