pi-cc-extensions 0.8.71 → 0.9.0

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
@@ -43,11 +43,12 @@ Run `/reload` after installation.
43
43
  | Settings panel | `Style / Diff / Thinking / UI / Feature` tabs | `/ccstyle` |
44
44
  | Context inspection | Usage breakdown and previews for the system prompt, memory, skills, tools definition, and messages | `/context` |
45
45
  | Session/Subagent references | Search and inject effective context from previous Sessions or existing SubAgents | `@` |
46
+ | Status bar | Two-line chips: model, context, cache, cost, git; requires a Nerd Font | `/ccstyle` Feature |
46
47
  | Theme | Bundled CC Dark and CC Light themes | `/theme` |
47
48
 
48
49
  ## Configuration
49
50
 
50
- `/ccstyle` behavior is configured through `~/.pi/agent/claude-code-style.json`:
51
+ `/ccstyle` behavior is configured through `~/.pi/agent/pi-cc-extensions.json`:
51
52
 
52
53
  ```js
53
54
  {
@@ -82,7 +83,8 @@ Run `/reload` after installation.
82
83
  "enableContextCommand": true, // /context usage check
83
84
  "enableAgentSummary": true, // per-turn tool summary
84
85
  "enableWorkingMessage": true, // Working... bottom token/elapsed
85
- "enableAliases": true // /clear, /exit aliases
86
+ "enableAliases": true, // /clear, /exit aliases
87
+ "enableCustomFooter": true // two-line chips status bar (needs a Nerd Font)
86
88
  }
87
89
  ```
88
90
 
package/README.md CHANGED
@@ -43,11 +43,12 @@ pi install git:github.com/minuque/pi-cc-extensions
43
43
  | 配置面板 | `Style / Diff / Thinking / UI / Feature` 五页签 | `/ccstyle` |
44
44
  | 上下文检查 | 查看上下文占用,并预览 System prompt、Memory、Skills、Tools definition 和消息内容 | `/context` |
45
45
  | Session/Subagent 引用 | 搜索并注入历史 Session 或现有 SubAgent 的有效上下文 | `@` |
46
+ | 状态栏 | 两行 chips:模型、上下文、缓存、费用、git;需要 Nerd Font | `/ccstyle` Feature |
46
47
  | 主题 | 随包提供内置 CC Dark、CC Light 主题 | `/theme` |
47
48
 
48
49
  ## 配置
49
50
 
50
- `/ccstyle` 的行为由 `~/.pi/agent/claude-code-style.json` 配置:
51
+ `/ccstyle` 的行为由 `~/.pi/agent/pi-cc-extensions.json` 配置:
51
52
 
52
53
  ```js
53
54
  {
@@ -82,7 +83,8 @@ pi install git:github.com/minuque/pi-cc-extensions
82
83
  "enableContextCommand": true, // /context 上下文检查
83
84
  "enableAgentSummary": true, // 每回合工具摘要
84
85
  "enableWorkingMessage": true, // Working... 底部 token/耗时
85
- "enableAliases": true // /clear、/exit 别名
86
+ "enableAliases": true, // /clear、/exit 别名
87
+ "enableCustomFooter": true // 两行 chips 状态栏(需 Nerd Font)
86
88
  }
87
89
  ```
88
90
 
@@ -1,5 +1,5 @@
1
1
  import type { CompactThinkingConfig } from "../feature/compact-thinking.ts";
2
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
@@ -64,10 +64,12 @@ export type Config = {
64
64
  enableAgentSummary: boolean;
65
65
  enableWorkingMessage: boolean;
66
66
  enableAliases: boolean;
67
+ enableCustomFooter: boolean;
67
68
  };
68
69
 
69
70
  const AGENT_DIR = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
70
- const CONFIG_PATH = join(AGENT_DIR, "claude-code-style.json");
71
+ export const CONFIG_PATH = join(AGENT_DIR, "pi-cc-extensions.json");
72
+ const LEGACY_CONFIG_PATH = join(AGENT_DIR, "claude-code-style.json");
71
73
 
72
74
  export const DIFF_VIEW_MODES: DiffViewMode[] = ["auto", "split", "unified"];
73
75
  export const DIFF_INDICATOR_MODES: DiffIndicatorMode[] = ["bars", "classic", "none"];
@@ -125,6 +127,7 @@ export const DEFAULT_CONFIG: Config = {
125
127
  enableAgentSummary: true,
126
128
  enableWorkingMessage: true,
127
129
  enableAliases: true,
130
+ enableCustomFooter: true,
128
131
  };
129
132
 
130
133
  function pickEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
@@ -223,6 +226,7 @@ export function normalizeConfig(input: unknown): Config {
223
226
  enableAgentSummary: source.enableAgentSummary !== false,
224
227
  enableWorkingMessage: source.enableWorkingMessage !== false,
225
228
  enableAliases: source.enableAliases !== false,
229
+ enableCustomFooter: source.enableCustomFooter !== false,
226
230
  };
227
231
  }
228
232
 
@@ -276,6 +280,7 @@ export function formatConfigStatus(source: Config = config): string {
276
280
  `agentSummary=${source.enableAgentSummary ? "on" : "off"}`,
277
281
  `workingMsg=${source.enableWorkingMessage ? "on" : "off"}`,
278
282
  `aliases=${source.enableAliases ? "on" : "off"}`,
283
+ `footer=${source.enableCustomFooter ? "on" : "off"}`,
279
284
  ].join(" · ");
280
285
  }
281
286
 
@@ -284,10 +289,21 @@ export const config: Config = loadConfig();
284
289
 
285
290
  function loadConfig(): Config {
286
291
  try {
287
- const source = existsSync(CONFIG_PATH)
288
- ? (JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Record<string, unknown>)
292
+ const fromLegacy = !existsSync(CONFIG_PATH) && existsSync(LEGACY_CONFIG_PATH);
293
+ const rawPath = existsSync(CONFIG_PATH) ? CONFIG_PATH : fromLegacy ? LEGACY_CONFIG_PATH : null;
294
+ const source = rawPath
295
+ ? (JSON.parse(readFileSync(rawPath, "utf8")) as Record<string, unknown>)
289
296
  : {};
290
- return normalizeConfig(source);
297
+ const next = normalizeConfig(source);
298
+ if (fromLegacy) {
299
+ try {
300
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2));
301
+ rmSync(LEGACY_CONFIG_PATH, { force: true });
302
+ } catch {
303
+ // 新路径写失败时仍使用已读到的旧配置,旧文件保留。
304
+ }
305
+ }
306
+ return next;
291
307
  } catch {
292
308
  // Ignore bad config and fall back to defaults.
293
309
  }
@@ -7,6 +7,7 @@
7
7
  import { getSettingsListTheme } 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
