pi-cc-extensions 0.9.11 → 0.9.12

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
@@ -61,14 +61,14 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
61
61
  "expandedOutputMaxLines": 10, // expanded tool Output lines; overflow shows a footer hint
62
62
  "expandedPreviewMaxLines": 40, // max lines for expanded TaskList bodies (expanded diffs always show all)
63
63
  "expandedCardBackground": "userMessageBg", // expanded card background slot: userMessageBg (same as user prompts) / toolPendingBg (neutral) etc.
64
- "inputClip": 0, // tool summary path/command clip length; 0 = fit width
64
+ "toolLabelClip": 0, // tool summary path/command clip length; 0 = fit width
65
65
  "showStartupHeader": true, // startup header (logo + tips) toggle
66
66
 
67
67
  // diff
68
68
  "diffViewMode": "auto", // layout: auto / split / unified
69
69
  "diffIndicatorMode": "bars", // change indicators: bars / classic / none
70
70
  "diffSplitMinWidth": 120, // min terminal width for side-by-side columns
71
- "editDiffCollapsedLines": 24, // Edit collapse lines; beyond that shows the expand hint
71
+ "editDiffCollapsedLines": 24, // edit collapse lines; 0 = change-count summary only
72
72
  "writeDiffCollapsedLines": 0, // write collapse lines; 0 = creation summary only
73
73
  "diffWordWrap": true, // wrap long diff lines
74
74
 
@@ -83,6 +83,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
83
83
  "footerCurrency": "USD", // display currency for the footer cost
84
84
  "footerCurrencyRate": null, // display units per 1 cost unit; null changes the symbol only
85
85
  "footerNerdIcons": true, // Nerd Font glyphs for git/cache; false = plain text
86
+ "footerGitStatsMode": "working", // working (default): uncommitted; branch: cumulative net changes
86
87
  "footerHiddenKeys": [], // hidden plugin chip keys
87
88
  "footerLine1Keys": ["pi-usage"], // line1 plugin chip order; pi-usage is shown by default, data from @narumitw/pi-usage
88
89
  "footerLine2Keys": [], // line2 plugin chip order (after cwd/git)
package/README.md CHANGED
@@ -61,14 +61,14 @@ pi install git:github.com/minuque/pi-cc-extensions
61
61
  "expandedOutputMaxLines": 10, // 展开工具卡 Output 可见行数,超出在末行显示展开提示
62
62
  "expandedPreviewMaxLines": 40, // 展开 TaskList 正文最大行数(展开 diff 始终全量)
63
63
  "expandedCardBackground": "userMessageBg", // 展开卡背景槽位;userMessageBg 与用户消息同色,toolPendingBg 为中性灰
64
- "inputClip": 0, // 工具摘要 path/command 折叠字符数;0 = 按可用宽度
64
+ "toolLabelClip": 0, // 工具摘要 path/command 折叠字符数;0 = 按可用宽度
65
65
  "showStartupHeader": true, // 启动头(logo + tips)开关
66
66
 
67
67
  // diff
68
68
  "diffViewMode": "auto", // 布局:auto / split / unified
69
69
  "diffIndicatorMode": "bars", // 变更指示:bars / classic / none
70
70
  "diffSplitMinWidth": 120, // 左右分栏的最小终端宽度
71
- "editDiffCollapsedLines": 24, // Edit 折叠行数,超出显示展开提示
71
+ "editDiffCollapsedLines": 24, // edit 折叠行数;0 仅显示变更统计摘要
72
72
  "writeDiffCollapsedLines": 0, // write 折叠行数,0 仅显示创建摘要
73
73
  "diffWordWrap": true, // 长 diff 行换行
74
74
 
@@ -83,6 +83,7 @@ pi install git:github.com/minuque/pi-cc-extensions
83
83
  "footerCurrency": "USD", // 底栏费用的显示币种
84
84
  "footerCurrencyRate": null, // 1 单位费用换成多少显示币种;null 只换符号、不乘倍率
85
85
  "footerNerdIcons": true, // git/缓存用 Nerd Font 图标;false 为纯文本
86
+ "footerGitStatsMode": "working", // working(默认):未提交改动;branch:分支累计净改动
86
87
  "footerHiddenKeys": [], // 隐藏的插件芯片 key
87
88
  "footerLine1Keys": ["pi-usage"], // line1 插件芯片顺序;pi-usage 默认显示,数据来自 @narumitw/pi-usage
88
89
  "footerLine2Keys": [], // line2 插件芯片顺序(接在 cwd/git 后)
@@ -13,11 +13,13 @@ export type CompactStyleMode = "on" | "compact" | "off";
13
13
 
14
14
  export type DiffViewMode = "auto" | "split" | "unified";
15
15
  export type DiffIndicatorMode = "bars" | "classic" | "none";
16
+ export type FooterGitStatsMode = "working" | "branch";
16
17
 
