pi-cc-extensions 0.9.9 → 0.9.11

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.
@@ -4,7 +4,7 @@
4
4
  * 渲染副作用(applyStyleMode / refreshCurrentTranscript)由 renderer 经
5
5
  * CcstylePanelHooks 注入,避免 config → renderer 循环依赖。
6
6
  */
7
- import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
7
+ import { getSettingsListTheme, type ThemeBg } from "@earendil-works/pi-coding-agent";
8
8
  import { Input, SettingsList, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import type { CompactThinkingController } from "../feature/compact-thinking.ts";
10
10
  import {
@@ -12,6 +12,10 @@ import {
12
12
  clearCustomFooter,
13
13
  getFooterStatusSnapshot,
14
14
  } from "../feature/shell/footer.ts";
15
+ import {
16
+ FOOTER_CURRENCY_PRESETS,
17
+ presetFooterCurrencyRate,
18
+ } from "../feature/shell/footer-currency.ts";
15
19
  import {
16
20
  footerChipDescription,
17
21
  footerLineOfKey,
@@ -33,6 +37,7 @@ import {
33
37
  DIFF_SPLIT_MIN_WIDTH_VALUES,
34
38
  DIFF_VIEW_MODES,
35
39
  EXCLUDE_RENDERER_CANDIDATES,
40
+ EXPANDED_CARD_BACKGROUND_SLOTS,
36
41
  EXPANDED_INPUT_MAX_LINES_VALUES,
37
42
  EXPANDED_OUTPUT_MAX_LINES_VALUES,
38
43
  EXPANDED_PREVIEW_MAX_LINES_VALUES,
@@ -41,7 +46,6 @@ import {
41
46
  pickInputClip,
42
47
  pickPositiveInt,
43
48
  pickPositiveNumber,
44
- SCROLL_STEP_LINES_VALUES,
45
49
  THINKING_ANIMATION_INTERVAL_VALUES,
46
50
  THINKING_PREVIEW_LINES_VALUES,
47
51
  INPUT_CLIP_VALUES,
@@ -201,6 +205,14 @@ function diffIndicatorDescription(mode: DiffIndicatorMode): string {
201
205
  return "Vertical bar indicators on changed lines (default).";
202
206
  }
203
207
 
208
+ function expandedCardBackgroundDescription(slot: ThemeBg): string {
209
+ if (slot === "userMessageBg") {
210
+ return "Expanded cards share the user message background. Pick another slot to tell them apart.";
211
+ }
212
+ if (slot === "toolPendingBg") return "Expanded cards use the theme's neutral tool background.";
213
+ return `Expanded cards use the theme's ${slot} background.`;
214
+ }
215
+
204
216
  /** 额外功能开关项:on/off 二值,描述随状态切换;切换后需重启生效。 */
205
217
  function featureToggleSetting(
206
218
  id: string,
@@ -272,10 +284,22 @@ function buildExcludeRenderersSubmenu(
272
284
  };
273
285
  }
274
286
 
275
- /** 数值项手动输入子面板:预填当前值,Space 循环预设,输入数字自定义,Enter 应用,Esc 取消。 */
276
- function buildNumberInputSubmenu(
287
+ function footerCurrencyRateLabel(rate: number | null): string {
288
+ return rate === null ? "-" : String(rate);
289
+ }
290
+
291
+ /**
292
+ * 手动输入子面板。accept 返回字符串则提交,undefined 取消,null 用 invalid 提示。
293
+ */
294
+ function buildValueInputSubmenu(
277
295
  theme: any,
278
- setting: { label: string; values: readonly string[]; currentValue: string },
296
+ options: {
297
+ prompt: string;
298
+ initial: string;
299
+ hint: string;
300
+ accept: (raw: string) => string | null | undefined;
301
+ invalid?: (raw: string) => string;
302
+ },
279
303
  closeSubmenu: (selected?: string) => void,
280
304
  ): {
281
305
  render: (width: number) => string[];
@@ -284,27 +308,28 @@ function buildNumberInputSubmenu(
284
308
  } {
285
309
  const input = new Input();
286
310
  let error = "";
287
- input.setValue(setting.currentValue);
311
+ input.setValue(options.initial);
288
312
  input.onSubmit = (value: string) => {
289
313
  const raw = value.trim();
290
- if (raw === "") {
291
- closeSubmenu(); // 空输入 = 取消
314
+ const next = options.accept(raw);
315
+ if (next === undefined) {
316
+ closeSubmenu();
292
317
  return;
293
318
  }
294
- if (!Number.isFinite(Number(raw))) {
295
- error = `Invalid number: "${raw}"`;
319
+ if (next === null) {
320
+ error = options.invalid?.(raw) ?? "Invalid value";
296
321
  return;
297
322
  }
298
- closeSubmenu(raw);
323
+ closeSubmenu(next);
299
324
  };
300
325
  input.onEscape = () => closeSubmenu();
301
326
  return {
302
327
  render: (width: number) => {
303
328
  const safe = Math.max(0, Math.floor(width));
304
329
  const lines = [
305
- theme.fg("dim", ` ${setting.label} — custom value:`),
330
+ theme.fg("dim", options.prompt),
306
331
  ...input.render(safe),
307
- truncateToWidth(theme.fg("dim", " Enter to apply · Esc to go back"), safe),
332
+ truncateToWidth(theme.fg("dim", options.hint), safe),
308
333
  ];
309
334
  if (error !== "") lines.push(theme.fg("dim", ` ${error}`));
310
335
  return lines;
@@ -314,6 +339,25 @@ function buildNumberInputSubmenu(
314
339
  };
315
340
  }
316
341
 
342
+ /** 数值项:预填当前值,空输入取消,非数字留下错误。 */
343
+ function buildNumberInputSubmenu(
344
+ theme: any,
345
+ setting: { label: string; currentValue: string },
346
+ closeSubmenu: (selected?: string) => void,
347
+ ) {
348
+ return buildValueInputSubmenu(
349
+ theme,
350
+ {
351
+ prompt: ` ${setting.label} — custom value:`,
352
+ initial: setting.currentValue,
353
+ hint: " Enter to apply · Esc to go back",
354
+ accept: (raw) => (raw === "" ? undefined : Number.isFinite(Number(raw)) ? raw : null),
355
+ invalid: (raw) => `Invalid number: "${raw}"`,
356
+ },
357
+ closeSubmenu,
358
+ );
359
+ }
360
+
317
361
  /** Section tabs for /ccstyle — matches Zentui-style "A / B / C" headers. */
318
362
  type CcstyleSection = {
319
363
  id: "style" | "diff" | "thinking" | "ui" | "feature" | "footer";
@@ -492,6 +536,13 @@ export async function showCcstylePanel(
492
536
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
493
537
  buildNumberInputSubmenu(theme, expandedMaxSetting, closeSubmenu),
494
538
  };
539
+ const expandedCardBgSetting = {
540
+ id: "expandedCardBackground",
541
+ label: "Expanded card bg",
542
+ description: expandedCardBackgroundDescription(config.expandedCardBackground),
543
+ currentValue: config.expandedCardBackground,
544
+ values: [...EXPANDED_CARD_BACKGROUND_SLOTS],
545
+ };
495
546
  const thinkingTitleSetting = {
496
547
  id: "useSummaryTitlesAsThinkingTitle",
497
548
  label: "Summary title",
@@ -535,15 +586,6 @@ export async function showCcstylePanel(
535
586
  currentValue: config.showStartupHeader ? "on" : "off",
536
587
  values: ["on", "off"],
537
588
  };
538
- const scrollStepSetting = {
539
- id: "scrollStepLines",
540
- label: "Scroll step",
541
- description: "Mouse wheel scroll lines in fullscreen mode.",
542
- currentValue: String(config.scrollStepLines),
543
- values: [...SCROLL_STEP_LINES_VALUES],
544
- submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
545
- buildNumberInputSubmenu(theme, scrollStepSetting, closeSubmenu),
546
- };
547
589
 
548
590
  // 额外功能开关:注册于扩展加载期,切换后需重启(/reload)生效。
549
591
  const sessionReferenceToggle = featureToggleSetting(
@@ -588,6 +630,65 @@ export async function showCcstylePanel(
588
630
  "Aliases disabled.",
589
631
  config.enableAliases,
590
632
  );
633
+ const footerCurrencySetting = {
634
+ id: "footerCurrency",
635
+ label: "Cost currency",
636
+ description:
637
+ "ISO code for the status-bar cost. Switching currency fills its preset rate. USD clears the rate.",
638
+ currentValue: config.footerCurrency,
639
+ values: [...FOOTER_CURRENCY_PRESETS],
640
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
641
+ nestedSubmenuOpen = true;
642
+ nestedHint = " Enter to apply · Esc back to Footer";
643
+ return buildValueInputSubmenu(
644
+ theme,
645
+ {
646
+ prompt: " Cost currency — ISO code:",
647
+ initial: footerCurrencySetting.currentValue,
648
+ hint: " Enter to apply · Esc to go back",
649
+ accept: (raw) => (raw === "" ? undefined : raw),
650
+ },
651
+ (selected) => {
652
+ nestedSubmenuOpen = false;
653
+ nestedHint = "";
654
+ closeSubmenu(selected);
655
+ },
656
+ );
657
+ },
658
+ };
659
+ const footerCurrencyRateSetting = {
660
+ id: "footerCurrencyRate",
661
+ label: "Cost rate",
662
+ description:
663
+ "Display units per 1 USD of cost. Switching currency replaces this. Empty keeps the number and only changes the symbol.",
664
+ currentValue: footerCurrencyRateLabel(config.footerCurrencyRate),
665
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
666
+ nestedSubmenuOpen = true;
667
+ nestedHint = " Enter to apply · empty clears · Esc back to Footer";
668
+ return buildValueInputSubmenu(
669
+ theme,
670
+ {
671
+ prompt: " Cost rate — display units per 1 cost unit:",
672
+ initial:
673
+ footerCurrencyRateSetting.currentValue === "-"
674
+ ? ""
675
+ : footerCurrencyRateSetting.currentValue,
676
+ hint: " Enter to apply · empty clears conversion · Esc to go back",
677
+ accept: (raw) => {
678
+ if (raw === "" || raw === "-") return "-";
679
+ const n = Number(raw);
680
+ return Number.isFinite(n) && n > 0 ? raw : null;
681
+ },
682
+ invalid: () => "Rate must be a positive number",
683
+ },
684
+ (selected) => {
685
+ nestedSubmenuOpen = false;
686
+ nestedHint = "";
687
+ closeSubmenu(selected);
688
+ },
689
+ );
690
+ },
691
+ };
591
692
  const footerNerdIconsSetting = {
592
693
  id: "footerNerdIcons",
593
694
  label: "Nerd Font icons",
@@ -647,6 +748,27 @@ export async function showCcstylePanel(
647
748
  ctx.ui.notify(`Updated ${id}: ${value}`, "info");
648
749
  return;
649
750
  }
751
+ if (id === "footerCurrency") {
752
+ updateConfig({
753
+ footerCurrency: value,
754
+ footerCurrencyRate: presetFooterCurrencyRate(value),
755
+ });
756
+ footerCurrencySetting.currentValue = config.footerCurrency;
757
+ footerCurrencyRateSetting.currentValue = footerCurrencyRateLabel(config.footerCurrencyRate);
758
+ if (config.enableCustomFooter) applyCustomFooter(ctx);
759
+ ctx.ui.notify(
760
+ `Updated ${id}: ${config.footerCurrency} · ${footerCurrencyRateSetting.currentValue}`,
761
+ "info",
762
+ );
763
+ return;
764
+ }
765
+ if (id === "footerCurrencyRate") {
766
+ updateConfig({ footerCurrencyRate: value === "-" ? null : Number(value) });
767
+ footerCurrencyRateSetting.currentValue = footerCurrencyRateLabel(config.footerCurrencyRate);
768
+ if (config.enableCustomFooter) applyCustomFooter(ctx);
769
+ ctx.ui.notify(`Updated ${id}: ${footerCurrencyRateSetting.currentValue}`, "info");
770
+ return;
771
+ }
650
772
  if (id === "footerNerdIcons") {
651
773
  const enabled = value === "on";
652
774
  updateConfig({ footerNerdIcons: enabled });
@@ -759,6 +881,12 @@ export async function showCcstylePanel(
759
881
  });
760
882
  expandedMaxSetting.currentValue = String(config.expandedPreviewMaxLines);
761
883
  break;
884
+ case "expandedCardBackground":
885
+ updateConfig({ expandedCardBackground: value as ThemeBg });
886
+ expandedCardBgSetting.description = expandedCardBackgroundDescription(
887
+ config.expandedCardBackground,
888
+ );
889
+ break;
762
890
  case "useSummaryTitlesAsThinkingTitle":
763
891
  updateConfig({ useSummaryTitlesAsThinkingTitle: value === "on" });
764
892
  break;
@@ -788,12 +916,6 @@ export async function showCcstylePanel(
788
916
  else clearStartupHeader(ctx);
789
917
  break;
790
918
  }
791
- case "scrollStepLines":
792
- updateConfig({
793
- scrollStepLines: pickPositiveInt(value, DEFAULT_CONFIG.scrollStepLines, 1, 50),
794
- });
795
- scrollStepSetting.currentValue = String(config.scrollStepLines);
796
- break;
797
919
  default:
798
920
  return;
799
921
  }
@@ -827,9 +949,9 @@ export async function showCcstylePanel(
827
949
  expandedInputSetting,
828
950
  expandedOutputSetting,
829
951
  expandedMaxSetting,
952
+ expandedCardBgSetting,
830
953
  inputClipSetting,
831
954
  startupHeaderSetting,
832
- scrollStepSetting,
833
955
  ],
834
956
  },
835
957
  {
@@ -857,7 +979,13 @@ export async function showCcstylePanel(
857
979
  {
858
980
  id: "footer",
859
981
  label: "Footer",
860
- items: [customFooterSetting, footerNerdIconsSetting, pluginChipsSetting],
982
+ items: [
983
+ customFooterSetting,
984
+ footerCurrencySetting,
985
+ footerCurrencyRateSetting,
986
+ footerNerdIconsSetting,
987
+ pluginChipsSetting,
988
+ ],
861
989
  },
862
990
  ];
863
991
 
@@ -18,6 +18,8 @@ import {
18
18
  wrapTextWithAnsi,
19
19
  } from "@earendil-works/pi-tui";
20
20
  import { Box, Markdown, Spacer, Text, type Component } from "@earendil-works/pi-tui";
21
+ // 展开卡背景是 ccstyle 的渲染约定,不随上游 CompactThinkingConfig 走,直接读全局配置。
22
+ import { config as styleConfig } from "../config/config.ts";
21
23
  import { isToolTuiFullscreen } from "../renderer/tool/show-more-hint.ts";
22
24
  import {
23
25
  animateCompactThinkingText,
@@ -309,19 +311,20 @@ function hiddenPreviewHint(
309
311
  const action = thinkingExpandAction() ?? "";
310
312
  if (hiddenLines > 0) {
311
313
  const noun = hiddenLines === 1 ? "line" : "lines";
314
+ // 与其它提示同一套写法:小圆点连接,不用逗号和括号
312
315
  return {
313
- prefix: ` • (${hiddenLines} more ${noun}${action ? ", " : ""}`,
316
+ prefix: ` · ${hiddenLines} more ${noun}${action ? " · " : ""}`,
314
317
  action,
315
- suffix: ")",
318
+ suffix: "",
316
319
  };
317
320
  }
318
321
  if (forceExpandHint && isToolTuiFullscreen()) {
319
- return { prefix: " • ", action: "click to show more", suffix: "" };
322
+ return { prefix: " · ", action: "click to show more", suffix: "" };
320
323
  }
321
324
  return undefined;
322
325
  }
323
326
 
324
- const expandedThinking = new Set<number>();
327
+ const expandedThinking = new Set<string>();
325
328
 
326
329
  /** 折叠预览 + 展开全文。fullscreen 点击 hint 展开、展开卡单击整块收起(拖动选择文本时不收起)。 */
327
330
  export class ThinkingPreviewBlock implements Component {
@@ -332,7 +335,6 @@ export class ThinkingPreviewBlock implements Component {
332
335
  readonly runStartIndex: number;
333
336
  private style: (text: string) => string;
334
337
  private theme: Theme | undefined;
335
- private _expanded: boolean;
336
338
  private hintHovered = false;
337
339
  private collapsedMemo:
338
340
  | {
@@ -360,7 +362,6 @@ export class ThinkingPreviewBlock implements Component {
360
362
  this.runStartIndex = runStartIndex;
361
363
  this.style = style;
362
364
  this.theme = theme;
363
- this._expanded = expandedThinking.has(messageTimestamp);
364
365
  }
365
366
 
366
367
  private paint(color: string, text: string): string {
@@ -369,18 +370,20 @@ export class ThinkingPreviewBlock implements Component {
369
370
  : text;
370
371
  }
371
372
 
373
+ /** 展开态按消息时间戳 + run 序号共享:面板重排/内容重建换掉实例后,
374
+ * 布局里的旧实例与 render 的新实例同键,点谁都能展开。 */
372
375
  get expanded(): boolean {
373
- return this._expanded;
376
+ return expandedThinking.has(`${this.messageTimestamp}:${this.runStartIndex}`);
374
377
  }
375
378
 
376
379
  setExpanded(expanded: boolean): void {
377
- if (this._expanded !== expanded) this.collapsedMemo = undefined;
378
- this._expanded = expanded;
379
- if (expanded) expandedThinking.add(this.messageTimestamp);
380
+ const key = `${this.messageTimestamp}:${this.runStartIndex}`;
381
+ if (expanded) expandedThinking.add(key);
380
382
  else {
381
- expandedThinking.delete(this.messageTimestamp);
383
+ expandedThinking.delete(key);
382
384
  evictExpandedWraps(this.messageTimestamp, this.runStartIndex);
383
385
  }
386
+ this.collapsedMemo = undefined;
384
387
  }
385
388
 
386
389
  setHintHovered(hovered: boolean): void {
@@ -389,7 +392,7 @@ export class ThinkingPreviewBlock implements Component {
389
392
  }
390
393
 
391
394
  private headingLines(width: number, hiddenLines: number, padding: number): string[] {
392
- const hint = this._expanded
395
+ const hint = this.expanded
393
396
  ? undefined
394
397
  : hiddenPreviewHint(hiddenLines, Boolean(this.text) && config.previewLines <= 0);
395
398
  if (!hint) return new Text(this.heading, padding, 0).render(width);
@@ -409,10 +412,10 @@ export class ThinkingPreviewBlock implements Component {
409
412
  }
410
413
 
411
414
  private bodyLines(width: number, padding: number): { lines: string[]; hiddenLines: number } {
412
- if (!this.text || (config.previewLines <= 0 && !this._expanded)) {
415
+ if (!this.text || (config.previewLines <= 0 && !this.expanded)) {
413
416
  return { lines: [], hiddenLines: 0 };
414
417
  }
415
- if (this._expanded) {
418
+ if (this.expanded) {
416
419
  return {
417
420
  lines: wrapExpandedThinking(this.messageTimestamp, this.runStartIndex, this.text, width),
418
421
  hiddenLines: 0,
@@ -423,7 +426,7 @@ export class ThinkingPreviewBlock implements Component {
423
426
  }
424
427
 
425
428
  render(width: number) {
426
- if (this._expanded) {
429
+ if (this.expanded) {
427
430
  const innerWidth = Math.max(1, width - 2);
428
431
  const body = this.bodyLines(innerWidth, 0);
429
432
  const heading = this.headingLines(innerWidth, 0, 0);
@@ -432,7 +435,7 @@ export class ThinkingPreviewBlock implements Component {
432
435
  : heading;
433
436
  const bgFn =
434
437
  this.theme && typeof this.theme.bg === "function"
435
- ? (text: string) => this.theme!.bg("userMessageBg" as never, text)
438
+ ? (text: string) => this.theme!.bg(styleConfig.expandedCardBackground, text)
436
439
  : undefined;
437
440
  const box = new Box(1, 1, bgFn);
438
441
  box.addChild({
@@ -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
+ }
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
  import { pathToFileURL } from "node:url";
22
22
  import { config } from "../../config/config.ts";
23
23
  import { stripAnsi } from "../../utils/ansi-text.ts";
24
+ import { formatFooterCost } from "./footer-currency.ts";
24
25
  import {
25
26
  PI_USAGE_KEY,
26
27
  isSkippedFooterStatusKey,
@@ -28,18 +29,24 @@ import {
28
29
  visibleFooterPluginTexts,
29
30
  type FooterChipLayout,
30
31
  } from "./footer-layout.ts";
32
+ import { MCP_STATUS_KEY, buildMcpChip } from "./mcp-chip.ts";
31
33
 
32
34
  const GIT_REFRESH_INTERVAL_MS = 10_000;
33
35
  const USAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
36
+ // 后台连接 MCP 服务器不会触发 footer 重绘,靠这个兜底轮询把芯片刷出来
37
+ const MCP_REFRESH_INTERVAL_MS = 5_000;
34
38
  const USAGE_TIMEOUT_MS = 15_000;
35
39
  // zentui NERD_DEFAULT_ICONS:git / cacheHit。无 Nerd Font 时留空,只保留文字。
36
40
  export const FOOTER_NERD_ICON_GIT = "";
37
41
  export const FOOTER_NERD_ICON_CACHE = "󰆼";
38
42
 
39
- export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string } {
43
+ // md power-plug:MCP 服务器连接数
44
+ export const FOOTER_NERD_ICON_MCP = "\u{f06a5}";
45
+
46
+ export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string; mcp: string } {
40
47
  return nerdIcons
41
- ? { git: FOOTER_NERD_ICON_GIT, cache: FOOTER_NERD_ICON_CACHE }
42
- : { git: "", cache: "" };
48
+ ? { git: FOOTER_NERD_ICON_GIT, cache: FOOTER_NERD_ICON_CACHE, mcp: FOOTER_NERD_ICON_MCP }
49
+ : { git: "", cache: "", mcp: "" };
43
50
  }
44
51
 
45
52
  type GitStats = { add: number; del: number };
@@ -97,8 +104,15 @@ export function formatXaiFooterChip(report: XaiFooterReport): string | undefined
97
104
 
98
105
  const cachedExtensionStatuses = new Map<string, string>();
99
106
  let cachedLocalUsageText = "";
107
+ /** 本包推算的内置 MCP 芯片(含 nerd 图标);空字符串表示不显示。 */
108
+ let cachedMcpChip = "";
109
+
110
+ /** 别的扩展(pi-mcp-adapter)自己写了 mcp 状态时不画,避免两个芯片。 */
111
+ function ownMcpChip(): string {
112
+ return cachedMcpChip && !cachedExtensionStatuses.has(MCP_STATUS_KEY) ? cachedMcpChip : "";
113
+ }
100
114
 
101
- /** 面板用:当前 setStatus 文案 + 本包 pi-usage(无文案时为空字符串)。 */
115
+ /** 面板用:当前 setStatus 文案 + 本包 pi-usage / MCP(无文案时为空字符串)。 */
102
116
  export function getFooterStatusSnapshot(): Map<string, string> {
103
117
  const out = new Map<string, string>();
104
118
  for (const [key, text] of cachedExtensionStatuses) {
@@ -106,6 +120,8 @@ export function getFooterStatusSnapshot(): Map<string, string> {
106
120
  out.set(key, text);
107
121
  }
108
122
  out.set(PI_USAGE_KEY, cachedLocalUsageText);
123
+ const mcpChip = ownMcpChip();
124
+ if (mcpChip) out.set(MCP_STATUS_KEY, mcpChip);
109
125
  return out;
110
126
  }
111
127
 
@@ -167,6 +183,9 @@ function colorUsageChip(theme: any, text: string): string {
167
183
  let currentTui: any = undefined;
168
184
  let refreshCurrentGitStats: (() => void) | undefined;
169
185
  let refreshCurrentUsage: (() => void) | undefined;
186
+ let refreshCurrentMcpChip: (() => void) | undefined;
187
+ /** 工具/命令列表在 pi API 上,而 footer 工厂只拿得到 ctx,加载时记下来。 */
188
+ let extensionApi: Pick<ExtensionAPI, "getAllTools" | "getCommands"> | undefined;
170
189
 
171
190
  const createCustomFooterFactory =
172
191
  (ctx: ExtensionContext) => (tui: any, theme: any, footerData: any) => {
@@ -281,6 +300,21 @@ const createCustomFooterFactory =
281
300
  const usageRefreshTimer = setInterval(refreshUsage, USAGE_REFRESH_INTERVAL_MS);
282
301
  usageRefreshTimer.unref?.();
283
302
 
303
+ // 内置 MCP 的状态不经 setStatus 暴露,只能轮询 pi API 与 mcp.json 推算
304
+ const refreshMcp = () => {
305
+ if (disposed) return;
306
+ const chip = buildMcpChip(ctx, extensionApi);
307
+ const glyph = footerGlyphs(config.footerNerdIcons).mcp;
308
+ const next = chip ? `${glyph ? `${glyph} ` : ""}${chip}` : "";
309
+ if (cachedMcpChip === next) return;
310
+ cachedMcpChip = next;
311
+ tui.requestRender();
312
+ };
313
+ refreshCurrentMcpChip = refreshMcp;
314
+ refreshMcp();
315
+ const mcpRefreshTimer = setInterval(refreshMcp, MCP_REFRESH_INTERVAL_MS);
316
+ mcpRefreshTimer.unref?.();
317
+
284
318
  // 分支变化时同步更新分支名和相对 HEAD 的统计。
285
319
  const unsubBranch = footerData.onBranchChange(() => {
286
320
  refreshGitStats();
@@ -359,6 +393,8 @@ const createCustomFooterFactory =
359
393
  footerData.getExtensionStatuses().entries() as Iterable<[string, string]>,
360
394
  );
361
395
  const pluginTexts = pluginTextsForRender(localUsageChip);
396
+ const mcpChip = ownMcpChip();
397
+ if (mcpChip) pluginTexts.set(MCP_STATUS_KEY, mcpChip);
362
398
  const layout = resolveFooterChipLayout(layoutFromConfig(), [...pluginTexts.keys()]);
363
399
  const dimPlugin = (text: string) => colorUsageChip(theme, text);
364
400
  const line1Plugins = visibleFooterPluginTexts(
@@ -384,8 +420,10 @@ const createCustomFooterFactory =
384
420
  cachePct > 0 ? `${glyphs.cache ? `${glyphs.cache} ` : ""}${Math.floor(cachePct)}%` : "";
385
421
  const costChip =
386
422
  cost || usingSubscription
387
- ? theme.fg("dim", `$${cost.toFixed(2)}`) +
388
- (usingSubscription ? theme.fg("warning", " sub") : "")
423
+ ? theme.fg(
424
+ "dim",
425
+ formatFooterCost(cost, config.footerCurrency, config.footerCurrencyRate),
426
+ ) + (usingSubscription ? theme.fg("warning", " sub") : "")
389
427
  : "";
390
428
  const line1 = joinChips([
391
429
  theme.fg("accent", modelLabel),
@@ -436,11 +474,13 @@ const createCustomFooterFactory =
436
474
  usageAbort = undefined;
437
475
  clearInterval(gitRefreshTimer);
438
476
  clearInterval(usageRefreshTimer);
477
+ clearInterval(mcpRefreshTimer);
439
478
  unsubBranch();
440
479
  if (currentTui === tui) {
441
480
  currentTui = undefined;
442
481
  refreshCurrentGitStats = undefined;
443
482
  refreshCurrentUsage = undefined;
483
+ refreshCurrentMcpChip = undefined;
444
484
  }
445
485
  },
446
486
  };
@@ -463,6 +503,7 @@ export function clearCustomFooter(ctx: ExtensionContext): void {
463
503
  }
464
504
 
465
505
  export default function (pi: ExtensionAPI) {
506
+ extensionApi = pi;
466
507
  // 模型/思考级别变化时强制重渲染(自定义 footer 不会被内置 invalidate() 触达)
467
508
  pi.on("model_select", () => {
468
509
  currentTui?.requestRender();
@@ -470,7 +511,11 @@ export default function (pi: ExtensionAPI) {
470
511
  });
471
512
  pi.on("thinking_level_select", () => currentTui?.requestRender());
472
513
  // 工具执行完成后立即刷新;定时器只负责兜底捕获外部文件变化。
473
- pi.on("tool_execution_end", () => refreshCurrentGitStats?.());
514
+ pi.on("tool_execution_end", () => {
515
+ refreshCurrentGitStats?.();
516
+ // 懒连接的 MCP 服务器可能刚被调用过
517
+ refreshCurrentMcpChip?.();
518
+ });
474
519
 
475
520
  // 启动 / /reload / 新建会话 时按配置恢复
476
521
  pi.on("session_start", (_event, ctx) => {