+ import { applyCustomFooter } from "../feature/shell/footer.ts";
10
11
  import { applyStartupHeader } from "../feature/shell/startup-header.ts";
11
12
  import type { ToolGroupingHooks } from "../renderer/tool/grouping.ts";
12
13
  import {
@@ -455,6 +456,13 @@ export async function showCcstylePanel(
455
456
  "Aliases disabled.",
456
457
  config.enableAliases,
457
458
  );
459
+ const customFooterToggle = featureToggleSetting(
460
+ "enableCustomFooter",
461
+ "Status bar",
462
+ "Two-line chips status bar with model, context, git, and usage. Applies immediately. Needs a Nerd Font.",
463
+ "Pi native footer restored.",
464
+ config.enableCustomFooter,
465
+ );
458
466
  const featureToggles: Record<string, { apply: (on: boolean) => void }> = {
459
467
  enableSessionReference: sessionReferenceToggle,
460
468
  enableSubagentAutocomplete: subagentAutocompleteToggle,
@@ -465,6 +473,13 @@ export async function showCcstylePanel(
465
473
  };
466
474
 
467
475
  const onSettingChange = (id: string, value: string) => {
476
+ if (id === "enableCustomFooter") {
477
+ updateConfig({ enableCustomFooter: value === "on" });
478
+ customFooterToggle.apply(value === "on");
479
+ applyCustomFooter(ctx);
480
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
481
+ return;
482
+ }
468
483
  // 额外功能开关:字段名与配置布尔字段一一对应,切换后重启生效。
469
484
  const featureToggle = featureToggles[id];
470
485
  if (featureToggle) {
@@ -658,6 +673,7 @@ export async function showCcstylePanel(
658
673
  agentSummaryToggle.setting,
659
674
  workingMessageToggle.setting,
660
675
  aliasesToggle.setting,
676
+ customFooterToggle.setting,
661
677
  ],
662
678
  },
663
679
  ];
@@ -0,0 +1,429 @@
1
+ /**
2
+ * 自定义底栏:chips + zentui 图标/句子
3
+ *
4
+ * 第一行:model thinking · ██████░░░░ 45%/200k · 󰆼 42% · $0.01 · 用量
5
+ * 第二行:cwd in session on  branch (+16 −1) · 其他扩展状态
6
+ *
7
+ * - line1 短芯片,· 分隔;缓存用 zentui 󰆼,费用 success
8
+ * - line2 用 zentui 句式 in / on + 
9
+ * - 当前模型用量:优先 pi-usage 的 usage 状态;xAI 等不写 statusline 的供应商由本扩展补拉
10
+ *
11
+ * 模型/计费/思考级别变化时自动更新(pi.on 全局事件 + render 实时计算)
12
+ */
13
+
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { truncateToWidth } from "@earendil-works/pi-tui";
16
+ import { execFile } from "node:child_process";
17
+ import { existsSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+ import { config } from "../../config/config.ts";
22
+ import { stripAnsi } from "../../utils/ansi-text.ts";
23
+
24
+ const GIT_REFRESH_INTERVAL_MS = 10_000;
25
+ const USAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
26
+ const USAGE_TIMEOUT_MS = 15_000;
27
+ // zentui NERD_DEFAULT_ICONS:git / cacheHit
28
+ const ICON_GIT = "";
29
+ const ICON_CACHE = "󰆼";
30
+
31
+ type GitStats = { add: number; del: number };
32
+
33
+ type XaiFooterReport = {
34
+ buckets?: Array<{
35
+ id?: string;
36
+ unit?: string;
37
+ used?: number;
38
+ limit?: number;
39
+ remaining?: number;
40
+ }>;
41
+ metrics?: Array<{ id?: string; value?: unknown }>;
42
+ };
43
+
44
+ export function parseGitStats(stdout: string): GitStats {
45
+ let add = 0;
46
+ let del = 0;
47
+ for (const line of stdout.split("\n")) {
48
+ const match = line.match(/^(\d+)\s+(\d+)/);
49
+ if (match) {
50
+ add += Number(match[1]);
51
+ del += Number(match[2]);
52
+ }
53
+ }
54
+ return { add, del };
55
+ }
56
+
57
+ export function classifyStatus(key: string): "usage" | "skip" | "other" {
58
+ if (key === "model") return "skip"; // line1 已有供应商/模型
59
+ if (/usage|quota|balance/i.test(key)) return "usage";
60
+ return "other";
61
+ }
62
+
63
+ export function formatXaiFooterChip(report: XaiFooterReport): string | undefined {
64
+ const included = report.buckets?.find((b) => b.id === "included-allowance");
65
+ if (included?.unit === "percent" && typeof included.used === "number") {
66
+ return `xAI ${Math.round(included.used)}%`;
67
+ }
68
+ if (included && included.unit !== "percent") {
69
+ if (
70
+ typeof included.used === "number" &&
71
+ typeof included.limit === "number" &&
72
+ included.limit > 0
73
+ ) {
74
+ return `xAI ${Math.round((included.used / included.limit) * 100)}%`;
75
+ }
76
+ if (typeof included.remaining === "number") {
77
+ return `xAI $${included.remaining.toFixed(2)}`;
78
+ }
79
+ if (typeof included.used === "number") {
80
+ return `xAI $${included.used.toFixed(2)}`;
81
+ }
82
+ }
83
+ const prepaid = report.metrics?.find((m) => m.id === "prepaid-balance");
84
+ if (typeof prepaid?.value === "number") {
85
+ return `xAI $${prepaid.value.toFixed(2)}`;
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ export function pickFooterUsageText(
91
+ pluginUsage: string | undefined,
92
+ localChip: string | undefined,
93
+ ): string | undefined {
94
+ const plugin = pluginUsage?.trim();
95
+ if (plugin && !/^checking$/i.test(plugin)) return plugin;
96
+ const local = localChip?.trim();
97
+ return local || undefined;
98
+ }
99
+
100
+ let piUsageMod: any | null | undefined;
101
+
102
+ function piUsageImportSpecs(): string[] {
103
+ const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
104
+ const dist = join(agentDir, "npm", "node_modules", "@narumitw", "pi-usage", "dist", "index.ts");
105
+ const specs: string[] = [];
106
+ if (existsSync(dist)) specs.push(pathToFileURL(dist).href);
107
+ specs.push("@narumitw/pi-usage");
108
+ return specs;
109
+ }
110
+
111
+ async function getPiUsage(): Promise<any | null> {
112
+ if (piUsageMod !== undefined) return piUsageMod;
113
+ for (const spec of piUsageImportSpecs()) {
114
+ try {
115
+ piUsageMod = await import(spec);
116
+ return piUsageMod;
117
+ } catch {
118
+ // 下一候选:本机 Pi npm 目录,或包名(与本包同 node_modules 时)
119
+ }
120
+ }
121
+ piUsageMod = null;
122
+ return piUsageMod;
123
+ }
124
+
125
+ function colorUsageChip(theme: any, text: string): string {
126
+ return theme.fg("dim", stripAnsi(text));
127
+ }
128
+
129
+ let currentTui: any = undefined;
130
+ let refreshCurrentGitStats: (() => void) | undefined;
131
+ let refreshCurrentUsage: (() => void) | undefined;
132
+
133
+ const createCustomFooterFactory =
134
+ (ctx: ExtensionContext) => (tui: any, theme: any, footerData: any) => {
135
+ currentTui = tui;
136
+ const sep = theme.fg("muted", " · ");
137
+ const joinChips = (parts: string[]) => parts.filter(Boolean).join(sep);
138
+ let gitStats: GitStats | undefined;
139
+ let gitRefreshRunning = false;
140
+ let localUsageChip = "";
141
+ let usageGeneration = 0;
142
+ let usageAbort: AbortController | undefined;
143
+ let disposed = false;
144
+
145
+ // 查询在 render 外执行,保留旧值;仅统计结果真正变化时重绘,避免周期性清零抖动。
146
+ const refreshGitStats = () => {
147
+ if (gitRefreshRunning || disposed) return;
148
+ gitRefreshRunning = true;
149
+ execFile(
150
+ "git",
151
+ ["diff", "--numstat", "HEAD"],
152
+ { cwd: ctx.cwd, timeout: 2000 },
153
+ (err, stdout) => {
154
+ gitRefreshRunning = false;
155
+ if (err || disposed) return;
156
+ const next = parseGitStats(stdout);
157
+ if (gitStats?.add === next.add && gitStats.del === next.del) return;
158
+ gitStats = next;
159
+ tui.requestRender();
160
+ },
161
+ );
162
+ };
163
+ refreshCurrentGitStats = refreshGitStats;
164
+ refreshGitStats();
165
+ const gitRefreshTimer = setInterval(refreshGitStats, GIT_REFRESH_INTERVAL_MS);
166
+ gitRefreshTimer.unref?.();
167
+
168
+ const refreshUsage = () => {
169
+ if (disposed) return;
170
+ const generation = ++usageGeneration;
171
+ usageAbort?.abort();
172
+ const controller = new AbortController();
173
+ usageAbort = controller;
174
+ const stale = () => disposed || generation !== usageGeneration || controller.signal.aborted;
175
+ void (async () => {
176
+ try {
177
+ const model = ctx.model;
178
+ const api = await getPiUsage();
179
+ if (stale()) return;
180
+ if (!api || !model) {
181
+ if (localUsageChip) {
182
+ localUsageChip = "";
183
+ tui.requestRender();
184
+ }
185
+ return;
186
+ }
187
+ const adapter = api.adapterForProvider?.(model.provider);
188
+ if (!adapter || adapter.publishesStatusline !== false) {
189
+ if (localUsageChip) {
190
+ localUsageChip = "";
191
+ tui.requestRender();
192
+ }
193
+ return;
194
+ }
195
+ const auth = await api.resolveUsageAuth(ctx, adapter);
196
+ if (stale()) return;
197
+ if (!auth) {
198
+ const next = `${adapter.displayName} ✗`;
199
+ if (localUsageChip !== next) {
200
+ localUsageChip = next;
201
+ tui.requestRender();
202
+ }
203
+ return;
204
+ }
205
+ const guard = async () => {
206
+ if (stale()) throw new Error("aborted");
207
+ const again = await api.resolveUsageAuth(ctx, adapter);
208
+ if (again?.fingerprint !== auth.fingerprint) {
209
+ throw new Error("aborted");
210
+ }
211
+ };
212
+ const report = await api.queryProviderUsage(
213
+ adapter,
214
+ auth,
215
+ controller.signal,
216
+ USAGE_TIMEOUT_MS,
217
+ guard,
218
+ );
219
+ if (stale()) return;
220
+ const next =
221
+ (adapter.id === "xai" ? formatXaiFooterChip(report) : undefined) ||
222
+ api.formatUsageStatusline?.(report, model) ||
223
+ `${adapter.displayName} ✗`;
224
+ if (localUsageChip !== next) {
225
+ localUsageChip = next;
226
+ tui.requestRender();
227
+ }
228
+ } catch {
229
+ if (stale()) return;
230
+ const name = ctx.model?.provider === "xai" ? "xAI" : "usage";
231
+ const next = `${name} ✗`;
232
+ if (localUsageChip !== next) {
233
+ localUsageChip = next;
234
+ tui.requestRender();
235
+ }
236
+ } finally {
237
+ if (usageAbort === controller) usageAbort = undefined;
238
+ }
239
+ })();
240
+ };
241
+ refreshCurrentUsage = refreshUsage;
242
+ refreshUsage();
243
+ const usageRefreshTimer = setInterval(refreshUsage, USAGE_REFRESH_INTERVAL_MS);
244
+ usageRefreshTimer.unref?.();
245
+
246
+ // 分支变化时同步更新分支名和相对 HEAD 的统计。
247
+ const unsubBranch = footerData.onBranchChange(() => {
248
+ refreshGitStats();
249
+ tui.requestRender();
250
+ });
251
+
252
+ const fmt = (n: number): string => {
253
+ if (n < 1000) return `${n}`;
254
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
255
+ if (n < 1000000) return `${Math.round(n / 1000)}k`;
256
+ if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
257
+ return `${Math.round(n / 1000000)}M`;
258
+ };
259
+
260
+ // ---- pi-cc-status 风格:上下文条形图 ----
261
+ const BAR_WIDTH = 10;
262
+ const BAR_WARNING = 80; // >= 80% 转 warning 色
263
+ const BAR_ERROR = 95; // >= 95% 转 error 色
264
+ const barGauge = (pct: number): string => {
265
+ const clamped = Math.max(0, Math.min(100, pct));
266
+ const filled = Math.round((clamped / 100) * BAR_WIDTH);
267
+ const color = clamped >= BAR_ERROR ? "error" : clamped >= BAR_WARNING ? "warning" : "accent";
268
+ return theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(BAR_WIDTH - filled));
269
+ };
270
+
271
+ // pi-cc-status showCachePercent:最近一条助手消息的 cacheRead 占比
272
+ const getCachePct = (): number => {
273
+ const branch = ctx.sessionManager.getBranch();
274
+ for (let i = branch.length - 1; i >= 0; i--) {
275
+ const e = branch[i];
276
+ if (e.type !== "message" || e.message.role !== "assistant") continue;
277
+ const u = e.message.usage;
278
+ if (!u) return 0;
279
+ const total = u.input + u.cacheWrite + u.cacheRead;
280
+ return total > 0 ? (u.cacheRead * 100) / total : 0;
281
+ }
282
+ return 0;
283
+ };
284
+
285
+ const render = (width: number): string[] => {
286
+ const model = ctx.model;
287
+ const contextUsage = ctx.getContextUsage();
288
+ const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
289
+ const percent = contextUsage?.percent;
290
+
291
+ let cost = 0;
292
+ for (const entry of ctx.sessionManager.getEntries()) {
293
+ if (entry.type === "message") {
294
+ const m = entry.message;
295
+ if (m.role === "assistant") cost += m.usage?.cost?.total || 0;
296
+ else if (m.role === "toolResult" && m.usage) cost += m.usage.cost?.total || 0;
297
+ } else if (
298
+ (entry.type === "branch_summary" || entry.type === "compaction") &&
299
+ entry.usage
300
+ ) {
301
+ cost += entry.usage.cost?.total || 0;
302
+ }
303
+ }
304
+
305
+ // cwd 默认只显示最底层路径(根目录时回退完整路径)
306
+ const cwdLabel = ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || ctx.cwd;
307
+
308
+ const usingSubscription =
309
+ !!model &&
310
+ ["kimi-coding", "xai", "github-copilot", "openai"].includes(model.provider || "");
311
+ const modelLabel = model
312
+ ? model.id.includes("/")
313
+ ? model.id
314
+ : `${model.provider}/${model.id}`
315
+ : "no-model";
316
+
317
+ const thinkingLevelStr = ctx.thinkingLevel || "off";
318
+ const thinkingColor = theme.getThinkingBorderColor(thinkingLevelStr);
319
+
320
+ const otherStatuses: string[] = [];
321
+ const usageStatuses: string[] = [];
322
+ for (const [key, raw] of Array.from(
323
+ footerData.getExtensionStatuses().entries() as Iterable<[string, string]>,
324
+ ).sort(([a], [b]) => a.localeCompare(b))) {
325
+ const t = raw.replace(/[\r\n\t]+/g, " ").trim();
326
+ if (!t) continue;
327
+ const kind = classifyStatus(key);
328
+ if (kind === "skip") continue;
329
+ if (kind === "usage") {
330
+ usageStatuses.push(stripAnsi(t));
331
+ } else {
332
+ otherStatuses.push(theme.fg("dim", stripAnsi(t)));
333
+ }
334
+ }
335
+
336
+ const usageText = pickFooterUsageText(usageStatuses[0], localUsageChip);
337
+
338
+ const pctLabel = percent === null || percent === undefined ? "?" : `${Math.floor(percent)}%`;
339
+ const cachePct = getCachePct();
340
+ const costChip =
341
+ cost || usingSubscription
342
+ ? theme.fg("dim", `$${cost.toFixed(2)}`) +
343
+ (usingSubscription ? theme.fg("warning", " sub") : "")
344
+ : "";
345
+ const line1 = joinChips([
346
+ theme.fg("accent", modelLabel),
347
+ model?.reasoning ? thinkingColor(thinkingLevelStr) : "",
348
+ barGauge(percent ?? 0) + theme.fg("dim", ` ${pctLabel}/${fmt(contextWindow)}`),
349
+ cachePct > 0 ? theme.fg("dim", `${ICON_CACHE} ${Math.floor(cachePct)}%`) : "",
350
+ costChip,
351
+ usageText ? colorUsageChip(theme, usageText) : "",
352
+ ]);
353
+
354
+ const gitColor = theme.getThinkingBorderColor("medium");
355
+ let place = theme.fg("accent", cwdLabel);
356
+ const sessionName = ctx.sessionManager.getSessionName();
357
+ if (sessionName) {
358
+ place += theme.fg("muted", " in ") + theme.fg("success", sessionName);
359
+ }
360
+ const branch = footerData.getGitBranch();
361
+ if (branch) {
362
+ const stats =
363
+ gitStats && (gitStats.add || gitStats.del)
364
+ ? theme.fg("dim", " (") +
365
+ theme.fg("success", `+${gitStats.add}`) +
366
+ " " +
367
+ theme.fg("error", `−${gitStats.del}`) +
368
+ theme.fg("dim", ")")
369
+ : "";
370
+ place += theme.fg("muted", " on ") + gitColor(`${ICON_GIT} ${branch}`) + stats;
371
+ }
372
+ const line2 = joinChips([place, ...otherStatuses]);
373
+
374
+ return [truncateToWidth(line1, width), ...(line2 ? [truncateToWidth(line2, width)] : [])];
375
+ };
376
+
377
+ return {
378
+ render,
379
+ invalidate(): void {
380
+ tui.requestRender();
381
+ },
382
+ dispose(): void {
383
+ disposed = true;
384
+ usageAbort?.abort();
385
+ usageAbort = undefined;
386
+ clearInterval(gitRefreshTimer);
387
+ clearInterval(usageRefreshTimer);
388
+ unsubBranch();
389
+ if (currentTui === tui) {
390
+ currentTui = undefined;
391
+ refreshCurrentGitStats = undefined;
392
+ refreshCurrentUsage = undefined;
393
+ }
394
+ },
395
+ };
396
+ };
397
+
398
+ /**
399
+ * 按配置应用底栏:on → 自定义 footer;off → 恢复官方默认 footer。
400
+ * 导出供 /ccstyle 面板在切换开关时实时重应用。
401
+ */
402
+ export function applyCustomFooter(ctx: ExtensionContext): void {
403
+ if (!ctx?.hasUI || typeof ctx.ui?.setFooter !== "function") return;
404
+ if (!config.enableCustomFooter) {
405
+ ctx.ui.setFooter(undefined);
406
+ return;
407
+ }
408
+ try {
409
+ ctx.ui.setFooter(createCustomFooterFactory(ctx));
410
+ } catch (err) {
411
+ ctx.ui.notify(`footer error: ${err instanceof Error ? err.message : String(err)}`, "error");
412
+ }
413
+ }
414
+
415
+ export default function (pi: ExtensionAPI) {
416
+ // 模型/思考级别变化时强制重渲染(自定义 footer 不会被内置 invalidate() 触达)
417
+ pi.on("model_select", () => {
418
+ currentTui?.requestRender();
419
+ refreshCurrentUsage?.();
420
+ });
421
+ pi.on("thinking_level_select", () => currentTui?.requestRender());
422
+ // 工具执行完成后立即刷新;定时器只负责兜底捕获外部文件变化。
423
+ pi.on("tool_execution_end", () => refreshCurrentGitStats?.());
424
+
425
+ // 启动 / /reload / 新建会话 时按配置恢复
426
+ pi.on("session_start", (_event, ctx) => {
427
+ applyCustomFooter(ctx);
428
+ });
429
+ }
@@ -4,6 +4,7 @@ import { config } from "./config/config.ts";
4
4
  // shell
5
5
  import piAliases from "./feature/shell/aliases.ts";
6
6
  import { installFlushDockedBash } from "./feature/shell/flush-docked-bash.ts";
7
+ import customFooter from "./feature/shell/footer.ts";
7
8
  import piStartupHeader from "./feature/shell/startup-header.ts";
8
9
  import workingMessage from "./feature/shell/working-message.ts";
9
10
 
@@ -24,6 +25,7 @@ export default function (pi: ExtensionAPI): void {
24
25
  installFlushDockedBash();
25
26
  piStartupHeader(pi);
26
27
  if (config.enableWorkingMessage) workingMessage(pi);
28
+ customFooter(pi);
27
29
 
28
30
  // render stack:thinking controller 直接交给 style 作 query
29
31
  markdownEnhance(pi);
@@ -327,6 +327,7 @@ function layoutExpandedToolCard(
327
327
  theme: any,
328
328
  children: any[],
329
329
  width: number,
330
+ paints?: string[][],
330
331
  ): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
331
332
  const slot = "userMessageBg";
332
333
  const toolBgAnsi = darkenBgAnsi(theme, slot);
@@ -337,8 +338,10 @@ function layoutExpandedToolCard(
337
338
  lines.push(paddedBackgroundRow(theme, slot, "", width));
338
339
  // 展开不再显示摘要行:跳过第一个 child 的首行空白,避免白占一行
339
340
  let skipLeadingBlank = true;
340
- for (const child of children) {
341
- const childLines = child.render(innerWidth);
341
+ for (let childIndex = 0; childIndex < children.length; childIndex++) {
342
+ const child = children[childIndex];
343
+ const painted = paints?.[childIndex];
344
+ const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
342
345
  // 展开的 thinking 与 tool 一样走嵌套深色卡,避免贴在外卡同色底上看不见。
343
346
  const nest = child.__ccToolCard || (isThinkingPreview(child) && child.expanded === true);
344
347
  if (!nest) {
@@ -401,21 +404,56 @@ function compactRoundCard(
401
404
  });
402
405
  }
403
406
  }
407
+ let paint:
408
+ | {
409
+ width: number;
410
+ theme: unknown;
411
+ paints: unknown[];
412
+ lines: string[];
413
+ hits: Array<{ child: any; start: number; end: number }>;
414
+ }
415
+ | undefined;
416
+ const layout = (width: number) => {
417
+ const theme = themeOf();
418
+ const innerWidth = Math.max(0, width - 2);
419
+ const paints = children.map((child) => {
420
+ const lines = child.render?.(innerWidth);
421
+ return Array.isArray(lines) ? lines : [];
422
+ });
423
+ if (
424
+ paint &&
425
+ paint.width === width &&
426
+ paint.theme === theme &&
427
+ paint.paints.length === paints.length &&
428
+ paint.paints.every((item, index) => item === paints[index])
429
+ ) {
430
+ return paint;
431
+ }
432
+ const laid = layoutExpandedToolCard(theme, children, width, paints);
433
+ paint = {
434
+ width,
435
+ theme,
436
+ paints,
437
+ lines: ["", ...laid.lines],
438
+ hits: laid.hits,
439
+ };
440
+ return paint;
441
+ };
404
442
  return {
405
443
  children,
406
444
  render(width: number): string[] {
407
- return ["", ...layoutExpandedToolCard(themeOf(), children, width).lines];
445
+ return layout(width).lines;
408
446
  },
409
447
  childAtRow(localRow: number, width: number) {
410
448
  if (localRow < 1) return null;
411
449
  const row = localRow - 1;
412
- const { hits } = layoutExpandedToolCard(themeOf(), children, width);
413
- for (const hit of hits) {
450
+ for (const hit of layout(width).hits) {
414
451
  if (row >= hit.start && row < hit.end) return hit.child;
415
452
  }
416
453
  return null;
417
454
  },
418
455
  invalidate() {
456
+ paint = undefined;
419
457
  for (const child of children) child.invalidate?.();
420
458
  },
421
459
  };
@@ -553,14 +591,77 @@ const compactRichDiffCache = new WeakMap<
553
591
  { result: unknown; collapsed?: unknown; expanded?: unknown }
554
592
  >();
555
593
 
594
+ type CompactEditPaintHit = {
595
+ width: number;
596
+ theme: unknown;
597
+ expanded: boolean;
598
+ result: unknown;
599
+ isPartial: boolean;
600
+ args: unknown;
601
+ hover: boolean;
602
+ ioHover: unknown;
603
+ lines: string[];
604
+ };
605
+ const compactEditPaintCache = new WeakMap<object, CompactEditPaintHit>();
606
+
607
+ function compactEditIoHover(component: any): unknown {
608
+ const view = component?.resultRendererComponent;
609
+ return typeof view?.getHoveredSection === "function" ? view.getHoveredSection() : null;
610
+ }
611
+
556
612
  /**
557
613
  * compact edit/write:标题行 + mode=on 同一套 rich diff。
558
614
  * 折叠/展开都走 `renderRichToolResult`,limits 不另开一套。
615
+ * 已完成的结果跨帧复用行;pending 不缓存,避免 loader 动画冻住。
559
616
  */
560
617
  function compactEditWriteLines(
561
618
  component: any,
562
619
  width: number,
563
620
  writeMetadata?: WriteExecutionMetadataStore,
621
+ ): string[] {
622
+ const expanded = component.expanded === true;
623
+ const isPartial = component.isPartial === true;
624
+ const pending = !component.result || isPartial;
625
+ const hover = isToolCallHovered(component.toolCallId);
626
+ const ioHover = compactEditIoHover(component);
627
+ const theme = themeOf();
628
+ if (!pending) {
629
+ const hit = compactEditPaintCache.get(component);
630
+ if (
631
+ hit &&
632
+ hit.width === width &&
633
+ hit.theme === theme &&
634
+ hit.expanded === expanded &&
635
+ hit.result === component.result &&
636
+ hit.isPartial === isPartial &&
637
+ hit.args === component.args &&
638
+ hit.hover === hover &&
639
+ hit.ioHover === ioHover
640
+ ) {
641
+ return hit.lines;
642
+ }
643
+ }
644
+ const lines = paintCompactEditWrite(component, width, writeMetadata);
645
+ if (!pending) {
646
+ compactEditPaintCache.set(component, {
647
+ width,
648
+ theme,
649
+ expanded,
650
+ result: component.result,
651
+ isPartial,
652
+ args: component.args,
653
+ hover,
654
+ ioHover: compactEditIoHover(component),
655
+ lines,
656
+ });
657
+ }
658
+ return lines;
659
+ }
660
+
661
+ function paintCompactEditWrite(
662
+ component: any,
663
+ width: number,
664
+ writeMetadata?: WriteExecutionMetadataStore,
564
665
  ): string[] {
565
666
  const theme = themeOf();
566
667
  const result = component.result;
@@ -656,15 +757,45 @@ function compactAssistantLineComponent(
656
757
  options: { hint?: boolean; leadingBlank?: boolean; pad?: number } = {},
657
758
  ): any {
658
759
  const self = component as any;
760
+ let paint:
761
+ | {
762
+ width: number;
763
+ theme: unknown;
764
+ resolved: string;
765
+ hover: boolean;
766
+ hint: boolean;
767
+ pad: number;
768
+ frame: number;
769
+ leadingBlank: boolean;
770
+ lines: string[];
771
+ }
772
+ | undefined;
659
773
  return {
660
774
  render(width: number): string[] {
661
775
  const theme = themeOf();
662
776
  const pad = Math.max(0, options.pad ?? (Number(self.outputPad) || 0));
663
777
  const available = Math.max(0, width - pad);
664
- const hintText = options.hint === false ? "" : ` • ${showMoreHintText()}`;
778
+ const hint = options.hint !== false;
779
+ const hintText = hint ? ` • ${showMoreHintText()}` : "";
665
780
  const summaryWidth = Math.max(0, available - visibleWidth(hintText));
666
781
  const resolved = typeof summary === "function" ? summary() : summary;
667
782
  const runningActive = resolved.startsWith("Running...");
783
+ const hover = hoveredAssistantComponent === component;
784
+ const frame = runningActive ? (query?.getThinkingAnimationFrame?.() ?? 0) : 0;
785
+ const leadingBlank = options.leadingBlank !== false;
786
+ if (
787
+ paint &&
788
+ paint.width === width &&
789
+ paint.theme === theme &&
790
+ paint.resolved === resolved &&
791
+ paint.hover === hover &&
792
+ paint.hint === hint &&
793
+ paint.pad === pad &&
794
+ paint.frame === frame &&
795
+ paint.leadingBlank === leadingBlank
796
+ ) {
797
+ return paint.lines;
798
+ }
668
799
  const plainText = truncateToWidth(resolved, summaryWidth, "…");
669
800
  let text = theme.fg("muted", plainText);
670
801
  if (runningActive || plainText.startsWith("Ran for ")) {
@@ -684,12 +815,26 @@ function compactAssistantLineComponent(
684
815
  text = `${styleCompactThinkingText(heading, theme)}${theme.fg("muted", tools)}`;
685
816
  }
686
817
  }
687
- const hintColor = hoveredAssistantComponent === component ? "text" : "dim";
818
+ const hintColor = hover ? "text" : "dim";
688
819
  const line = `${text}${hintText ? theme.fg(hintColor, hintText) : ""}`;
689
820
  const rendered = `${" ".repeat(pad)}${truncateToWidth(line, available, "")}`;
690
- return options.leadingBlank === false ? [rendered] : ["", rendered];
821
+ const lines = leadingBlank ? ["", rendered] : [rendered];
822
+ paint = {
823
+ width,
824
+ theme,
825
+ resolved,
826
+ hover,
827
+ hint,
828
+ pad,
829
+ frame,
830
+ leadingBlank,
831
+ lines,
832
+ };
833
+ return lines;
834
+ },
835
+ invalidate() {
836
+ paint = undefined;
691
837
  },
692
- invalidate() {},
693
838
  };
694
839
  }
695
840
 
@@ -58,6 +58,17 @@ type ToolRenderMethods = {
58
58
  getResultRenderer: (...args: any[]) => any;
59
59
  };
60
60
 
61
+ type ToolPaintHit = {
62
+ width: number;
63
+ expanded: boolean;
64
+ isPartial: boolean;
65
+ result: unknown;
66
+ args: unknown;
67
+ callHover: boolean;
68
+ ioHover: string | null;
69
+ lines: string[];
70
+ };
71
+
61
72
  type GlobalToolRenderPatch = {
62
73
  version: 2;
63
74
  prototype: any;
@@ -68,6 +79,11 @@ type GlobalToolRenderPatch = {
68
79
  byName: Map<string, any>;
69
80
  downstream: ToolRenderMethods;
70
81
  installed: ToolRenderMethods;
82
+ originalRender?: (width: number) => string[];
83
+ originalInvalidate?: () => void;
84
+ renderPaint?: (width: number) => string[];
85
+ invalidatePaint?: () => void;
86
+ paintCache?: WeakMap<object, ToolPaintHit>;
71
87
  };
72
88
 
73
89
  type ToolExpandedBackgroundPatch = {
@@ -478,12 +494,38 @@ function disconnectGlobalToolRenderPatch(patch: GlobalToolRenderPatch | undefine
478
494
  patch.byName.clear();
479
495
  }
480
496
 
497
+ function ioHoverOf(tool: any): string | null {
498
+ const view = tool?.resultRendererComponent;
499
+ if (!view || typeof view.getHoveredSection !== "function") return null;
500
+ return view.getHoveredSection();
501
+ }
502
+
503
+ function toolPaintMatches(hit: ToolPaintHit, tool: any, width: number): boolean {
504
+ return (
505
+ hit.width === width &&
506
+ hit.expanded === Boolean(tool.expanded) &&
507
+ hit.isPartial === Boolean(tool.isPartial) &&
508
+ hit.result === tool.result &&
509
+ hit.args === tool.args &&
510
+ hit.callHover === isToolCallHovered(tool.toolCallId) &&
511
+ hit.ioHover === ioHoverOf(tool)
512
+ );
513
+ }
514
+
481
515
  function installGlobalToolRendering(
482
516
  writeExecutionMetadata: WriteExecutionMetadataStore,
483
517
  ): GlobalToolRenderPatch {
484
518
  const prototype = (ToolExecutionComponent as any).prototype;
485
519
  const previous = patchRegistry.get<GlobalToolRenderPatch>(GLOBAL_TOOL_RENDER_PATCH);
486
520
  const downstream = downstreamForGlobalToolInstall(prototype, previous);
521
+ const originalRender =
522
+ previous?.originalRender && prototype.render === previous.renderPaint
523
+ ? previous.originalRender
524
+ : prototype.render;
525
+ const originalInvalidate =
526
+ previous?.originalInvalidate && prototype.invalidate === previous.invalidatePaint
527
+ ? previous.originalInvalidate
528
+ : prototype.invalidate;
487
529
  if (isOwnershipAwarePatch(previous)) disconnectGlobalToolRenderPatch(previous);
488
530
 
489
531
  const patch: GlobalToolRenderPatch = {
@@ -496,6 +538,9 @@ function installGlobalToolRendering(
496
538
  byName: new Map(),
497
539
  downstream,
498
540
  installed: undefined as any,
541
+ originalRender,
542
+ originalInvalidate,
543
+ paintCache: new WeakMap(),
499
544
  };
500
545
 
501
546
  patch.installed = {
@@ -532,6 +577,33 @@ function installGlobalToolRendering(
532
577
  prototype.getRenderShell = patch.installed.getRenderShell;
533
578
  prototype.getCallRenderer = patch.installed.getCallRenderer;
534
579
  prototype.getResultRenderer = patch.installed.getResultRenderer;
580
+ // fullscreen 滚动/选区每帧整树 render;内容未变时复用上一帧行,避免重做 diff/Box。
581
+ patch.renderPaint = function (this: any, width: number): string[] {
582
+ if (!patch.active || patch.mode() === "off") {
583
+ return originalRender.call(this, width);
584
+ }
585
+ const cache = patch.paintCache;
586
+ const hit = cache?.get(this);
587
+ if (hit && toolPaintMatches(hit, this, width)) return hit.lines;
588
+ const lines = originalRender.call(this, width);
589
+ cache?.set(this, {
590
+ width,
591
+ expanded: Boolean(this.expanded),
592
+ isPartial: Boolean(this.isPartial),
593
+ result: this.result,
594
+ args: this.args,
595
+ callHover: isToolCallHovered(this.toolCallId),
596
+ ioHover: ioHoverOf(this),
597
+ lines,
598
+ });
599
+ return lines;
600
+ };
601
+ patch.invalidatePaint = function (this: any): void {
602
+ patch.paintCache?.delete(this);
603
+ originalInvalidate.call(this);
604
+ };
605
+ prototype.render = patch.renderPaint;
606
+ prototype.invalidate = patch.invalidatePaint;
535
607
  patchRegistry.install(GLOBAL_TOOL_RENDER_PATCH, patch);
536
608
  return patch;
537
609
  }
@@ -552,6 +624,16 @@ function deactivateGlobalToolRendering(patch: GlobalToolRenderPatch): void {
552
624
  if (prototype.getResultRenderer === patch.installed.getResultRenderer) {
553
625
  prototype.getResultRenderer = patch.downstream.getResultRenderer;
554
626
  }
627
+ if (patch.renderPaint && prototype.render === patch.renderPaint && patch.originalRender) {
628
+ prototype.render = patch.originalRender;
629
+ }
630
+ if (
631
+ patch.invalidatePaint &&
632
+ prototype.invalidate === patch.invalidatePaint &&
633
+ patch.originalInvalidate
634
+ ) {
635
+ prototype.invalidate = patch.originalInvalidate;
636
+ }
555
637
  }
556
638
 
557
639
  /** 展开面板背景统一为 user message 背景色;折叠行保持原生状态色。
@@ -18,6 +18,7 @@ import {
18
18
  import {
19
19
  collectToolComponents,
20
20
  extractToolFramePlacements,
21
+ isSgrIdleMotion,
21
22
  isSgrLeftPress,
22
23
  isSgrLeftRelease,
23
24
  isToolExecutionComponent,
@@ -138,7 +139,7 @@ function tryOpenToolIoShowMore(region: InteractionRegion): boolean {
138
139
  }
139
140
 
140
141
  function updateToolSummaryHover(tui: any, packet: SgrMousePacket): void {
141
- if ((packet.code & 32) === 0 || packet.final !== "M") return;
142
+ if (!isSgrIdleMotion(packet)) return;
142
143
  const region = interactionRegionAt(packet);
143
144
  const nextScrollButtonHovered = region?.kind === "scroll-bottom";
144
145
  const scrollButtonChanged = setScrollButtonHovered(nextScrollButtonHovered);
@@ -404,7 +405,7 @@ function handleFullscreenToolClick(tui: any, packet: SgrMousePacket): boolean {
404
405
  * expanded 卡截断头 show-more、回到底部按钮。motion 不 consume,官方链照常。
405
406
  */
406
407
  function handleFullscreenToolHover(tui: any, packet: SgrMousePacket): void {
407
- if (packet.final !== "M") return;
408
+ if (!isSgrIdleMotion(packet)) return;
408
409
  const layout = tui.currentLayout;
409
410
  if (!layout?.root) return;
410
411
  const x = packet.col - 1;
@@ -502,7 +503,8 @@ function patchFullscreenViewportInput(tui: any): void {
502
503
  if (isSgrLeftPress(packet) && handleFullscreenToolClick(tui, packet)) {
503
504
  return { consume: true };
504
505
  }
505
- if ((packet.code & 32) !== 0 && packet.final === "M") {
506
+ // 仅无按键移动走 hover;左键拖动(文本多选)放行官方选区,避免每像素命中+重绘。
507
+ if (isSgrIdleMotion(packet)) {
506
508
  handleFullscreenToolHover(tui, packet);
507
509
  }
508
510
  }
@@ -8,6 +8,7 @@ export {
8
8
  parseSgrMousePackets,
9
9
  isSgrLeftPress,
10
10
  isSgrLeftRelease,
11
+ isSgrIdleMotion,
11
12
  type SgrMousePacket,
12
13
  } from "../../utils/sgr-mouse.ts";
13
14
 
@@ -173,6 +173,15 @@ type SettledGroupCache = {
173
173
  lines: string[];
174
174
  };
175
175
 
176
+ type ExpandedGroupCache = {
177
+ width: number;
178
+ hover: boolean;
179
+ theme: unknown;
180
+ fullscreen: boolean;
181
+ paints: readonly unknown[];
182
+ lines: string[];
183
+ };
184
+
176
185
  export class ToolGroupComponent extends Container {
177
186
  readonly toolCallId = `ccstyle-tool-group-${nextGroupId++}`;
178
187
  readonly toolName = "Tool group";
@@ -183,8 +192,10 @@ export class ToolGroupComponent extends Container {
183
192
  }
184
193
  private hintHovered = false;
185
194
  private readonly patch: Patch;
186
- /** 仅缓存已完成且折叠的分组;pending / expanded 每帧现算。 */
195
+ /** 仅缓存已完成且折叠的分组;pending 每帧现算。 */
187
196
  private settledCache: SettledGroupCache | undefined;
197
+ /** 展开分组:子工具 paint 引用未变则复用整卡行。 */
198
+ private expandedPaintCache: ExpandedGroupCache | undefined;
188
199
 
189
200
  constructor(patch: Patch) {
190
201
  super();
@@ -192,14 +203,19 @@ export class ToolGroupComponent extends Container {
192
203
  patch.groups.add(this);
193
204
  }
194
205
 
195
- addTool(tool: any): void {
206
+ private clearPaintCache(): void {
196
207
  this.settledCache = undefined;
208
+ this.expandedPaintCache = undefined;
209
+ }
210
+
211
+ addTool(tool: any): void {
212
+ this.clearPaintCache();
197
213
  this.children.push(tool);
198
214
  tool[PARENT_KEY] = this;
199
215
  }
200
216
 
201
217
  releaseTools(): any[] {
202
- this.settledCache = undefined;
218
+ this.clearPaintCache();
203
219
  const tools = [...this.children];
204
220
  this.children.length = 0;
205
221
  this.patch.groups.delete(this);
@@ -207,21 +223,21 @@ export class ToolGroupComponent extends Container {
207
223
  }
208
224
 
209
225
  removeTool(tool: any): void {
210
- this.settledCache = undefined;
226
+ this.clearPaintCache();
211
227
  const index = this.children.indexOf(tool);
212
228
  if (index >= 0) this.children.splice(index, 1);
213
229
  if (tool?.[PARENT_KEY] === this) delete tool[PARENT_KEY];
214
230
  }
215
231
 
216
232
  setExpanded(expanded: boolean): void {
217
- if (this._expanded !== expanded) this.settledCache = undefined;
233
+ if (this._expanded !== expanded) this.clearPaintCache();
218
234
  this._expanded = expanded;
219
235
  for (const tool of this.children)
220
236
  (tool as Component & { setExpanded?: (expanded: boolean) => void }).setExpanded?.(expanded);
221
237
  }
222
238
 
223
239
  setHintHovered(hovered: boolean): void {
224
- if (this.hintHovered !== hovered) this.settledCache = undefined;
240
+ if (this.hintHovered !== hovered) this.clearPaintCache();
225
241
  this.hintHovered = hovered;
226
242
  }
227
243
 
@@ -250,7 +266,7 @@ export class ToolGroupComponent extends Container {
250
266
  }
251
267
 
252
268
  invalidate(): void {
253
- this.settledCache = undefined;
269
+ this.clearPaintCache();
254
270
  for (const tool of this.children) tool.invalidate?.();
255
271
  }
256
272
 
@@ -330,6 +346,23 @@ export class ToolGroupComponent extends Container {
330
346
  ),
331
347
  ];
332
348
  const total = this.children.length;
349
+ const childPaints = this._expanded
350
+ ? (this.children as any[]).map((tool) => tool.render?.(Math.max(1, width - 2)))
351
+ : undefined;
352
+ if (this._expanded && childPaints) {
353
+ const expandedHit = this.expandedPaintCache;
354
+ if (
355
+ expandedHit &&
356
+ expandedHit.width === width &&
357
+ expandedHit.hover === this.hintHovered &&
358
+ expandedHit.theme === this.patch.theme &&
359
+ expandedHit.fullscreen === isToolTuiFullscreen() &&
360
+ expandedHit.paints.length === childPaints.length &&
361
+ expandedHit.paints.every((paint, index) => paint === childPaints[index])
362
+ ) {
363
+ return expandedHit.lines;
364
+ }
365
+ }
333
366
  const expandedLines: string[] = [];
334
367
  for (let index = 0; index < total; index++) {
335
368
  const tool = this.children[index];
@@ -351,7 +384,7 @@ export class ToolGroupComponent extends Container {
351
384
  );
352
385
  continue;
353
386
  }
354
- const rendered = visibleLines(tool.render(Math.max(1, width - 2)));
387
+ const rendered = visibleLines(Array.isArray(childPaints?.[index]) ? childPaints[index] : []);
355
388
  if (rendered.length) {
356
389
  rendered[0] = stripLeadingStatusIcon(rendered[0])
357
390
  .replace(/^ +/, "")
@@ -376,6 +409,14 @@ export class ToolGroupComponent extends Container {
376
409
  lines.push(paddedBackgroundRow(theme, backgroundSlot, line, width));
377
410
  }
378
411
  lines.push(paddedBackgroundRow(theme, backgroundSlot, "", width));
412
+ this.expandedPaintCache = {
413
+ width,
414
+ hover: this.hintHovered,
415
+ theme: this.patch.theme,
416
+ fullscreen: isToolTuiFullscreen(),
417
+ paints: childPaints ?? [],
418
+ lines,
419
+ };
379
420
  } else if (counts.pending === 0) {
380
421
  this.storeSettledCache(width, lines);
381
422
  }
@@ -11,13 +11,13 @@ export function toolViewportWidth(width: number): number {
11
11
  return Math.max(1, Math.floor(width * TOOL_VIEWPORT_WIDTH_RATIO));
12
12
  }
13
13
 
14
- /** 与默认工具结果相同的一级缩进包装。 */
14
+ /** 与默认工具结果相同的一级缩进包装。子组件只扣始终加上的 1 列;↳ 行多出的 2 列由 truncateToWidth 吃掉。 */
15
15
  export function insetComponent(component: any): any {
16
16
  return {
17
17
  render: (width: number) =>
18
18
  component.render(Math.max(1, width - 1)).map((line: string) => {
19
19
  const nestedMarker = line.replace(/^((?:\x1b\[[0-?]*[ -/]*[@-~])*)↳/, "$1 ↳");
20
- return ` ${nestedMarker}`;
20
+ return truncateToWidth(` ${nestedMarker}`, Math.max(0, width), "");
21
21
  }),
22
22
  invalidate: () => component.invalidate?.(),
23
23
  };
@@ -309,6 +309,10 @@ export class ExpandedToolIoView {
309
309
  this.invalidate();
310
310
  }
311
311
 
312
+ getHoveredSection(): ToolIoSection | null {
313
+ return this.hoveredSection;
314
+ }
315
+
312
316
  /** True when the plain truncation footer carries show-more. Input 续行带 │,Output 不带。 */
313
317
  matchShowMoreLine(plainLine: string): ToolIoSection | null {
314
318
  const line = plainLine.replace(/\x1b\[[0-9;]*m/g, "");
@@ -59,6 +59,11 @@ export function isSgrLeftRelease(packet: SgrMousePacket): boolean {
59
59
  return packet.final === "m" && baseButton === 0 && (packet.code & 32) === 0;
60
60
  }
61
61
 
62
+ /** 无按键按住的指针移动(DECSET 1003 的 35)。左键拖动(32,文本选区)不是 hover。 */
63
+ export function isSgrIdleMotion(packet: SgrMousePacket): boolean {
64
+ return packet.final === "M" && (packet.code & 32) !== 0 && mouseBaseButton(packet.code) === 3;
65
+ }
66
+
62
67
  /** 剥离修饰键位(4/8/16/32:shift/meta/ctrl/motion),得到基础按键码。 */
63
68
  export function mouseBaseButton(code: number): number {
64
69
  return code & ~(4 | 8 | 16 | 32);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.8.71",
3
+ "version": "0.9.0",
4
4
  "license": "MIT",
5
5
  "description": "A Pi productivity suite with Claude Code-style UI, context inspection, and agent/session references",
6
6
  "type": "module",