pi-cc-extensions 0.9.10 → 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.
package/README.en.md CHANGED
@@ -63,7 +63,6 @@ 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
  "inputClip": 0, // tool summary path/command clip length; 0 = fit width
65
65
  "showStartupHeader": true, // startup header (logo + tips) toggle
66
- "scrollStepLines": 3, // fullscreen wheel scroll step
67
66
 
68
67
  // diff
69
68
  "diffViewMode": "auto", // layout: auto / split / unified
@@ -81,6 +80,8 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
81
80
 
82
81
  // footer
83
82
  "enableCustomFooter": true, // custom status bar
83
+ "footerCurrency": "USD", // display currency for the footer cost
84
+ "footerCurrencyRate": null, // display units per 1 cost unit; null changes the symbol only
84
85
  "footerNerdIcons": true, // Nerd Font glyphs for git/cache; false = plain text
85
86
  "footerHiddenKeys": [], // hidden plugin chip keys
86
87
  "footerLine1Keys": ["pi-usage"], // line1 plugin chip order; pi-usage is shown by default, data from @narumitw/pi-usage
@@ -98,11 +99,9 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
98
99
  ## Local development
99
100
 
100
101
  ```bash
101
- npm test
102
- npm run typecheck
103
- ./test.sh # macOS/Linux: load the local checkout for one run, then restore
104
- test.bat # Windows: same
105
- # one-off run without touching settings: pi -e .
102
+ ./test.sh # macOS/Linux; needs node and pi
103
+ ./test.bat # Windows; runs test.sh through Git Bash
104
+ pi -e . # one run, does not edit settings.json
106
105
  ```
107
106
 
108
107
  ## Compatibility
package/README.md CHANGED
@@ -63,7 +63,6 @@ pi install git:github.com/minuque/pi-cc-extensions
63
63
  "expandedCardBackground": "userMessageBg", // 展开卡背景槽位;userMessageBg 与用户消息同色,toolPendingBg 为中性灰
64
64
  "inputClip": 0, // 工具摘要 path/command 折叠字符数;0 = 按可用宽度
65
65
  "showStartupHeader": true, // 启动头(logo + tips)开关
66
- "scrollStepLines": 3, // fullscreen 滚轮步进
67
66
 
68
67
  // diff
69
68
  "diffViewMode": "auto", // 布局:auto / split / unified
@@ -81,6 +80,8 @@ pi install git:github.com/minuque/pi-cc-extensions
81
80
 
82
81
  // footer
83
82
  "enableCustomFooter": true, // 自定义状态栏
83
+ "footerCurrency": "USD", // 底栏费用的显示币种
84
+ "footerCurrencyRate": null, // 1 单位费用换成多少显示币种;null 只换符号、不乘倍率
84
85
  "footerNerdIcons": true, // git/缓存用 Nerd Font 图标;false 为纯文本
85
86
  "footerHiddenKeys": [], // 隐藏的插件芯片 key
86
87
  "footerLine1Keys": ["pi-usage"], // line1 插件芯片顺序;pi-usage 默认显示,数据来自 @narumitw/pi-usage
@@ -98,11 +99,9 @@ pi install git:github.com/minuque/pi-cc-extensions
98
99
  ## 本地开发
99
100
 
100
101
  ```bash
101
- npm test
102
- npm run typecheck
103
- ./test.sh # macOS/Linux:临时加载本地检出,退出后还原
104
- test.bat # Windows:同上
105
- # 只想跑一次、不改配置:pi -e .
102
+ ./test.sh # macOS/Linux,需要 node 和 pi
103
+ ./test.bat # Windows,经 Git Bash 运行 test.sh
104
+ pi -e . # 只跑一次,不改 settings.json
106
105
  ```
107
106
 
108
107
  ## 兼容性
