pi-cc-extensions 0.8.71 → 0.8.72
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 +3 -1
- package/README.md +3 -1
- package/extensions/config/config.ts +4 -0
- package/extensions/config/panel.ts +16 -0
- package/extensions/feature/shell/footer.ts +413 -0
- package/extensions/index.ts +2 -0
- package/extensions/renderer/compact-mode.ts +154 -9
- package/extensions/renderer/default-mode.ts +82 -0
- package/extensions/renderer/mouse/interaction.ts +5 -3
- package/extensions/renderer/mouse/packets.ts +1 -0
- package/extensions/renderer/tool/grouping.ts +49 -8
- package/extensions/renderer/tool/result.ts +6 -2
- package/extensions/utils/sgr-mouse.ts +5 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -43,6 +43,7 @@ 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
|
|
@@ -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
|
|
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,6 +43,7 @@ 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
|
## 配置
|
|
@@ -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
|
|
86
|
+
"enableAliases": true, // /clear、/exit 别名
|
|
87
|
+
"enableCustomFooter": true // 两行 chips 状态栏(需 Nerd Font)
|
|
86
88
|
}
|
|
87
89
|
```
|
|
88
90
|
|
|
@@ -64,6 +64,7 @@ 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");
|
|
@@ -125,6 +126,7 @@ export const DEFAULT_CONFIG: Config = {
|
|
|
125
126
|
enableAgentSummary: true,
|
|
126
127
|
enableWorkingMessage: true,
|
|
127
128
|
enableAliases: true,
|
|
129
|
+
enableCustomFooter: true,
|
|
128
130
|
};
|
|
129
131
|
|
|
130
132
|
function pickEnum<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
|
@@ -223,6 +225,7 @@ export function normalizeConfig(input: unknown): Config {
|
|
|
223
225
|
enableAgentSummary: source.enableAgentSummary !== false,
|
|
224
226
|
enableWorkingMessage: source.enableWorkingMessage !== false,
|
|
225
227
|
enableAliases: source.enableAliases !== false,
|
|
228
|
+
enableCustomFooter: source.enableCustomFooter !== false,
|
|
226
229
|
};
|
|
227
230
|
}
|
|
228
231
|
|
|
@@ -276,6 +279,7 @@ export function formatConfigStatus(source: Config = config): string {
|
|
|
276
279
|
`agentSummary=${source.enableAgentSummary ? "on" : "off"}`,
|
|
277
280
|
`workingMsg=${source.enableWorkingMessage ? "on" : "off"}`,
|
|
278
281
|
`aliases=${source.enableAliases ? "on" : "off"}`,
|
|
282
|
+
`footer=${source.enableCustomFooter ? "on" : "off"}`,
|
|
279
283
|
].join(" · ");
|
|
280
284
|
}
|
|
281
285
|
|
|
@@ -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,413 @@
|
|
|
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 { config } from "../../config/config.ts";
|
|
18
|
+
import { stripAnsi } from "../../utils/ansi-text.ts";
|
|
19
|
+
|
|
20
|
+
const GIT_REFRESH_INTERVAL_MS = 10_000;
|
|
21
|
+
const USAGE_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
|
22
|
+
const USAGE_TIMEOUT_MS = 15_000;
|
|
23
|
+
// zentui NERD_DEFAULT_ICONS:git / cacheHit
|
|
24
|
+
const ICON_GIT = "";
|
|
25
|
+
const ICON_CACHE = "";
|
|
26
|
+
|
|
27
|
+
type GitStats = { add: number; del: number };
|
|
28
|
+
|
|
29
|
+
type XaiFooterReport = {
|
|
30
|
+
buckets?: Array<{
|
|
31
|
+
id?: string;
|
|
32
|
+
unit?: string;
|
|
33
|
+
used?: number;
|
|
34
|
+
limit?: number;
|
|
35
|
+
remaining?: number;
|
|
36
|
+
}>;
|
|
37
|
+
metrics?: Array<{ id?: string; value?: unknown }>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function parseGitStats(stdout: string): GitStats {
|
|
41
|
+
let add = 0;
|
|
42
|
+
let del = 0;
|
|
43
|
+
for (const line of stdout.split("\n")) {
|
|
44
|
+
const match = line.match(/^(\d+)\s+(\d+)/);
|
|
45
|
+
if (match) {
|
|
46
|
+
add += Number(match[1]);
|
|
47
|
+
del += Number(match[2]);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { add, del };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function classifyStatus(key: string): "usage" | "skip" | "other" {
|
|
54
|
+
if (key === "model") return "skip"; // line1 已有供应商/模型
|
|
55
|
+
if (/usage|quota|balance/i.test(key)) return "usage";
|
|
56
|
+
return "other";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatXaiFooterChip(report: XaiFooterReport): string | undefined {
|
|
60
|
+
const included = report.buckets?.find((b) => b.id === "included-allowance");
|
|
61
|
+
if (included?.unit === "percent" && typeof included.used === "number") {
|
|
62
|
+
return `xAI ${Math.round(included.used)}%`;
|
|
63
|
+
}
|
|
64
|
+
if (included && included.unit !== "percent") {
|
|
65
|
+
if (
|
|
66
|
+
typeof included.used === "number" &&
|
|
67
|
+
typeof included.limit === "number" &&
|
|
68
|
+
included.limit > 0
|
|
69
|
+
) {
|
|
70
|
+
return `xAI ${Math.round((included.used / included.limit) * 100)}%`;
|
|
71
|
+
}
|
|
72
|
+
if (typeof included.remaining === "number") {
|
|
73
|
+
return `xAI $${included.remaining.toFixed(2)}`;
|
|
74
|
+
}
|
|
75
|
+
if (typeof included.used === "number") {
|
|
76
|
+
return `xAI $${included.used.toFixed(2)}`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const prepaid = report.metrics?.find((m) => m.id === "prepaid-balance");
|
|
80
|
+
if (typeof prepaid?.value === "number") {
|
|
81
|
+
return `xAI $${prepaid.value.toFixed(2)}`;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function pickFooterUsageText(
|
|
87
|
+
pluginUsage: string | undefined,
|
|
88
|
+
localChip: string | undefined,
|
|
89
|
+
): string | undefined {
|
|
90
|
+
const plugin = pluginUsage?.trim();
|
|
91
|
+
if (plugin && !/^checking$/i.test(plugin)) return plugin;
|
|
92
|
+
const local = localChip?.trim();
|
|
93
|
+
return local || undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let piUsageMod: any | null | undefined;
|
|
97
|
+
|
|
98
|
+
async function getPiUsage(): Promise<any | null> {
|
|
99
|
+
if (piUsageMod !== undefined) return piUsageMod;
|
|
100
|
+
try {
|
|
101
|
+
const spec = "@narumitw/pi-usage";
|
|
102
|
+
piUsageMod = await import(spec);
|
|
103
|
+
} catch {
|
|
104
|
+
piUsageMod = null;
|
|
105
|
+
}
|
|
106
|
+
return piUsageMod;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function colorUsageChip(theme: any, text: string): string {
|
|
110
|
+
return theme.fg("dim", stripAnsi(text));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let currentTui: any = undefined;
|
|
114
|
+
let refreshCurrentGitStats: (() => void) | undefined;
|
|
115
|
+
let refreshCurrentUsage: (() => void) | undefined;
|
|
116
|
+
|
|
117
|
+
const createCustomFooterFactory =
|
|
118
|
+
(ctx: ExtensionContext) => (tui: any, theme: any, footerData: any) => {
|
|
119
|
+
currentTui = tui;
|
|
120
|
+
const sep = theme.fg("muted", " · ");
|
|
121
|
+
const joinChips = (parts: string[]) => parts.filter(Boolean).join(sep);
|
|
122
|
+
let gitStats: GitStats | undefined;
|
|
123
|
+
let gitRefreshRunning = false;
|
|
124
|
+
let localUsageChip = "";
|
|
125
|
+
let usageGeneration = 0;
|
|
126
|
+
let usageAbort: AbortController | undefined;
|
|
127
|
+
let disposed = false;
|
|
128
|
+
|
|
129
|
+
// 查询在 render 外执行,保留旧值;仅统计结果真正变化时重绘,避免周期性清零抖动。
|
|
130
|
+
const refreshGitStats = () => {
|
|
131
|
+
if (gitRefreshRunning || disposed) return;
|
|
132
|
+
gitRefreshRunning = true;
|
|
133
|
+
execFile(
|
|
134
|
+
"git",
|
|
135
|
+
["diff", "--numstat", "HEAD"],
|
|
136
|
+
{ cwd: ctx.cwd, timeout: 2000 },
|
|
137
|
+
(err, stdout) => {
|
|
138
|
+
gitRefreshRunning = false;
|
|
139
|
+
if (err || disposed) return;
|
|
140
|
+
const next = parseGitStats(stdout);
|
|
141
|
+
if (gitStats?.add === next.add && gitStats.del === next.del) return;
|
|
142
|
+
gitStats = next;
|
|
143
|
+
tui.requestRender();
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
};
|
|
147
|
+
refreshCurrentGitStats = refreshGitStats;
|
|
148
|
+
refreshGitStats();
|
|
149
|
+
const gitRefreshTimer = setInterval(refreshGitStats, GIT_REFRESH_INTERVAL_MS);
|
|
150
|
+
gitRefreshTimer.unref?.();
|
|
151
|
+
|
|
152
|
+
const refreshUsage = () => {
|
|
153
|
+
if (disposed) return;
|
|
154
|
+
const generation = ++usageGeneration;
|
|
155
|
+
usageAbort?.abort();
|
|
156
|
+
const controller = new AbortController();
|
|
157
|
+
usageAbort = controller;
|
|
158
|
+
const stale = () => disposed || generation !== usageGeneration || controller.signal.aborted;
|
|
159
|
+
void (async () => {
|
|
160
|
+
try {
|
|
161
|
+
const model = ctx.model;
|
|
162
|
+
const api = await getPiUsage();
|
|
163
|
+
if (stale()) return;
|
|
164
|
+
if (!api || !model) {
|
|
165
|
+
if (localUsageChip) {
|
|
166
|
+
localUsageChip = "";
|
|
167
|
+
tui.requestRender();
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const adapter = api.adapterForProvider?.(model.provider);
|
|
172
|
+
if (!adapter || adapter.publishesStatusline !== false) {
|
|
173
|
+
if (localUsageChip) {
|
|
174
|
+
localUsageChip = "";
|
|
175
|
+
tui.requestRender();
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const auth = await api.resolveUsageAuth(ctx, adapter);
|
|
180
|
+
if (stale()) return;
|
|
181
|
+
if (!auth) {
|
|
182
|
+
const next = `${adapter.displayName} ✗`;
|
|
183
|
+
if (localUsageChip !== next) {
|
|
184
|
+
localUsageChip = next;
|
|
185
|
+
tui.requestRender();
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const guard = async () => {
|
|
190
|
+
if (stale()) throw new Error("aborted");
|
|
191
|
+
const again = await api.resolveUsageAuth(ctx, adapter);
|
|
192
|
+
if (again?.fingerprint !== auth.fingerprint) {
|
|
193
|
+
throw new Error("aborted");
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const report = await api.queryProviderUsage(
|
|
197
|
+
adapter,
|
|
198
|
+
auth,
|
|
199
|
+
controller.signal,
|
|
200
|
+
USAGE_TIMEOUT_MS,
|
|
201
|
+
guard,
|
|
202
|
+
);
|
|
203
|
+
if (stale()) return;
|
|
204
|
+
const next =
|
|
205
|
+
(adapter.id === "xai" ? formatXaiFooterChip(report) : undefined) ||
|
|
206
|
+
api.formatUsageStatusline?.(report, model) ||
|
|
207
|
+
`${adapter.displayName} ✗`;
|
|
208
|
+
if (localUsageChip !== next) {
|
|
209
|
+
localUsageChip = next;
|
|
210
|
+
tui.requestRender();
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
if (stale()) return;
|
|
214
|
+
const name = ctx.model?.provider === "xai" ? "xAI" : "usage";
|
|
215
|
+
const next = `${name} ✗`;
|
|
216
|
+
if (localUsageChip !== next) {
|
|
217
|
+
localUsageChip = next;
|
|
218
|
+
tui.requestRender();
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
if (usageAbort === controller) usageAbort = undefined;
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
};
|
|
225
|
+
refreshCurrentUsage = refreshUsage;
|
|
226
|
+
refreshUsage();
|
|
227
|
+
const usageRefreshTimer = setInterval(refreshUsage, USAGE_REFRESH_INTERVAL_MS);
|
|
228
|
+
usageRefreshTimer.unref?.();
|
|
229
|
+
|
|
230
|
+
// 分支变化时同步更新分支名和相对 HEAD 的统计。
|
|
231
|
+
const unsubBranch = footerData.onBranchChange(() => {
|
|
232
|
+
refreshGitStats();
|
|
233
|
+
tui.requestRender();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const fmt = (n: number): string => {
|
|
237
|
+
if (n < 1000) return `${n}`;
|
|
238
|
+
if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
|
|
239
|
+
if (n < 1000000) return `${Math.round(n / 1000)}k`;
|
|
240
|
+
if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
|
|
241
|
+
return `${Math.round(n / 1000000)}M`;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// ---- pi-cc-status 风格:上下文条形图 ----
|
|
245
|
+
const BAR_WIDTH = 10;
|
|
246
|
+
const BAR_WARNING = 80; // >= 80% 转 warning 色
|
|
247
|
+
const BAR_ERROR = 95; // >= 95% 转 error 色
|
|
248
|
+
const barGauge = (pct: number): string => {
|
|
249
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
250
|
+
const filled = Math.round((clamped / 100) * BAR_WIDTH);
|
|
251
|
+
const color = clamped >= BAR_ERROR ? "error" : clamped >= BAR_WARNING ? "warning" : "accent";
|
|
252
|
+
return theme.fg(color, "█".repeat(filled)) + theme.fg("dim", "░".repeat(BAR_WIDTH - filled));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// pi-cc-status showCachePercent:最近一条助手消息的 cacheRead 占比
|
|
256
|
+
const getCachePct = (): number => {
|
|
257
|
+
const branch = ctx.sessionManager.getBranch();
|
|
258
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
259
|
+
const e = branch[i];
|
|
260
|
+
if (e.type !== "message" || e.message.role !== "assistant") continue;
|
|
261
|
+
const u = e.message.usage;
|
|
262
|
+
if (!u) return 0;
|
|
263
|
+
const total = u.input + u.cacheWrite + u.cacheRead;
|
|
264
|
+
return total > 0 ? (u.cacheRead * 100) / total : 0;
|
|
265
|
+
}
|
|
266
|
+
return 0;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const render = (width: number): string[] => {
|
|
270
|
+
const model = ctx.model;
|
|
271
|
+
const contextUsage = ctx.getContextUsage();
|
|
272
|
+
const contextWindow = contextUsage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
273
|
+
const percent = contextUsage?.percent;
|
|
274
|
+
|
|
275
|
+
let cost = 0;
|
|
276
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
277
|
+
if (entry.type === "message") {
|
|
278
|
+
const m = entry.message;
|
|
279
|
+
if (m.role === "assistant") cost += m.usage?.cost?.total || 0;
|
|
280
|
+
else if (m.role === "toolResult" && m.usage) cost += m.usage.cost?.total || 0;
|
|
281
|
+
} else if (
|
|
282
|
+
(entry.type === "branch_summary" || entry.type === "compaction") &&
|
|
283
|
+
entry.usage
|
|
284
|
+
) {
|
|
285
|
+
cost += entry.usage.cost?.total || 0;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// cwd 默认只显示最底层路径(根目录时回退完整路径)
|
|
290
|
+
const cwdLabel = ctx.cwd.split(/[\\/]/).filter(Boolean).pop() || ctx.cwd;
|
|
291
|
+
|
|
292
|
+
const usingSubscription =
|
|
293
|
+
!!model &&
|
|
294
|
+
["kimi-coding", "xai", "github-copilot", "openai"].includes(model.provider || "");
|
|
295
|
+
const modelLabel = model
|
|
296
|
+
? model.id.includes("/")
|
|
297
|
+
? model.id
|
|
298
|
+
: `${model.provider}/${model.id}`
|
|
299
|
+
: "no-model";
|
|
300
|
+
|
|
301
|
+
const thinkingLevelStr = ctx.thinkingLevel || "off";
|
|
302
|
+
const thinkingColor = theme.getThinkingBorderColor(thinkingLevelStr);
|
|
303
|
+
|
|
304
|
+
const otherStatuses: string[] = [];
|
|
305
|
+
const usageStatuses: string[] = [];
|
|
306
|
+
for (const [key, raw] of Array.from(
|
|
307
|
+
footerData.getExtensionStatuses().entries() as Iterable<[string, string]>,
|
|
308
|
+
).sort(([a], [b]) => a.localeCompare(b))) {
|
|
309
|
+
const t = raw.replace(/[\r\n\t]+/g, " ").trim();
|
|
310
|
+
if (!t) continue;
|
|
311
|
+
const kind = classifyStatus(key);
|
|
312
|
+
if (kind === "skip") continue;
|
|
313
|
+
if (kind === "usage") {
|
|
314
|
+
usageStatuses.push(stripAnsi(t));
|
|
315
|
+
} else {
|
|
316
|
+
otherStatuses.push(theme.fg("dim", stripAnsi(t)));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const usageText = pickFooterUsageText(usageStatuses[0], localUsageChip);
|
|
321
|
+
|
|
322
|
+
const pctLabel = percent === null || percent === undefined ? "?" : `${Math.floor(percent)}%`;
|
|
323
|
+
const cachePct = getCachePct();
|
|
324
|
+
const costChip =
|
|
325
|
+
cost || usingSubscription
|
|
326
|
+
? theme.fg("dim", `$${cost.toFixed(2)}`) +
|
|
327
|
+
(usingSubscription ? theme.fg("warning", " sub") : "")
|
|
328
|
+
: "";
|
|
329
|
+
const line1 = joinChips([
|
|
330
|
+
theme.fg("accent", modelLabel),
|
|
331
|
+
model?.reasoning ? thinkingColor(thinkingLevelStr) : "",
|
|
332
|
+
barGauge(percent ?? 0) + theme.fg("dim", ` ${pctLabel}/${fmt(contextWindow)}`),
|
|
333
|
+
cachePct > 0 ? theme.fg("dim", `${ICON_CACHE} ${Math.floor(cachePct)}%`) : "",
|
|
334
|
+
costChip,
|
|
335
|
+
usageText ? colorUsageChip(theme, usageText) : "",
|
|
336
|
+
]);
|
|
337
|
+
|
|
338
|
+
const gitColor = theme.getThinkingBorderColor("medium");
|
|
339
|
+
let place = theme.fg("accent", cwdLabel);
|
|
340
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
341
|
+
if (sessionName) {
|
|
342
|
+
place += theme.fg("muted", " in ") + theme.fg("success", sessionName);
|
|
343
|
+
}
|
|
344
|
+
const branch = footerData.getGitBranch();
|
|
345
|
+
if (branch) {
|
|
346
|
+
const stats =
|
|
347
|
+
gitStats && (gitStats.add || gitStats.del)
|
|
348
|
+
? theme.fg("dim", " (") +
|
|
349
|
+
theme.fg("success", `+${gitStats.add}`) +
|
|
350
|
+
" " +
|
|
351
|
+
theme.fg("error", `−${gitStats.del}`) +
|
|
352
|
+
theme.fg("dim", ")")
|
|
353
|
+
: "";
|
|
354
|
+
place += theme.fg("muted", " on ") + gitColor(`${ICON_GIT} ${branch}`) + stats;
|
|
355
|
+
}
|
|
356
|
+
const line2 = joinChips([place, ...otherStatuses]);
|
|
357
|
+
|
|
358
|
+
return [truncateToWidth(line1, width), ...(line2 ? [truncateToWidth(line2, width)] : [])];
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
render,
|
|
363
|
+
invalidate(): void {
|
|
364
|
+
tui.requestRender();
|
|
365
|
+
},
|
|
366
|
+
dispose(): void {
|
|
367
|
+
disposed = true;
|
|
368
|
+
usageAbort?.abort();
|
|
369
|
+
usageAbort = undefined;
|
|
370
|
+
clearInterval(gitRefreshTimer);
|
|
371
|
+
clearInterval(usageRefreshTimer);
|
|
372
|
+
unsubBranch();
|
|
373
|
+
if (currentTui === tui) {
|
|
374
|
+
currentTui = undefined;
|
|
375
|
+
refreshCurrentGitStats = undefined;
|
|
376
|
+
refreshCurrentUsage = undefined;
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 按配置应用底栏:on → 自定义 footer;off → 恢复官方默认 footer。
|
|
384
|
+
* 导出供 /ccstyle 面板在切换开关时实时重应用。
|
|
385
|
+
*/
|
|
386
|
+
export function applyCustomFooter(ctx: ExtensionContext): void {
|
|
387
|
+
if (!ctx?.hasUI || typeof ctx.ui?.setFooter !== "function") return;
|
|
388
|
+
if (!config.enableCustomFooter) {
|
|
389
|
+
ctx.ui.setFooter(undefined);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
ctx.ui.setFooter(createCustomFooterFactory(ctx));
|
|
394
|
+
} catch (err) {
|
|
395
|
+
ctx.ui.notify(`footer error: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export default function (pi: ExtensionAPI) {
|
|
400
|
+
// 模型/思考级别变化时强制重渲染(自定义 footer 不会被内置 invalidate() 触达)
|
|
401
|
+
pi.on("model_select", () => {
|
|
402
|
+
currentTui?.requestRender();
|
|
403
|
+
refreshCurrentUsage?.();
|
|
404
|
+
});
|
|
405
|
+
pi.on("thinking_level_select", () => currentTui?.requestRender());
|
|
406
|
+
// 工具执行完成后立即刷新;定时器只负责兜底捕获外部文件变化。
|
|
407
|
+
pi.on("tool_execution_end", () => refreshCurrentGitStats?.());
|
|
408
|
+
|
|
409
|
+
// 启动 / /reload / 新建会话 时按配置恢复
|
|
410
|
+
pi.on("session_start", (_event, ctx) => {
|
|
411
|
+
applyCustomFooter(ctx);
|
|
412
|
+
});
|
|
413
|
+
}
|
package/extensions/index.ts
CHANGED
|
@@ -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 (
|
|
341
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
506
|
+
// 仅无按键移动走 hover;左键拖动(文本多选)放行官方选区,避免每像素命中+重绘。
|
|
507
|
+
if (isSgrIdleMotion(packet)) {
|
|
506
508
|
handleFullscreenToolHover(tui, packet);
|
|
507
509
|
}
|
|
508
510
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
}
|
|
@@ -15,9 +15,9 @@ export function toolViewportWidth(width: number): number {
|
|
|
15
15
|
export function insetComponent(component: any): any {
|
|
16
16
|
return {
|
|
17
17
|
render: (width: number) =>
|
|
18
|
-
component.render(Math.max(1, width -
|
|
18
|
+
component.render(Math.max(1, width - 3)).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);
|