17
18
  export interface ToolDisplayConfig {
18
19
  diffViewMode: DiffViewMode;
19
20
  diffIndicatorMode: DiffIndicatorMode;
20
21
  diffSplitMinWidth: number;
22
+ /** Edit-only collapsed body lines. 0 = change counts and an expand hint. */
21
23
  editDiffCollapsedLines: number;
22
24
  /** Write-only collapsed body lines. 0 = `↳ created · click to show more`. */
23
25
  writeDiffCollapsedLines: number;
@@ -57,7 +59,7 @@ export type Config = {
57
59
  expandedCardBackground: ThemeBg;
58
60
  expandedInputMaxLines: number;
59
61
  expandedOutputMaxLines: number;
60
- inputClip: number;
62
+ toolLabelClip: number;
61
63
  useSummaryTitlesAsThinkingTitle: boolean;
62
64
  previewLines: number;
63
65
  animationIntervalMs: number;
@@ -75,6 +77,7 @@ export type Config = {
75
77
  /** 1 单位费用对应的显示币种数量。null 只换符号、不乘倍率。 */
76
78
  footerCurrencyRate: number | null;
77
79
  footerNerdIcons: boolean;
80
+ footerGitStatsMode: FooterGitStatsMode;
78
81
  footerHiddenKeys: string[];
79
82
  footerLine1Keys: string[];
80
83
  footerLine2Keys: string[];
@@ -87,8 +90,9 @@ const LEGACY_CONFIG_PATH = join(AGENT_DIR, "claude-code-style.json");
87
90
 
88
91
  export const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
89
92
  export const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
93
+ export const FOOTER_GIT_STATS_MODES: FooterGitStatsMode[] = ["working", "branch"];
90
94
  export const DIFF_SPLIT_MIN_WIDTH_VALUES = ["80", "100", "120", "140", "160", "180"];
91
- export const DIFF_COLLAPSED_LINES_VALUES = ["12", "24", "36", "48", "80", "120"];
95
+ export const DIFF_COLLAPSED_LINES_VALUES = ["0", "12", "24", "36", "48", "80", "120"];
92
96
  /** Write collapsed presets. 0 = stats only (`+N -0` + expand hint). */
93
97
  export const WRITE_DIFF_COLLAPSED_LINES_VALUES = ["0", "4", "8", "12", "24", "36"];
94
98
  /** Presets for expanded body height — keep low options first so cycling stays TUI-friendly. */
@@ -112,7 +116,7 @@ export const EXPANDED_INPUT_MAX_LINES_VALUES = ["5", "10", "20", "40", "80"];
112
116
  /** 展开工具卡 Output 可见行数预设。 */
113
117
  export const EXPANDED_OUTPUT_MAX_LINES_VALUES = ["10", "20", "40", "80", "120"];
114
118
  /** 工具摘要里 path/command 等输入的折叠字符数;0 = 只按可用宽度截断。 */
115
- export const INPUT_CLIP_VALUES = ["0", "40", "60", "80", "100", "120", "160"];
119
+ export const TOOL_LABEL_CLIP_VALUES = ["0", "40", "60", "80", "100", "120", "160"];
116
120
  export const THINKING_PREVIEW_LINES_VALUES = ["0", "1", "3", "5", "10"];
117
121
  export const THINKING_ANIMATION_INTERVAL_VALUES = ["40", "60", "90", "120", "180"];
118
122
  /** Tools commonly toggled in excludeRenderers via the settings panel. */
@@ -143,7 +147,7 @@ export const DEFAULT_CONFIG: Config = {
143
147
  expandedCardBackground: "userMessageBg",
144
148
  expandedInputMaxLines: 5,
145
149
  expandedOutputMaxLines: 10,
146
- inputClip: 0,
150
+ toolLabelClip: 0,
147
151
  useSummaryTitlesAsThinkingTitle: true,
148
152
  previewLines: 3,
149
153
  animationIntervalMs: 90,
@@ -159,6 +163,7 @@ export const DEFAULT_CONFIG: Config = {
159
163
  footerCurrency: "USD",
160
164
  footerCurrencyRate: null,
161
165
  footerNerdIcons: true,
166
+ footerGitStatsMode: "working",
162
167
  ...DEFAULT_FOOTER_CHIP_LAYOUT,
163
168
  };
164
169
 
@@ -174,10 +179,10 @@ export function pickPositiveInt(value: unknown, fallback: number, min = 1, max =
174
179
  return Math.min(max, Math.max(min, Math.floor(n)));
175
180
  }
176
181
 
177
- /** inputClip:0 表示按宽度自适应,其余夹到 8..500。 */
178
- export function pickInputClip(value: unknown): number {
182
+ /** toolLabelClip:0 表示按宽度自适应,其余夹到 8..500。 */
183
+ export function pickToolLabelClip(value: unknown): number {
179
184
  const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
180
- return n === 0 ? 0 : pickPositiveInt(value, DEFAULT_CONFIG.inputClip, 8, 500);
185
+ return n === 0 ? 0 : pickPositiveInt(value, DEFAULT_CONFIG.toolLabelClip, 8, 500);
181
186
  }
182
187
 
183
188
  export function pickPositiveNumber(value: unknown, fallback: number, min = 1): number {
@@ -228,7 +233,7 @@ export function normalizeConfig(input: unknown): Config {
228
233
  editDiffCollapsedLines: pickPositiveInt(
229
234
  source.editDiffCollapsedLines,
230
235
  DEFAULT_CONFIG.editDiffCollapsedLines,
231
- 1,
236
+ 0,
232
237
  500,
233
238
  ),
234
239
  writeDiffCollapsedLines: pickPositiveInt(
@@ -261,7 +266,9 @@ export function normalizeConfig(input: unknown): Config {
261
266
  1,
262
267
  5_000,
263
268
  ),
264
- inputClip: pickInputClip(source.inputClip),
269
+ toolLabelClip: pickToolLabelClip(
270
+ source.toolLabelClip ?? source.inputClip, // 兼容旧配置键
271
+ ),
265
272
  useSummaryTitlesAsThinkingTitle: source.useSummaryTitlesAsThinkingTitle !== false,
266
273
  previewLines: pickPositiveInt(
267
274
  source.previewLines,
@@ -285,6 +292,11 @@ export function normalizeConfig(input: unknown): Config {
285
292
  footerCurrency: normalizeCurrency(source.footerCurrency),
286
293
  footerCurrencyRate: normalizeFooterCurrencyRate(source.footerCurrencyRate),
287
294
  footerNerdIcons: source.footerNerdIcons !== false,
295
+ footerGitStatsMode: pickEnum(
296
+ source.footerGitStatsMode,
297
+ FOOTER_GIT_STATS_MODES,
298
+ DEFAULT_CONFIG.footerGitStatsMode,
299
+ ),
288
300
  ...normalizeFooterChipLayout(source),
289
301
  };
290
302
  }
@@ -326,7 +338,7 @@ export function formatConfigStatus(source: Config = config): string {
326
338
  `expandedMax=${source.expandedPreviewMaxLines}`,
327
339
  `expandedInput=${source.expandedInputMaxLines}`,
328
340
  `expandedOutput=${source.expandedOutputMaxLines}`,
329
- `inputClip=${source.inputClip}`,
341
+ `toolLabelClip=${source.toolLabelClip}`,
330
342
  `thinkingTitle=${source.useSummaryTitlesAsThinkingTitle ? "summary" : "default"}`,
331
343
  `thinkingPreview=${source.previewLines}`,
332
344
  `thinkingAnimation=${source.animationIntervalMs}ms`,
@@ -342,6 +354,7 @@ export function formatConfigStatus(source: Config = config): string {
342
354
  `footerCurrency=${source.footerCurrency}`,
343
355
  `footerRate=${source.footerCurrencyRate ?? "-"}`,
344
356
  `footerIcons=${source.footerNerdIcons ? "nerd" : "plain"}`,
357
+ `footerGitStats=${source.footerGitStatsMode}`,
345
358
  formatFooterChipSummary(source),
346
359
  ].join(" · ");
347
360
  }
@@ -11,6 +11,7 @@ import {
11
11
  applyCustomFooter,
12
12
  clearCustomFooter,
13
13
  getFooterStatusSnapshot,
14
+ refreshFooterGitStats,
14
15
  } from "../feature/shell/footer.ts";
15
16
  import {
16
17
  FOOTER_CURRENCY_PRESETS,
@@ -41,20 +42,22 @@ import {
41
42
  EXPANDED_INPUT_MAX_LINES_VALUES,
42
43
  EXPANDED_OUTPUT_MAX_LINES_VALUES,
43
44
  EXPANDED_PREVIEW_MAX_LINES_VALUES,
45
+ FOOTER_GIT_STATS_MODES,
44
46
  formatExcludeRenderers,
45
47
  getCompactThinkingConfig,
46
- pickInputClip,
47
48
  pickPositiveInt,
48
49
  pickPositiveNumber,
50
+ pickToolLabelClip,
49
51
  THINKING_ANIMATION_INTERVAL_VALUES,
50
52
  THINKING_PREVIEW_LINES_VALUES,
51
- INPUT_CLIP_VALUES,
53
+ TOOL_LABEL_CLIP_VALUES,
52
54
  WRITE_DIFF_COLLAPSED_LINES_VALUES,
53
55
  updateConfig,
54
56
  type CompactStyleMode,
55
57
  type Config,
56
58
  type DiffIndicatorMode,
57
59
  type DiffViewMode,
60
+ type FooterGitStatsMode,
58
61
  } from "./config.ts";
59
62
 
60
63
  /** renderer 注入的渲染副作用,面板自身不触碰渲染状态。 */
@@ -84,6 +87,12 @@ function customFooterDescription(enabled: boolean): string {
84
87
  return "Custom status bar with model, context, cache, cost, git, and plugin chips.";
85
88
  }
86
89
 
90
+ function footerGitStatsDescription(mode: FooterGitStatsMode): string {
91
+ return mode === "branch"
92
+ ? "Net changes since the merge base, including uncommitted edits. Base: origin/HEAD, then local main/master. Untracked files excluded; unavailable stats hidden."
93
+ : "Uncommitted net changes relative to HEAD (default). Includes staged and unstaged edits; excludes untracked files.";
94
+ }
95
+
87
96
  function pluginChipsDescription(): string {
88
97
  return `Arrange plugin chips: ${formatFooterChipSummary(config)}. Enter to edit.`;
89
98
  }
@@ -444,15 +453,15 @@ export async function showCcstylePanel(
444
453
  );
445
454
  },
446
455
  };
447
- const inputClipSetting = {
448
- id: "inputClip",
449
- label: "Input clip",
456
+ const toolLabelClipSetting = {
457
+ id: "toolLabelClip",
458
+ label: "Tool label clip",
450
459
  description:
451
460
  "Max characters for path/command/name in single and grouped tool summaries; 0 fits available width. Enter to type a custom value.",
452
- currentValue: String(config.inputClip),
453
- values: [...INPUT_CLIP_VALUES],
461
+ currentValue: String(config.toolLabelClip),
462
+ values: [...TOOL_LABEL_CLIP_VALUES],
454
463
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
455
- buildNumberInputSubmenu(theme, inputClipSetting, closeSubmenu),
464
+ buildNumberInputSubmenu(theme, toolLabelClipSetting, closeSubmenu),
456
465
  };
457
466
  const diffViewSetting = {
458
467
  id: "diffViewMode",
@@ -482,7 +491,7 @@ export async function showCcstylePanel(
482
491
  id: "editDiffCollapsedLines",
483
492
  label: "Edit collapsed lines",
484
493
  description:
485
- "How many edit/diff body lines to show before the expand hint (Ctrl+O / click). Write uses its own setting below.",
494
+ "Edit diff body lines before the expand hint (Ctrl+O / click). 0 shows only change counts and the hint.",
486
495
  currentValue: String(config.editDiffCollapsedLines),
487
496
  values: [...DIFF_COLLAPSED_LINES_VALUES],
488
497
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) =>
@@ -705,6 +714,13 @@ export async function showCcstylePanel(
705
714
  currentValue: config.enableCustomFooter ? "on" : "off",
706
715
  values: ["on", "off"],
707
716
  };
717
+ const footerGitStatsSetting = {
718
+ id: "footerGitStatsMode",
719
+ label: "Git changes",
720
+ description: footerGitStatsDescription(config.footerGitStatsMode),
721
+ currentValue: config.footerGitStatsMode,
722
+ values: FOOTER_GIT_STATS_MODES,
723
+ };
708
724
  const pluginChipsSetting = {
709
725
  id: "footerPluginChips",
710
726
  label: "Plugin chips",
@@ -769,6 +785,14 @@ export async function showCcstylePanel(
769
785
  ctx.ui.notify(`Updated ${id}: ${footerCurrencyRateSetting.currentValue}`, "info");
770
786
  return;
771
787
  }
788
+ if (id === "footerGitStatsMode") {
789
+ updateConfig({ footerGitStatsMode: value as FooterGitStatsMode });
790
+ footerGitStatsSetting.currentValue = config.footerGitStatsMode;
791
+ footerGitStatsSetting.description = footerGitStatsDescription(config.footerGitStatsMode);
792
+ refreshFooterGitStats();
793
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
794
+ return;
795
+ }
772
796
  if (id === "footerNerdIcons") {
773
797
  const enabled = value === "on";
774
798
  updateConfig({ footerNerdIcons: enabled });
@@ -788,11 +812,11 @@ export async function showCcstylePanel(
788
812
  return;
789
813
  }
790
814
  switch (id) {
791
- case "inputClip":
815
+ case "toolLabelClip":
792
816
  updateConfig({
793
- inputClip: pickInputClip(value),
817
+ toolLabelClip: pickToolLabelClip(value),
794
818
  });
795
- inputClipSetting.currentValue = String(config.inputClip);
819
+ toolLabelClipSetting.currentValue = String(config.toolLabelClip);
796
820
  break;
797
821
  case "mode": {
798
822
  // 选项值带 Experimental 标记,选择后还原为真实 mode 值。
@@ -825,7 +849,7 @@ export async function showCcstylePanel(
825
849
  editDiffCollapsedLines: pickPositiveInt(
826
850
  value,
827
851
  DEFAULT_CONFIG.editDiffCollapsedLines,
828
- 1,
852
+ 0,
829
853
  500,
830
854
  ),
831
855
  });
@@ -950,7 +974,7 @@ export async function showCcstylePanel(
950
974
  expandedOutputSetting,
951
975
  expandedMaxSetting,
952
976
  expandedCardBgSetting,
953
- inputClipSetting,
977
+ toolLabelClipSetting,
954
978
  startupHeaderSetting,
955
979
  ],
956
980
  },
@@ -984,6 +1008,7 @@ export async function showCcstylePanel(
984
1008
  footerCurrencySetting,
985
1009
  footerCurrencyRateSetting,
986
1010
  footerNerdIconsSetting,
1011
+ footerGitStatsSetting,
987
1012
  pluginChipsSetting,
988
1013
  ],
989
1014
  },
@@ -14,7 +14,6 @@
14
14
 
15
15
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import { truncateToWidth } from "@earendil-works/pi-tui";
17
- import { execFile } from "node:child_process";
18
17
  import { existsSync } from "node:fs";
19
18
  import { homedir } from "node:os";
20
19
  import { join } from "node:path";
@@ -29,8 +28,11 @@ import {
29
28
  visibleFooterPluginTexts,
30
29
  type FooterChipLayout,
31
30
  } from "./footer-layout.ts";
31
+ import { createGitStatsRefresher, readGitStats, type GitStats } from "./git-stats.ts";
32
32
  import { MCP_STATUS_KEY, buildMcpChip } from "./mcp-chip.ts";
33
33
 
34
+ export { parseGitStats } from "./git-stats.ts";
35
+
34
36
  const GIT_REFRESH_INTERVAL_MS = 10_000;
35
37
  const USAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
36
38
  // 后台连接 MCP 服务器不会触发 footer 重绘,靠这个兜底轮询把芯片刷出来
@@ -49,8 +51,6 @@ export function footerGlyphs(nerdIcons: boolean): { git: string; cache: string;
49
51
  : { git: "", cache: "", mcp: "" };
50
52
  }
51
53
 
52
- type GitStats = { add: number; del: number };
53
-
54
54
  type XaiFooterReport = {
55
55
  buckets?: Array<{
56
56
  id?: string;
@@ -62,19 +62,6 @@ type XaiFooterReport = {
62
62
  metrics?: Array<{ id?: string; value?: unknown }>;
63
63
  };
64
64
 
65
- export function parseGitStats(stdout: string): GitStats {
66
- let add = 0;
67
- let del = 0;
68
- for (const line of stdout.split("\n")) {
69
- const match = line.match(/^(\d+)\s+(\d+)/);
70
- if (match) {
71
- add += Number(match[1]);
72
- del += Number(match[2]);
73
- }
74
- }
75
- return { add, del };
76
- }
77
-
78
65
  export function formatXaiFooterChip(report: XaiFooterReport): string | undefined {
79
66
  const included = report.buckets?.find((b) => b.id === "included-allowance");
80
67
  if (included?.unit === "percent" && typeof included.used === "number") {
@@ -181,7 +168,12 @@ function colorUsageChip(theme: any, text: string): string {
181
168
  }
182
169
 
183
170
  let currentTui: any = undefined;
184
- let refreshCurrentGitStats: (() => void) | undefined;
171
+ let refreshCurrentGitStats: ((reset?: boolean) => void) | undefined;
172
+
173
+ /** Apply a /ccstyle Git mode change immediately, without recreating the footer. */
174
+ export function refreshFooterGitStats(): void {
175
+ refreshCurrentGitStats?.(true);
176
+ }
185
177
  let refreshCurrentUsage: (() => void) | undefined;
186
178
  let refreshCurrentMcpChip: (() => void) | undefined;
187
179
  /** 工具/命令列表在 pi API 上,而 footer 工厂只拿得到 ctx,加载时记下来。 */
@@ -193,33 +185,23 @@ const createCustomFooterFactory =
193
185
  const sep = theme.fg("muted", " · ");
194
186
  const joinChips = (parts: string[]) => parts.filter(Boolean).join(sep);
195
187
  let gitStats: GitStats | undefined;
196
- let gitRefreshRunning = false;
197
188
  let localUsageChip = "";
198
189
  let usageGeneration = 0;
199
190
  let usageAbort: AbortController | undefined;
200
191
  let disposed = false;
201
192
 
202
- // 查询在 render 外执行,保留旧值;仅统计结果真正变化时重绘,避免周期性清零抖动。
203
- const refreshGitStats = () => {
204
- if (gitRefreshRunning || disposed) return;
205
- gitRefreshRunning = true;
206
- execFile(
207
- "git",
208
- ["diff", "--numstat", "HEAD"],
209
- { cwd: ctx.cwd, timeout: 2000 },
210
- (err, stdout) => {
211
- gitRefreshRunning = false;
212
- if (err || disposed) return;
213
- const next = parseGitStats(stdout);
214
- if (gitStats?.add === next.add && gitStats.del === next.del) return;
215
- gitStats = next;
216
- tui.requestRender();
217
- },
218
- );
219
- };
220
- refreshCurrentGitStats = refreshGitStats;
221
- refreshGitStats();
222
- const gitRefreshTimer = setInterval(refreshGitStats, GIT_REFRESH_INTERVAL_MS);
193
+ // 查询在 render 外执行;模式/分支变化清旧值,普通轮询只在统计变化时重绘。
194
+ const gitRefresher = createGitStatsRefresher({
195
+ getMode: () => config.footerGitStatsMode,
196
+ query: (mode, signal) => readGitStats(ctx.cwd, mode, signal),
197
+ onChange: (next) => {
198
+ gitStats = next;
199
+ tui.requestRender();
200
+ },
201
+ });
202
+ refreshCurrentGitStats = gitRefresher.refresh;
203
+ gitRefresher.refresh();
204
+ const gitRefreshTimer = setInterval(() => gitRefresher.refresh(), GIT_REFRESH_INTERVAL_MS);
223
205
  gitRefreshTimer.unref?.();
224
206
 
225
207
  const refreshUsage = () => {
@@ -315,9 +297,9 @@ const createCustomFooterFactory =
315
297
  const mcpRefreshTimer = setInterval(refreshMcp, MCP_REFRESH_INTERVAL_MS);
316
298
  mcpRefreshTimer.unref?.();
317
299
 
318
- // 分支变化时同步更新分支名和相对 HEAD 的统计。
300
+ // 分支变化时重新选择基准,并拒绝旧分支的异步查询结果。
319
301
  const unsubBranch = footerData.onBranchChange(() => {
320
- refreshGitStats();
302
+ gitRefresher.refresh(true);
321
303
  tui.requestRender();
322
304
  });
323
305
 
@@ -442,14 +424,17 @@ const createCustomFooterFactory =
442
424
  }
443
425
  const branch = footerData.getGitBranch();
444
426
  if (branch) {
445
- const stats =
446
- gitStats && (gitStats.add || gitStats.del)
447
- ? theme.fg("dim", " (") +
448
- theme.fg("success", `+${gitStats.add}`) +
449
- " " +
450
- theme.fg("error", `−${gitStats.del}`) +
451
- theme.fg("dim", ")")
452
- : "";
427
+ const s = gitStats;
428
+ let stats = "";
429
+ if (s && s.status)
430
+ stats = theme.fg("dim", " [") + gitColor(s.status) + theme.fg("dim", "]");
431
+ if (s && (s.add || s.del))
432
+ stats +=
433
+ theme.fg("dim", "(") +
434
+ theme.fg("success", `+${s.add}`) +
435
+ " " +
436
+ theme.fg("error", `−${s.del}`) +
437
+ theme.fg("dim", ")");
453
438
  const gitLabel = glyphs.git ? `${glyphs.git} ${branch}` : branch;
454
439
  place += theme.fg("muted", " on ") + gitColor(gitLabel) + stats;
455
440
  }
@@ -470,6 +455,7 @@ const createCustomFooterFactory =
470
455
  },
471
456
  dispose(): void {
472
457
  disposed = true;
458
+ gitRefresher.dispose();
473
459
  usageAbort?.abort();
474
460
  usageAbort = undefined;
475
461
  clearInterval(gitRefreshTimer);
@@ -0,0 +1,162 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import type { FooterGitStatsMode } from "../../config/config.ts";
4
+
5
+ export type GitStats = { add: number; del: number; status?: string };
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const BRANCH_BASE_REFS = ["refs/remotes/origin/HEAD", "refs/heads/main", "refs/heads/master"];
9
+
10
+ export function parseGitStats(stdout: string): GitStats {
11
+ let add = 0;
12
+ let del = 0;
13
+ for (const line of stdout.split("\n")) {
14
+ const match = line.match(/^(\d+)\s+(\d+)/);
15
+ if (match) {
16
+ add += Number(match[1]);
17
+ del += Number(match[2]);
18
+ }
19
+ }
20
+ return { add, del };
21
+ }
22
+
23
+ const CONFLICT_CODES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
24
+
25
+ /**
26
+ * git status --porcelain --branch 的第一行带分支头(## branch...origin/branch [ahead N, behind M]),
27
+ * 其余行是 XY 文件状态。返回 Starship 风格的状态符号串(= ! ? ⇡⇣),无变化时 undefined。
28
+ */
29
+ export function parseGitStatus(stdout: string): string | undefined {
30
+ let conflicted = false;
31
+ let modified = false;
32
+ let untracked = false;
33
+ let ahead: number | undefined;
34
+ let behind: number | undefined;
35
+ for (const line of stdout.split("\n")) {
36
+ if (line.startsWith("## ")) {
37
+ ahead = Number(line.match(/ahead (\d+)/)?.[1]) || undefined;
38
+ behind = Number(line.match(/behind (\d+)/)?.[1]) || undefined;
39
+ continue;
40
+ }
41
+ const code = line.slice(0, 2);
42
+ if (code === "??") untracked = true;
43
+ else if (CONFLICT_CODES.has(code)) {
44
+ // 冲突文件必然也有未提交内容,! 与 = 同时出现
45
+ conflicted = true;
46
+ modified = true;
47
+ } else if (line.trim()) modified = true;
48
+ }
49
+ let status = "";
50
+ if (conflicted) status += "=";
51
+ if (modified) status += "!";
52
+ if (untracked) status += "?";
53
+ if (ahead) status += `⇡${ahead}`;
54
+ if (behind) status += `⇣${behind}`;
55
+ return status || undefined;
56
+ }
57
+
58
+ /** Read only local refs and tracked files; never fetch or change the index. */
59
+ export async function readGitStats(
60
+ cwd: string,
61
+ mode: FooterGitStatsMode,
62
+ signal?: AbortSignal,
63
+ ): Promise<GitStats | undefined> {
64
+ const git = async (args: string[]) => {
65
+ const { stdout } = await execFileAsync("git", args, {
66
+ cwd,
67
+ timeout: 2000,
68
+ maxBuffer: 16 * 1024 * 1024,
69
+ signal,
70
+ });
71
+ return stdout.trim();
72
+ };
73
+ try {
74
+ let base = "HEAD";
75
+ if (mode === "branch") {
76
+ let baseCommit: string | undefined;
77
+ for (const ref of BRANCH_BASE_REFS) {
78
+ try {
79
+ baseCommit = await git(["rev-parse", "--verify", `${ref}^{commit}`]);
80
+ break;
81
+ } catch {
82
+ if (signal?.aborted) return undefined;
83
+ // Missing/dangling remote HEAD: try the local default branches.
84
+ }
85
+ }
86
+ if (!baseCommit) return undefined;
87
+ base = await git(["merge-base", "HEAD", baseCommit]);
88
+ }
89
+ // One comparison includes committed + staged + unstaged net changes,
90
+ // without double-counting edits that are later reverted. Untracked files are excluded.
91
+ const [numstat, statusOut] = await Promise.all([
92
+ git(["diff", "--numstat", base, "--"]),
93
+ git(["status", "--porcelain", "--branch"]).catch(() => ""),
94
+ ]);
95
+ if (signal?.aborted) return undefined;
96
+ const stats = parseGitStats(numstat);
97
+ const status = parseGitStatus(statusOut);
98
+ return status ? { ...stats, status } : stats;
99
+ } catch {
100
+ // Non-repository, unborn HEAD, unrelated histories, timeout, etc.
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ /** Coalesce overlapping refreshes and reject stale results after a mode/branch change. */
106
+ export function createGitStatsRefresher(options: {
107
+ getMode: () => FooterGitStatsMode;
108
+ query: (mode: FooterGitStatsMode, signal: AbortSignal) => Promise<GitStats | undefined>;
109
+ onChange: (stats: GitStats | undefined) => void;
110
+ }): { refresh: (reset?: boolean) => void; dispose: () => void } {
111
+ let stats: GitStats | undefined;
112
+ let mode = options.getMode();
113
+ let generation = 0;
114
+ let running = false;
115
+ let pending = false;
116
+ let disposed = false;
117
+ let controller: AbortController | undefined;
118
+ const publish = (next: GitStats | undefined) => {
119
+ if (stats?.add === next?.add && stats?.del === next?.del && stats?.status === next?.status)
120
+ return;
121
+ stats = next;
122
+ options.onChange(next);
123
+ };
124
+ const refresh = (reset = false) => {
125
+ if (disposed) return;
126
+ const nextMode = options.getMode();
127
+ if (reset || mode !== nextMode) publish(undefined);
128
+ mode = nextMode;
129
+ const request = ++generation;
130
+ if (running) {
131
+ pending = true;
132
+ return;
133
+ }
134
+ running = true;
135
+ const queryController = new AbortController();
136
+ controller = queryController;
137
+ void (async () => {
138
+ let next: GitStats | undefined;
139
+ try {
140
+ next = await options.query(nextMode, queryController.signal);
141
+ } catch {
142
+ // Failed queries clear the old numbers rather than leaving a stale chip.
143
+ } finally {
144
+ if (!disposed && request === generation && nextMode === options.getMode()) publish(next);
145
+ running = false;
146
+ controller = undefined;
147
+ if (pending && !disposed) {
148
+ pending = false;
149
+ refresh();
150
+ }
151
+ }
152
+ })();
153
+ };
154
+ return {
155
+ refresh,
156
+ dispose: () => {
157
+ disposed = true;
158
+ generation++;
159
+ controller?.abort();
160
+ },
161
+ };
162
+ }
@@ -42,6 +42,7 @@ import {
42
42
  isRichDiffComponent,
43
43
  } from "./tool/diff/diff-renderer.ts";
44
44
  import { renderRichToolResult } from "./tool/diff/index.ts";
45
+ import { displayConfigCacheKey } from "./tool/diff/diff-component.ts";
45
46
  import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
46
47
  import { isToolCallHovered } from "./mouse/hover.ts";
47
48
  import { getToolMouseTui } from "./mouse/scroll.ts";
@@ -710,8 +711,9 @@ function compactEditWriteLine(
710
711
  // 展开卡 Box(1,1) 已 pad;折叠行自己留 1 格前导空格
711
712
  const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
712
713
  const namePart = theme.fg("toolTitle", name);
713
- const hintText =
714
- options.hint !== false && component.expanded !== true ? ` · ${showMoreHintText()}` : "";
714
+ const clickable = options.hint !== false && component.expanded !== true;
715
+ const clickText = clickable ? showMoreHintText() : "";
716
+ const hintText = clickable ? ` · ${clickText}` : "";
715
717
  const fixedWidth =
716
718
  visibleWidth(iconPart) +
717
719
  visibleWidth(namePart) +
@@ -720,7 +722,11 @@ function compactEditWriteLine(
720
722
  const pathWidth = Math.max(0, width - fixedWidth - (path ? 1 : 0));
721
723
  const pathPart =
722
724
  pathWidth > 0 && path ? ` ${formatDisplayPath(path, component.cwd, pathWidth)}` : "";
723
- const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintText ? theme.fg("dim", hintText) : ""}`;
725
+ // 圆点保持 dim;hover 只高亮可点击文字,与 compact 摘要行一致
726
+ const hintPart = clickable
727
+ ? `${theme.fg("dim", " · ")}${theme.fg(isToolCallHovered(component.toolCallId) ? "text" : "dim", clickText)}`
728
+ : "";
729
+ const line = `${iconPart}${namePart}${theme.fg("toolTitle", pathPart)}${statsStyled}${hintPart}`;
724
730
  return ["", truncateToWidth(line, width, "")];
725
731
  }
726
732
 
@@ -735,6 +741,7 @@ type CompactEditPaintHit = {
735
741
  theme: unknown;
736
742
  /** 展开卡的背景槽位同样参与命中判断。 */
737
743
  bgSlot: string;
744
+ diffConfigKey: string;
738
745
  expanded: boolean;
739
746
  result: unknown;
740
747
  isPartial: boolean;
@@ -766,6 +773,7 @@ function compactEditWriteLines(
766
773
  const hover = isToolCallHovered(component.toolCallId);
767
774
  const ioHover = compactEditIoHover(component);
768
775
  const theme = themeOf();
776
+ const diffConfigKey = displayConfigCacheKey(getToolDisplayConfig());
769
777
  if (!pending) {
770
778
  const hit = compactEditPaintCache.get(component);
771
779
  if (
@@ -773,6 +781,7 @@ function compactEditWriteLines(
773
781
  hit.width === width &&
774
782
  hit.theme === theme &&
775
783
  hit.bgSlot === config.expandedCardBackground &&
784
+ hit.diffConfigKey === diffConfigKey &&
776
785
  hit.expanded === expanded &&
777
786
  hit.result === component.result &&
778
787
  hit.isPartial === isPartial &&
@@ -789,6 +798,7 @@ function compactEditWriteLines(
789
798
  width,
790
799
  theme,
791
800
  bgSlot: config.expandedCardBackground,
801
+ diffConfigKey,
792
802
  expanded,
793
803
  result: component.result,
794
804
  isPartial,
@@ -1,6 +1,7 @@
1
- import { Text, type Component } from "@earendil-works/pi-tui";
1
+ import { Text, visibleWidth, type Component } from "@earendil-works/pi-tui";
2
2
  import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
3
3
  import { sanitizeToolResultText } from "../../../utils/tool-result-sanitize.ts";
4
+ import { showMoreHintText } from "../show-more-hint.ts";
4
5
  import {
5
6
  getLineNumberWidth,
6
7
  parseDiff,
@@ -20,6 +21,7 @@ import {
20
21
  import { renderDiffFrameLine, renderHeaderRows, renderSingleDiffRow } from "./diff-header.ts";
21
22
  import {
22
23
  applyLineLimit,
24
+ clampDiffLineToWidth,
23
25
  clampDiffLinesToWidth,
24
26
  resolveDiffDisplayLimit,
25
27
  resolveDiffProcessBudget,
@@ -81,6 +83,33 @@ export function countEditDiffStats(
81
83
  }
82
84
  }
83
85
 
86
+ /** Keep counts in the result row: the default-mode edit title does not include them. */
87
+ function renderEditCollapsedSummaryLine(
88
+ stats: ParsedDiff["stats"],
89
+ width: number,
90
+ theme: DiffTheme,
91
+ hovered: boolean,
92
+ ): string {
93
+ const counts = `+${stats.added} -${stats.removed}`;
94
+ const hint = showMoreHintText();
95
+ const candidates = [
96
+ `↳ diff ${counts} · ${hint}`,
97
+ `↳ ${counts} · ${hint}`,
98
+ `${counts} · ${hint}`,
99
+ `↳ diff ${counts}`,
100
+ counts,
101
+ "diff",
102
+ "…",
103
+ ];
104
+ const text = candidates.find((candidate) => visibleWidth(candidate) <= width) ?? "";
105
+ const hintIndex = text.lastIndexOf(hint);
106
+ const styled =
107
+ hovered && hintIndex >= 0
108
+ ? theme.fg("muted", text.slice(0, hintIndex)) + theme.fg("text", hint)
109
+ : theme.fg("muted", text);
110
+ return clampDiffLineToWidth(styled, width);
111
+ }
112
+
84
113
  export function renderEditDiffResult(
85
114
  details: unknown,
86
115
  options: DiffRenderOptions,
@@ -110,25 +139,33 @@ export function renderEditDiffResult(
110
139
  return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
111
140
  }
112
141
 
113
- const splitRows = buildSplitRows(renderEntries);
114
- const showHashlineAnchors =
115
- options.expanded === true &&
116
- renderEntries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
117
- const lineNumberWidth = getLineNumberWidth(renderEntries, showHashlineAnchors);
118
142
  const palette = resolveDiffPalette(theme);
119
143
  // Rich diffs use ccstyle's self shell. Keep the panel transparent so the
120
144
  // separator cannot leak toolSuccessBg across the entire new column.
121
145
  const containerBgAnsi = undefined;
122
146
  const language = resolveLanguageFromPath(options.filePath);
123
147
  const cache = createDiffRenderCache();
124
- const highlightLine = createCodeLineHighlighter(language, theme, renderEntries, () => {
125
- cache.invalidate();
126
- options.invalidate?.();
127
- });
148
+ // Summary-only previews need parsed counts, not split rows or syntax highlighting.
149
+ // Keep detailed data lazy so a live setting change can still reveal the same diff.
150
+ function buildDetailedData() {
151
+ const showHashlineAnchors =
152
+ options.expanded === true &&
153
+ renderEntries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
154
+ return {
155
+ splitRows: buildSplitRows(renderEntries),
156
+ showHashlineAnchors,
157
+ lineNumberWidth: getLineNumberWidth(renderEntries, showHashlineAnchors),
158
+ highlightLine: createCodeLineHighlighter(language, theme, renderEntries, () => {
159
+ cache.invalidate();
160
+ options.invalidate?.();
161
+ }),
162
+ };
163
+ }
164
+ let detailedData: ReturnType<typeof buildDetailedData> | undefined;
128
165
 
129
166
  return {
130
167
  [RICH_DIFF_COMPONENT]: true,
131
- /** 鼠标层只在真正的 remainder 行上提供展开入口。 */
168
+ /** Only the current summary/remainder row is an expand target, never diff body text. */
132
169
  isCollapsedHintLine(plainLine: string): boolean {
133
170
  const hint = cache.getHintLine();
134
171
  return hint !== undefined && normalizeCollapsedHintLine(plainLine) === hint;
@@ -147,6 +184,20 @@ export function renderEditDiffResult(
147
184
  return cached;
148
185
  }
149
186
 
187
+ // Like write's zero-line preview, handle this before the normal body limiter.
188
+ if (!options.expanded && live.editDiffCollapsedLines === 0) {
189
+ const summary = renderEditCollapsedSummaryLine(parsed.stats, safeWidth, theme, hovered);
190
+ return cache.set(
191
+ safeWidth,
192
+ options.expanded,
193
+ mode,
194
+ configKey,
195
+ hovered,
196
+ [summary],
197
+ normalizeCollapsedHintLine(summary),
198
+ );
199
+ }
200
+
150
201
  if (mode === "summary") {
151
202
  return cache.set(
152
203
  safeWidth,
@@ -166,6 +217,8 @@ export function renderEditDiffResult(
166
217
  );
167
218
  }
168
219
 
220
+ const { splitRows, showHashlineAnchors, lineNumberWidth, highlightLine } = (detailedData ??=
221
+ buildDetailedData());
169
222
  const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
170
223
  const displayLimit = resolveDiffDisplayLimit(options.expanded, live.editDiffCollapsedLines);
171
224
  const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
@@ -5,9 +5,9 @@ import { config } from "../../config/config.ts";
5
5
  import { oneLine } from "../../utils/format.ts";
6
6
  import { headTruncateToWidth } from "./result.ts";
7
7
 
8
- /** inputClip=0 时不设字符上限,交给渲染时的实际宽度截断。 */
8
+ /** toolLabelClip=0 时不设字符上限,交给渲染时的实际宽度截断。 */
9
9
  function clipLimit(): number {
10
- return config.inputClip > 0 ? config.inputClip : Number.POSITIVE_INFINITY;
10
+ return config.toolLabelClip > 0 ? config.toolLabelClip : Number.POSITIVE_INFINITY;
11
11
  }
12
12
 
13
13
  function clip(value: unknown): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "license": "MIT",
5
5
  "description": "Claude Code-style TUI output with some personal touches and handy utilities.",
6
6
  "type": "module",