@@ -63,7 +63,6 @@ export type Config = {
63
63
  animationIntervalMs: number;
64
64
  dimThinkingText: boolean;
65
65
  showStartupHeader: boolean;
66
- scrollStepLines: number;
67
66
  enableSessionReference: boolean;
68
67
  enableSubagentAutocomplete: boolean;
69
68
  enableContextCommand: boolean;
@@ -71,6 +70,10 @@ export type Config = {
71
70
  enableWorkingMessage: boolean;
72
71
  enableAliases: boolean;
73
72
  enableCustomFooter: boolean;
73
+ /** ISO 4217 显示币种。USD 时不换算。 */
74
+ footerCurrency: string;
75
+ /** 1 单位费用对应的显示币种数量。null 只换符号、不乘倍率。 */
76
+ footerCurrencyRate: number | null;
74
77
  footerNerdIcons: boolean;
75
78
  footerHiddenKeys: string[];
76
79
  footerLine1Keys: string[];
@@ -112,8 +115,6 @@ export const EXPANDED_OUTPUT_MAX_LINES_VALUES = ["10", "20", "40", "80", "120"];
112
115
  export const INPUT_CLIP_VALUES = ["0", "40", "60", "80", "100", "120", "160"];
113
116
  export const THINKING_PREVIEW_LINES_VALUES = ["0", "1", "3", "5", "10"];
114
117
  export const THINKING_ANIMATION_INTERVAL_VALUES = ["40", "60", "90", "120", "180"];
115
- /** fullscreen 滚轮步进行数预设。 */
116
- export const SCROLL_STEP_LINES_VALUES = ["1", "2", "3", "5", "10"];
117
118
  /** Tools commonly toggled in excludeRenderers via the settings panel. */
118
119
  export const EXCLUDE_RENDERER_CANDIDATES = [
119
120
  "bash",
@@ -148,7 +149,6 @@ export const DEFAULT_CONFIG: Config = {
148
149
  animationIntervalMs: 90,
149
150
  dimThinkingText: false,
150
151
  showStartupHeader: true,
151
- scrollStepLines: 3,
152
152
  enableSessionReference: true,
153
153
  enableSubagentAutocomplete: true,
154
154
  enableContextCommand: true,
@@ -156,6 +156,8 @@ export const DEFAULT_CONFIG: Config = {
156
156
  enableWorkingMessage: true,
157
157
  enableAliases: true,
158
158
  enableCustomFooter: true,
159
+ footerCurrency: "USD",
160
+ footerCurrencyRate: null,
159
161
  footerNerdIcons: true,
160
162
  ...DEFAULT_FOOTER_CHIP_LAYOUT,
161
163
  };
@@ -183,6 +185,19 @@ export function pickPositiveNumber(value: unknown, fallback: number, min = 1): n
183
185
  return Number.isFinite(n) ? Math.max(min, n) : fallback;
184
186
  }
185
187
 
188
+ function normalizeCurrency(value: unknown): string {
189
+ return typeof value === "string" && /^[A-Za-z]{3}$/.test(value.trim())
190
+ ? value.trim().toUpperCase()
191
+ : "USD";
192
+ }
193
+
194
+ /** 缺失、空串、非正数都视为不换算。 */
195
+ function normalizeFooterCurrencyRate(value: unknown): number | null {
196
+ if (value === undefined || value === null || value === "") return null;
197
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
198
+ return Number.isFinite(n) && n > 0 ? n : null;
199
+ }
200
+
186
201
  export function normalizeConfig(input: unknown): Config {
187
202
  const source = (input && typeof input === "object" ? input : {}) as Record<string, unknown>;
188
203
  const mode = pickEnum(source.mode, ["on", "compact", "off"], DEFAULT_CONFIG.mode);
@@ -260,7 +275,6 @@ export function normalizeConfig(input: unknown): Config {
260
275
  ),
261
276
  dimThinkingText: source.dimThinkingText === true,
262
277
  showStartupHeader: source.showStartupHeader !== false,
263
- scrollStepLines: pickPositiveInt(source.scrollStepLines, DEFAULT_CONFIG.scrollStepLines, 1, 50),
264
278
  enableSessionReference: source.enableSessionReference !== false,
265
279
  enableSubagentAutocomplete: source.enableSubagentAutocomplete !== false,
266
280
  enableContextCommand: source.enableContextCommand !== false,
@@ -268,6 +282,8 @@ export function normalizeConfig(input: unknown): Config {
268
282
  enableWorkingMessage: source.enableWorkingMessage !== false,
269
283
  enableAliases: source.enableAliases !== false,
270
284
  enableCustomFooter: source.enableCustomFooter !== false,
285
+ footerCurrency: normalizeCurrency(source.footerCurrency),
286
+ footerCurrencyRate: normalizeFooterCurrencyRate(source.footerCurrencyRate),
271
287
  footerNerdIcons: source.footerNerdIcons !== false,
272
288
  ...normalizeFooterChipLayout(source),
273
289
  };
@@ -316,7 +332,6 @@ export function formatConfigStatus(source: Config = config): string {
316
332
  `thinkingAnimation=${source.animationIntervalMs}ms`,
317
333
  `thinkingDim=${source.dimThinkingText ? "on" : "off"}`,
318
334
  `startupHeader=${source.showStartupHeader ? "on" : "off"}`,
319
- `scrollStep=${source.scrollStepLines}`,
320
335
  `sessionRef=${source.enableSessionReference ? "on" : "off"}`,
321
336
  `subagentAuto=${source.enableSubagentAutocomplete ? "on" : "off"}`,
322
337
  `context=${source.enableContextCommand ? "on" : "off"}`,
@@ -324,6 +339,8 @@ export function formatConfigStatus(source: Config = config): string {
324
339
  `workingMsg=${source.enableWorkingMessage ? "on" : "off"}`,
325
340
  `aliases=${source.enableAliases ? "on" : "off"}`,
326
341
  `footer=${source.enableCustomFooter ? "on" : "off"}`,
342
+ `footerCurrency=${source.footerCurrency}`,
343
+ `footerRate=${source.footerCurrencyRate ?? "-"}`,
327
344
  `footerIcons=${source.footerNerdIcons ? "nerd" : "plain"}`,
328
345
  formatFooterChipSummary(source),
329
346
  ].join(" · ");
@@ -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,
@@ -42,7 +46,6 @@ import {
42
46
  pickInputClip,
43
47
  pickPositiveInt,
44
48
  pickPositiveNumber,
45
- SCROLL_STEP_LINES_VALUES,
46
49
  THINKING_ANIMATION_INTERVAL_VALUES,
47
50
  THINKING_PREVIEW_LINES_VALUES,
48
51
  INPUT_CLIP_VALUES,
@@ -281,10 +284,22 @@ function buildExcludeRenderersSubmenu(
281
284
  };
282
285
  }
283
286
 
284
- /** 数值项手动输入子面板:预填当前值,Space 循环预设,输入数字自定义,Enter 应用,Esc 取消。 */
285
- 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(
286
295
  theme: any,
287
- 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
+ },
288
303
  closeSubmenu: (selected?: string) => void,
289
304
  ): {
290
305
  render: (width: number) => string[];
@@ -293,27 +308,28 @@ function buildNumberInputSubmenu(
293
308
  } {
294
309
  const input = new Input();
295
310
  let error = "";
296
- input.setValue(setting.currentValue);
311
+ input.setValue(options.initial);
297
312
  input.onSubmit = (value: string) => {
298
313
  const raw = value.trim();
299
- if (raw === "") {
300
- closeSubmenu(); // 空输入 = 取消
314
+ const next = options.accept(raw);
315
+ if (next === undefined) {
316
+ closeSubmenu();
301
317
  return;
302
318
  }
303
- if (!Number.isFinite(Number(raw))) {
304
- error = `Invalid number: "${raw}"`;
319
+ if (next === null) {
320
+ error = options.invalid?.(raw) ?? "Invalid value";
305
321
  return;
306
322
  }
307
- closeSubmenu(raw);
323
+ closeSubmenu(next);
308
324
  };
309
325
  input.onEscape = () => closeSubmenu();
310
326
  return {
311
327
  render: (width: number) => {
312
328
  const safe = Math.max(0, Math.floor(width));
313
329
  const lines = [
314
- theme.fg("dim", ` ${setting.label} — custom value:`),
330
+ theme.fg("dim", options.prompt),
315
331
  ...input.render(safe),
316
- truncateToWidth(theme.fg("dim", " Enter to apply · Esc to go back"), safe),
332
+ truncateToWidth(theme.fg("dim", options.hint), safe),
317
333
  ];
318
334
  if (error !== "") lines.push(theme.fg("dim", ` ${error}`));
319
335
  return lines;
@@ -323,6 +339,25 @@ function buildNumberInputSubmenu(
323
339
  };
324
340
  }
325
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
+
326
361
  /** Section tabs for /ccstyle — matches Zentui-style "A / B / C" headers. */
327
362
  type CcstyleSection = {
328
363
  id: "style" | "diff" | "thinking" | "ui" | "feature" | "footer";
@@ -551,15 +586,6 @@ export async function showCcstylePanel(
551
586
  currentValue: config.showStartupHeader ? "on" : "off",
552
587
  values: ["on", "off"],
553
588
  };
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),
562
- };
563
589
 
564
590
  // 额外功能开关:注册于扩展加载期,切换后需重启(/reload)生效。
565
591
  const sessionReferenceToggle = featureToggleSetting(
@@ -604,6 +630,65 @@ export async function showCcstylePanel(
604
630
  "Aliases disabled.",
605
631
  config.enableAliases,
606
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
+ };
607
692
  const footerNerdIconsSetting = {
608
693
  id: "footerNerdIcons",
609
694
  label: "Nerd Font icons",
@@ -663,6 +748,27 @@ export async function showCcstylePanel(
663
748
  ctx.ui.notify(`Updated ${id}: ${value}`, "info");
664
749
  return;
665
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
+ }
666
772
  if (id === "footerNerdIcons") {
667
773
  const enabled = value === "on";
668
774
  updateConfig({ footerNerdIcons: enabled });
@@ -810,12 +916,6 @@ export async function showCcstylePanel(
810
916
  else clearStartupHeader(ctx);
811
917
  break;
812
918
  }
813
- case "scrollStepLines":
814
- updateConfig({
815
- scrollStepLines: pickPositiveInt(value, DEFAULT_CONFIG.scrollStepLines, 1, 50),
816
- });
817
- scrollStepSetting.currentValue = String(config.scrollStepLines);
818
- break;
819
919
  default:
820
920
  return;
821
921
  }
@@ -852,7 +952,6 @@ export async function showCcstylePanel(
852
952
  expandedCardBgSetting,
853
953
  inputClipSetting,
854
954
  startupHeaderSetting,
855
- scrollStepSetting,
856
955
  ],
857
956
  },
858
957
  {
@@ -880,7 +979,13 @@ export async function showCcstylePanel(
880
979
  {
881
980
  id: "footer",
882
981
  label: "Footer",
883
- items: [customFooterSetting, footerNerdIconsSetting, pluginChipsSetting],
982
+ items: [
983
+ customFooterSetting,
984
+ footerCurrencySetting,
985
+ footerCurrencyRateSetting,
986
+ footerNerdIconsSetting,
987
+ pluginChipsSetting,
988
+ ],
884
989
  },
885
990
  ];
886
991
 
@@ -324,7 +324,7 @@ function hiddenPreviewHint(
324
324
  return undefined;
325
325
  }
326
326
 
327
- const expandedThinking = new Set<number>();
327
+ const expandedThinking = new Set<string>();
328
328
 
329
329
  /** 折叠预览 + 展开全文。fullscreen 点击 hint 展开、展开卡单击整块收起(拖动选择文本时不收起)。 */
330
330
  export class ThinkingPreviewBlock implements Component {
@@ -335,7 +335,6 @@ export class ThinkingPreviewBlock implements Component {
335
335
  readonly runStartIndex: number;
336
336
  private style: (text: string) => string;
337
337
  private theme: Theme | undefined;
338
- private _expanded: boolean;
339
338
  private hintHovered = false;
340
339
  private collapsedMemo:
341
340
  | {
@@ -363,7 +362,6 @@ export class ThinkingPreviewBlock implements Component {
363
362
  this.runStartIndex = runStartIndex;
364
363
  this.style = style;
365
364
  this.theme = theme;
366
- this._expanded = expandedThinking.has(messageTimestamp);
367
365
  }
368
366
 
369
367
  private paint(color: string, text: string): string {
@@ -372,18 +370,20 @@ export class ThinkingPreviewBlock implements Component {
372
370
  : text;
373
371
  }
374
372
 
373
+ /** 展开态按消息时间戳 + run 序号共享:面板重排/内容重建换掉实例后,
374
+ * 布局里的旧实例与 render 的新实例同键,点谁都能展开。 */
375
375
  get expanded(): boolean {
376
- return this._expanded;
376
+ return expandedThinking.has(`${this.messageTimestamp}:${this.runStartIndex}`);
377
377
  }
378
378
 
379
379
  setExpanded(expanded: boolean): void {
380
- if (this._expanded !== expanded) this.collapsedMemo = undefined;
381
- this._expanded = expanded;
382
- if (expanded) expandedThinking.add(this.messageTimestamp);
380
+ const key = `${this.messageTimestamp}:${this.runStartIndex}`;
381
+ if (expanded) expandedThinking.add(key);
383
382
  else {
384
- expandedThinking.delete(this.messageTimestamp);
383
+ expandedThinking.delete(key);
385
384
  evictExpandedWraps(this.messageTimestamp, this.runStartIndex);
386
385
  }
386
+ this.collapsedMemo = undefined;
387
387
  }
388
388
 
389
389
  setHintHovered(hovered: boolean): void {
@@ -392,7 +392,7 @@ export class ThinkingPreviewBlock implements Component {
392
392
  }
393
393
 
394
394
  private headingLines(width: number, hiddenLines: number, padding: number): string[] {
395
- const hint = this._expanded
395
+ const hint = this.expanded
396
396
  ? undefined
397
397
  : hiddenPreviewHint(hiddenLines, Boolean(this.text) && config.previewLines <= 0);
398
398
  if (!hint) return new Text(this.heading, padding, 0).render(width);
@@ -412,10 +412,10 @@ export class ThinkingPreviewBlock implements Component {
412
412
  }
413
413
 
414
414
  private bodyLines(width: number, padding: number): { lines: string[]; hiddenLines: number } {
415
- if (!this.text || (config.previewLines <= 0 && !this._expanded)) {
415
+ if (!this.text || (config.previewLines <= 0 && !this.expanded)) {
416
416
  return { lines: [], hiddenLines: 0 };
417
417
  }
418
- if (this._expanded) {
418
+ if (this.expanded) {
419
419
  return {
420
420
  lines: wrapExpandedThinking(this.messageTimestamp, this.runStartIndex, this.text, width),
421
421
  hiddenLines: 0,
@@ -426,7 +426,7 @@ export class ThinkingPreviewBlock implements Component {
426
426
  }
427
427
 
428
428
  render(width: number) {
429
- if (this._expanded) {
429
+ if (this.expanded) {
430
430
  const innerWidth = Math.max(1, width - 2);
431
431
  const body = this.bodyLines(innerWidth, 0);
432
432
  const heading = this.headingLines(innerWidth, 0, 0);
@@ -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,
@@ -419,8 +420,10 @@ const createCustomFooterFactory =
419
420
  cachePct > 0 ? `${glyphs.cache ? `${glyphs.cache} ` : ""}${Math.floor(cachePct)}%` : "";
420
421
  const costChip =
421
422
  cost || usingSubscription
422
- ? theme.fg("dim", `$${cost.toFixed(2)}`) +
423
- (usingSubscription ? theme.fg("warning", " sub") : "")
423
+ ? theme.fg(
424
+ "dim",
425
+ formatFooterCost(cost, config.footerCurrency, config.footerCurrencyRate),
426
+ ) + (usingSubscription ? theme.fg("warning", " sub") : "")
424
427
  : "";
425
428
  const line1 = joinChips([
426
429
  theme.fg("accent", modelLabel),
@@ -535,6 +535,12 @@ function isToolComponent(value: any): boolean {
535
535
  return value instanceof ToolExecutionComponent;
536
536
  }
537
537
 
538
+ /** 成员离开回合后不再算外层面板,避免点别的卡时被跳过收起。 */
539
+ function clearRoundAnchor(component: any): void {
540
+ if (!component || Object.getOwnPropertyDescriptor(component, "roundAnchor")?.get) return;
541
+ delete component.roundAnchor;
542
+ }
543
+
538
544
  function detachAssistantExpansion(component: any): void {
539
545
  if (
540
546
  typeof component?.[ASSISTANT_SET_EXPANDED_KEY] !== "function" ||
@@ -546,6 +552,7 @@ function detachAssistantExpansion(component: any): void {
546
552
  delete component[ASSISTANT_SET_EXPANDED_KEY];
547
553
  delete component[ASSISTANT_TOGGLE_ROUND_KEY];
548
554
  delete component[ASSISTANT_REENTRY_KEY];
555
+ clearRoundAnchor(component);
549
556
  }
550
557
 
551
558
  /** 供 mouse-interaction 识别可点击的 compact assistant 行(仅 compact 模式下生效)。 */
@@ -949,8 +956,11 @@ function compactAssistantLineComponent(
949
956
  text = `${styleCompactThinkingText(heading, theme)}${plain(tools)}`;
950
957
  }
951
958
  }
952
- const hintColor = hover ? "text" : "dim";
953
- const line = `${text}${hintText ? theme.fg(hintColor, hintText) : ""}`;
959
+ // 圆点保持 dim;hover 只高亮可点击文字。
960
+ const hintPart = hint
961
+ ? `${theme.fg("dim", " · ")}${theme.fg(hover ? "text" : "dim", showMoreHintText())}`
962
+ : "";
963
+ const line = `${text}${hintPart}`;
954
964
  const rendered = `${" ".repeat(pad)}${truncateToWidth(line, available, "")}`;
955
965
  const lines = leadingBlank ? ["", rendered] : [rendered];
956
966
  paint = {
@@ -1695,6 +1705,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1695
1705
  for (const [component, message] of round.messages) {
1696
1706
  component.lastMessage = message;
1697
1707
  ensureAssistantSetExpanded(component);
1708
+ // 尾行用 getter 指向 anchor;成员用同名属性,点内部工具时能认出同一回合。
1709
+ if (!Object.getOwnPropertyDescriptor(component, "roundAnchor")?.get) {
1710
+ component.roundAnchor = round.anchor;
1711
+ }
1698
1712
  component[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) => {
1699
1713
  const wasExpanded = round.anchor.expanded === true;
1700
1714
  if (expanded && !wasExpanded) capturePanelViewport(round.anchor);
@@ -1946,7 +1960,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1946
1960
  if (hasToolCalls) {
1947
1961
  let round = roundByComponent.get(this);
1948
1962
  if (hasText && (!round || round.anchor !== this)) {
1949
- if (round) round.messages.delete(this);
1963
+ if (round) {
1964
+ round.messages.delete(this);
1965
+ clearRoundAnchor(this);
1966
+ }
1950
1967
  if (activeRound) finishRound(activeRound);
1951
1968
  round = createRound(this, message);
1952
1969
  roundByComponent.set(this, round);
@@ -1966,6 +1983,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1966
1983
  // 最终回答开始后,当前组件恢复原生文本;它已完成的 thinking
1967
1984
  // 留在上一轮摘要中,避免再次生成独立 Thought 行。
1968
1985
  round.messages.delete(this);
1986
+ clearRoundAnchor(this);
1969
1987
  round.detachedMessages.push(previousMessage);
1970
1988
  roundByComponent.delete(this);
1971
1989
  finishRound(round);
@@ -1975,6 +1993,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
1975
1993
  // 围观态下 anchor 还挂着槽位卡,必须重绘才能收回。
1976
1994
  endRound(round, true);
1977
1995
  roundByComponent.delete(this);
1996
+ clearRoundAnchor(this);
1978
1997
  return passThroughAssistant(this, message);
1979
1998
  }
1980
1999
  if (activeRound) finishRound(activeRound);
@@ -85,8 +85,8 @@ type GlobalToolRenderPatch = {
85
85
  prototype: any;
86
86
  active: boolean;
87
87
  mode: () => CompactStyleMode;
88
- wrap: (tool: any) => any;
89
- byDefinition: WeakMap<object, any>;
88
+ wrap: (tool: any, toolName: string) => any;
89
+ byDefinition: WeakMap<object, Map<string, any>>;
90
90
  byName: Map<string, any>;
91
91
  downstream: ToolRenderMethods;
92
92
  installed: ToolRenderMethods;
@@ -217,8 +217,8 @@ function renderExpandedTaskResult(
217
217
  function createCcstyleTool(
218
218
  originalTool: any,
219
219
  writeExecutionMetadata: WriteExecutionMetadataStore,
220
+ toolName: string,
220
221
  ): any {
221
- const toolName = originalTool.name;
222
222
  const label = originalTool.label || toolName;
223
223
  const defaultTitle = label === toolName ? humanizeToolLabel(label) : label;
224
224
  // MCP 工具直接用 adapter 暴露的真实工具名,不做人性化
@@ -459,19 +459,24 @@ function shouldGloballyStyleTool(component: any, patch: GlobalToolRenderPatch):
459
459
 
460
460
  function getGloballyStyledTool(component: any, patch: GlobalToolRenderPatch): any {
461
461
  const definition = component.toolDefinition ?? component.builtInToolDefinition;
462
+ const name = String(component.toolName || definition?.name || "tool");
462
463
  if (definition && typeof definition === "object") {
463
- let wrapped = patch.byDefinition.get(definition);
464
+ let byName = patch.byDefinition.get(definition);
465
+ if (!byName) {
466
+ byName = new Map();
467
+ patch.byDefinition.set(definition, byName);
468
+ }
469
+ let wrapped = byName.get(name);
464
470
  if (!wrapped) {
465
- wrapped = patch.wrap(definition);
466
- patch.byDefinition.set(definition, wrapped);
471
+ wrapped = patch.wrap(definition, name);
472
+ byName.set(name, wrapped);
467
473
  }
468
474
  return wrapped;
469
475
  }
470
476
 
471
- const name = String(component.toolName || "tool");
472
477
  let wrapped = patch.byName.get(name);
473
478
  if (!wrapped) {
474
- wrapped = patch.wrap({ name, label: name });
479
+ wrapped = patch.wrap({ name, label: name }, name);
475
480
  patch.byName.set(name, wrapped);
476
481
  }
477
482
  return wrapped;
@@ -568,7 +573,8 @@ function installGlobalToolRendering(
568
573
  prototype,
569
574
  active: true,
570
575
  mode: () => config.mode,
571
- wrap: (tool: any) => createCcstyleTool(tool, writeExecutionMetadata),
576
+ wrap: (tool: any, toolName: string) =>
577
+ createCcstyleTool(tool, writeExecutionMetadata, toolName),
572
578
  byDefinition: new WeakMap(),
573
579
  byName: new Map(),
574
580
  downstream,
@@ -13,7 +13,6 @@ import {
13
13
  setHoveredCompactAssistant,
14
14
  } from "../compact-mode.ts";
15
15
  import { isMessageDisplayComponent } from "../tool/message-display.ts";
16
- import { config } from "../../config/config.ts";
17
16
  import { isLazyProxyTui } from "../../utils/fullscreen-detect.ts";
18
17
  import { setToolTuiFullscreen, isCollapseHintLine } from "../tool/show-more-hint.ts";
19
18
  import {
@@ -308,10 +307,6 @@ function ensureFullscreenToolMouseMotion(tui: any): void {
308
307
  releaseFullscreenToolMouseMotion(tui);
309
308
  return;
310
309
  }
311
- // 面板改 scrollStepLines 后,下一帧渲染即同步(restore 仍按 original 恢复)。
312
- if (typeof tui.wheelScrollLines === "number" && tui.wheelScrollLines !== config.scrollStepLines) {
313
- tui.wheelScrollLines = config.scrollStepLines;
314
- }
315
310
  if (
316
311
  !toolMouseInteractionActive() ||
317
312
  tui.mouseEnabled === false ||
@@ -348,7 +343,6 @@ function releaseFullscreenToolMouseMotion(tui?: any): void {
348
343
  }
349
344
 
350
345
  const FULLSCREEN_VIEWPORT_PATCH = Symbol("ccstyle.fullscreen-viewport-patch");
351
- const FULLSCREEN_WHEEL_SCROLL_ORIGINAL = Symbol("ccstyle.fullscreen-wheel-scroll-original");
352
346
 
353
347
  /**
354
348
  * diff 结果组件自带 remainder 行:声明存在时只有那一行是展开入口(正文里的同名字样不算);
@@ -418,11 +412,11 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
418
412
  if (!isTool && !isGroup && !isAssistant && !isThinking && !isMessage) return false;
419
413
  if (!component.expanded) {
420
414
  // collapsed 仅按钮文本可展开,不能把同一行正文/留白变成点击区。
415
+ // thinking 例外:标题行(标题、隐藏行数与提示同一行)整行都是展开入口。
421
416
  const hint = collapsedHintHitbox(line);
422
417
  const onHint = Boolean(
423
418
  hint &&
424
- packet.col >= hint.startCol &&
425
- packet.col <= hint.endCol &&
419
+ (isThinking || (packet.col >= hint.startCol && packet.col <= hint.endCol)) &&
426
420
  isCollapsedHintRow(component, line),
427
421
  );
428
422
  if (!onHint) {
@@ -437,18 +431,16 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
437
431
  if (other !== component && other.expanded) {
438
432
  // 展开 round 卡内 thinking/工具时,外层 compact 卡是它的容器,不能收起。
439
433
  // 面板挂在摘要行时,anchor 与摘要行是同一张外层卡,点内部工具不能收起它。
440
- if (
441
- (isThinking || isTool) &&
442
- isCompactAssistantComponent(other) &&
443
- (other === target.owner || other === target.owner?.roundAnchor)
444
- )
445
- continue;
434
+ // 同一回合的摘要行、anchor 和其他消息都是外层面板,收起任一个都会关掉整块。
435
+ if ((isThinking || isTool) && isSameExpandedRound(other, target.owner)) continue;
446
436
  other.setExpanded(false);
447
437
  other.invalidate?.();
448
438
  }
449
439
  }
450
440
  // 展开 round 卡内工具时,让 compact 的强制折叠放行它(非 round 内工具为空操作)。
451
441
  if (isTool) markCompactRoundToolExpanded(component);
442
+ // compact 摘要行自己记展开前的视口(收起时还原跟随),这里不动它。
443
+ if (!isAssistant) keepViewportOnExpand(tui);
452
444
  component.setExpanded(true);
453
445
  clearPendingCollapsePress();
454
446
  } else {
@@ -489,6 +481,25 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
489
481
  return true;
490
482
  }
491
483
 
484
+ /** 点到的组件和外层卡是否属于同一张 compact 回合面板。 */
485
+ function isSameExpandedRound(other: any, owner: any): boolean {
486
+ if (!owner || !isCompactAssistantComponent(other)) return false;
487
+ const anchor = owner.roundAnchor ?? owner;
488
+ return other === owner || other === anchor || other.roundAnchor === anchor;
489
+ }
490
+
491
+ /**
492
+ * 原地展开时停在当前滚动位置:transcript 跟随底部时,内容变高会把视口拖到底,
493
+ * 点击的行和它上方的外层面板被顶出屏幕。展开前关掉跟随,点击行留在原位。
494
+ */
495
+ function keepViewportOnExpand(tui: any): void {
496
+ const view = tui.getPrimaryScrollView?.();
497
+ if (typeof view?.scrollTo !== "function" || typeof view.scrollTop !== "number") return;
498
+ view.scrollTo(view.scrollTop, { disableFollow: true });
499
+ // 已脱离底部跟随:亮出回到底部按钮,新输出到来时仍有入口。
500
+ if (view.isFollowingEnd === false) setScrollButtonVisible(true);
501
+ }
502
+
492
503
  /**
493
504
  * fullscreen 鼠标悬停:collapsed 卡 [click to show more] hint、
494
505
  * expanded 卡截断头 show-more、回到底部按钮。motion 不 consume,官方链照常。
@@ -528,7 +539,8 @@ function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
528
539
  if (component instanceof ToolGroupComponent) {
529
540
  if (overHint) target = { kind: "group", component };
530
541
  } else if (component instanceof ThinkingPreviewBlock) {
531
- if (component.expanded || overHint) target = { kind: "thinking", component };
542
+ // thinking 折叠标题行整行可点:整块 hover 都高亮动作词,不只高亮按钮文本。
543
+ if (component.expanded || Boolean(hintBox)) target = { kind: "thinking", component };
532
544
  } else if (isToolExecutionComponent(component)) {
533
545
  let view: ExpandedToolIoView | null = null;
534
546
  let section: ToolIoSection | null = null;
@@ -571,11 +583,6 @@ function patchFullscreenViewportInput(tui: any): void {
571
583
  const proto = Object.getPrototypeOf(tui);
572
584
  const original = proto?.handleViewportInput;
573
585
  if (typeof original !== "function") return;
574
- // 官方原生 routeWheel 已完整处理嵌套 ScrollView;只调整默认步进(config.scrollStepLines)。
575
- if (typeof tui.wheelScrollLines === "number") {
576
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = tui.wheelScrollLines;
577
- tui.wheelScrollLines = config.scrollStepLines;
578
- }
579
586
  tui[FULLSCREEN_VIEWPORT_PATCH] = true;
580
587
  tui.handleViewportInput = function (this: any, data: string) {
581
588
  if (toolMouseInteractionActive() && tui.mode === "fullscreen") {
@@ -624,11 +631,6 @@ function restoreFullscreenViewportInput(tui: any): void {
624
631
  if (typeof proto?.handleViewportInput === "function") {
625
632
  tui.handleViewportInput = proto.handleViewportInput;
626
633
  }
627
- const originalWheelScrollLines = tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL];
628
- if (typeof originalWheelScrollLines === "number") {
629
- tui.wheelScrollLines = originalWheelScrollLines;
630
- tui[FULLSCREEN_WHEEL_SCROLL_ORIGINAL] = undefined;
631
- }
632
634
  tui[FULLSCREEN_VIEWPORT_PATCH] = false;
633
635
  }
634
636
 
@@ -235,7 +235,10 @@ export function codemodeCollapsedLines(options: {
235
235
  const summary = codemodeSummaryParts(calls, codemodeOutputLineCount(result), running);
236
236
  // 没有子调用时不画衔接符,避免孤零零一个 └
237
237
  const prefix = shown.length > 0 ? `${INDENT}${fg("dim", "└")} ` : INDENT;
238
- const hint = running ? "" : ` ${fg(options.hovered ? "text" : "dim", `· ${showMoreHintText()}`)}`;
238
+ // 圆点保持 dim;hover 只高亮可点击文字。
239
+ const hint = running
240
+ ? ""
241
+ : ` ${fg("dim", "·")} ${fg(options.hovered ? "text" : "dim", showMoreHintText())}`;
239
242
  const bodyWidth = Math.max(1, rowWidth - visibleWidth(prefix) - visibleWidth(hint));
240
243
  const body = fg(isError ? "error" : "muted", fitSummaryParts(summary, bodyWidth));
241
244
  lines.push(truncateToWidth(`${prefix}${body}${hint}`, rowWidth, ""));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",