pi-cc-extensions 0.9.8 → 0.9.10
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 +48 -48
- package/README.md +6 -6
- package/extensions/config/config.ts +35 -6
- package/extensions/config/panel.ts +27 -3
- package/extensions/feature/compact-thinking.ts +7 -4
- package/extensions/feature/shell/footer.ts +47 -5
- package/extensions/feature/shell/mcp-chip.ts +136 -0
- package/extensions/feature/shell/startup-header.ts +82 -6
- package/extensions/feature/shell/working-message.ts +21 -6
- package/extensions/index.ts +2 -1
- package/extensions/renderer/compact-mode.ts +105 -16
- package/extensions/renderer/default-mode.ts +36 -4
- package/extensions/renderer/mouse/interaction.ts +11 -2
- package/extensions/renderer/tool/codemode.ts +303 -0
- package/extensions/renderer/tool/diff/diff-header.ts +1 -1
- package/extensions/renderer/tool/diff/diff-highlight.ts +36 -0
- package/extensions/renderer/tool/diff/diff-presentation.ts +2 -2
- package/extensions/renderer/tool/diff/diff-write-render.ts +2 -2
- package/extensions/renderer/tool/diff/line-width-safety.ts +6 -5
- package/extensions/renderer/tool/grouping.ts +37 -9
- package/extensions/renderer/tool/message-display.ts +3 -3
- package/extensions/renderer/tool/names.ts +12 -4
- package/extensions/renderer/tool/result.ts +180 -37
- package/extensions/utils/ansi-text.ts +1 -0
- package/extensions/utils/patch-keys.ts +2 -0
- package/package.json +7 -4
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 内置 MCP 的状态栏芯片。
|
|
3
|
+
*
|
|
4
|
+
* pi 的内置 MCP 不写 setStatus:连接状态只在 `/mcp` 菜单里,扩展读不到。这里用公开数据推算
|
|
5
|
+
* 「已连接/已配置」,并在别的扩展接管 `/mcp` 时让位(pi-mcp-adapter 这类扩展自己会写 "mcp" 状态)。
|
|
6
|
+
*
|
|
7
|
+
* - 已连接:`pi.getAllTools()` 里来源是 builtin:mcp、曝光非 hidden 的工具,按 namespace 去重。
|
|
8
|
+
* 断线后工具仍在、0 个工具的服务器连上也看不见,needs-auth / failed 与 connecting 也分不开,
|
|
9
|
+
* 所以这只是近似值。
|
|
10
|
+
* - 已配置:mcp.json 里 enabled !== false 的条目(项目级要已 trust)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync, statSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
/** 与 pi-mcp-adapter 共用同一个 status key;它写了 key 时本扩展不画。 */
|
|
18
|
+
export const MCP_STATUS_KEY = "mcp";
|
|
19
|
+
/** 内置 MCP 扩展的资源路径。 */
|
|
20
|
+
export const BUILTIN_MCP_SOURCE = "builtin:mcp";
|
|
21
|
+
const MCP_NAMESPACE_PREFIX = "mcp__";
|
|
22
|
+
|
|
23
|
+
type McpConfigCacheEntry = { mtimeMs: number; names: string[] };
|
|
24
|
+
|
|
25
|
+
const configCache = new Map<string, McpConfigCacheEntry>();
|
|
26
|
+
|
|
27
|
+
export function mcpAgentDir(): string {
|
|
28
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** pi 把 `-` 与 `_` 视作同一个服务器名(`mcpNamespace` 只把 `-` 换成 `_`)。 */
|
|
32
|
+
export function normalizeMcpServerName(name: string): string {
|
|
33
|
+
return name.trim().replace(/-/g, "_");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `mcp__<server>` → `<server>`;不是 MCP 命名空间返回 undefined。 */
|
|
37
|
+
export function mcpNamespaceServer(namespace: string): string | undefined {
|
|
38
|
+
if (!namespace.startsWith(MCP_NAMESPACE_PREFIX)) return undefined;
|
|
39
|
+
const server = namespace.slice(MCP_NAMESPACE_PREFIX.length);
|
|
40
|
+
return server || undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 已连接的服务器名(按 namespace 去重,返回规范化名字)。 */
|
|
44
|
+
export function connectedMcpServers(tools: readonly any[]): string[] {
|
|
45
|
+
const fromBuiltin = new Set<string>();
|
|
46
|
+
const fromNamespace = new Set<string>();
|
|
47
|
+
for (const tool of tools ?? []) {
|
|
48
|
+
if (tool?.exposure === "hidden") continue;
|
|
49
|
+
const server = mcpNamespaceServer(String(tool?.namespace?.name ?? ""));
|
|
50
|
+
if (!server) continue;
|
|
51
|
+
const name = normalizeMcpServerName(server);
|
|
52
|
+
if (tool?.sourceInfo?.path === BUILTIN_MCP_SOURCE) fromBuiltin.add(name);
|
|
53
|
+
else fromNamespace.add(name);
|
|
54
|
+
}
|
|
55
|
+
// 版本漂移兜底:来源路径对不上时按 namespace 认(接管 /mcp 的扩展已在此之前排除)
|
|
56
|
+
return [...(fromBuiltin.size > 0 ? fromBuiltin : fromNamespace)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `MCP 2/3`:已连接/(已连接 ∪ 已配置)。两边都空时不显示。 */
|
|
60
|
+
export function formatMcpChip(
|
|
61
|
+
connected: readonly string[],
|
|
62
|
+
configured: readonly string[],
|
|
63
|
+
): string | undefined {
|
|
64
|
+
const connectedNames = new Set(connected.map(normalizeMcpServerName).filter(Boolean));
|
|
65
|
+
const configuredNames = new Set(configured.map(normalizeMcpServerName).filter(Boolean));
|
|
66
|
+
const total = new Set([...connectedNames, ...configuredNames]).size;
|
|
67
|
+
if (total === 0) return undefined;
|
|
68
|
+
return `MCP ${connectedNames.size}/${total}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 读一个 mcp.json 里启用中的服务器名;文件缺失或损坏返回 undefined。 */
|
|
72
|
+
function readMcpConfigNames(path: string): string[] | undefined {
|
|
73
|
+
try {
|
|
74
|
+
const { mtimeMs } = statSync(path);
|
|
75
|
+
const cached = configCache.get(path);
|
|
76
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.names;
|
|
77
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as { mcpServers?: unknown };
|
|
78
|
+
const servers = parsed?.mcpServers;
|
|
79
|
+
const names: string[] = [];
|
|
80
|
+
if (servers && typeof servers === "object" && !Array.isArray(servers)) {
|
|
81
|
+
for (const [name, entry] of Object.entries(servers as Record<string, unknown>)) {
|
|
82
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
83
|
+
if ((entry as { enabled?: unknown }).enabled === false) continue;
|
|
84
|
+
names.push(name);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
configCache.set(path, { mtimeMs, names });
|
|
88
|
+
return names;
|
|
89
|
+
} catch {
|
|
90
|
+
configCache.delete(path);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** mcp.json 里启用中的服务器名:用户级,加上已信任时的项目级(同名以项目为准,计数时去重)。 */
|
|
96
|
+
export function configuredMcpServers(options: {
|
|
97
|
+
agentDir: string;
|
|
98
|
+
cwd: string;
|
|
99
|
+
projectTrusted: boolean;
|
|
100
|
+
}): string[] {
|
|
101
|
+
const names = [...(readMcpConfigNames(join(options.agentDir, "mcp.json")) ?? [])];
|
|
102
|
+
if (options.projectTrusted) {
|
|
103
|
+
names.push(...(readMcpConfigNames(join(options.cwd, ".pi", "mcp.json")) ?? []));
|
|
104
|
+
}
|
|
105
|
+
return names;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 注册了 `/mcp` 的都是内置 MCP;别的扩展接管时它自己会写状态芯片。 */
|
|
109
|
+
export function builtinMcpOwnsCommand(commands: readonly any[]): boolean {
|
|
110
|
+
const mcpCommands = (commands ?? []).filter((command) => command?.name === MCP_STATUS_KEY);
|
|
111
|
+
return (
|
|
112
|
+
mcpCommands.length > 0 &&
|
|
113
|
+
mcpCommands.every((command) => command?.sourceInfo?.path === BUILTIN_MCP_SOURCE)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** footer 渲染用:当前芯片文案(不含 nerd 图标),不适用时返回 undefined。 */
|
|
118
|
+
export function buildMcpChip(ctx: any, api: any): string | undefined {
|
|
119
|
+
try {
|
|
120
|
+
// 工具与命令列表在 pi API 上,ctx 只提供 cwd / trust
|
|
121
|
+
if (!builtinMcpOwnsCommand(api?.getCommands?.() ?? [])) return undefined;
|
|
122
|
+
const tools = api?.getAllTools?.();
|
|
123
|
+
if (!Array.isArray(tools)) return undefined;
|
|
124
|
+
return formatMcpChip(
|
|
125
|
+
connectedMcpServers(tools),
|
|
126
|
+
configuredMcpServers({
|
|
127
|
+
agentDir: mcpAgentDir(),
|
|
128
|
+
cwd: String(ctx?.cwd ?? process.cwd()),
|
|
129
|
+
projectTrusted: ctx?.isProjectTrusted?.() === true,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
} catch {
|
|
133
|
+
// footer 不能因为读配置或读工具列表失败而中断渲染
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { VERSION, type AppKeybinding } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import { InteractiveMode, VERSION, type AppKeybinding } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getKeybindings } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { config } from "../../config/config.ts";
|
|
5
5
|
import { ansi16ToRgb, ansi256ToRgb } from "../../utils/ansi-color.ts";
|
|
6
6
|
import { stripAnsi } from "../../utils/ansi-text.ts";
|
|
7
|
+
import {
|
|
8
|
+
EARLY_STARTUP_HEADER_PATCH,
|
|
9
|
+
EARLY_STARTUP_HEADER_SWAP_KEY,
|
|
10
|
+
patchRegistry,
|
|
11
|
+
} from "../../utils/patch-keys.ts";
|
|
7
12
|
type Rgb = [number, number, number];
|
|
8
13
|
type StyledPart = {
|
|
9
14
|
raw: string;
|
|
@@ -279,17 +284,88 @@ export function renderHeaderLines(
|
|
|
279
284
|
);
|
|
280
285
|
}
|
|
281
286
|
|
|
287
|
+
/** 官方 setHeader 的组件工厂:theme 由宿主注入。 */
|
|
288
|
+
function headerFactory(): (
|
|
289
|
+
tui: unknown,
|
|
290
|
+
theme: any,
|
|
291
|
+
) => {
|
|
292
|
+
render(width: number): string[];
|
|
293
|
+
invalidate(): void;
|
|
294
|
+
} {
|
|
295
|
+
return (_tui: unknown, theme: any) => ({
|
|
296
|
+
render(width: number): string[] {
|
|
297
|
+
return renderHeaderLines(width, theme);
|
|
298
|
+
},
|
|
299
|
+
invalidate() {},
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
282
303
|
/** 按配置安装启动头。禁用时不碰槽位,避免清掉其他扩展的 header。 */
|
|
283
304
|
export function applyStartupHeader(ctx: any): void {
|
|
284
305
|
if (!ctx?.hasUI || typeof ctx.ui?.setHeader !== "function" || !config.showStartupHeader) {
|
|
285
306
|
return;
|
|
286
307
|
}
|
|
287
|
-
ctx.ui.setHeader((
|
|
288
|
-
|
|
289
|
-
|
|
308
|
+
ctx.ui.setHeader(headerFactory());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type EarlyHeaderPatch = {
|
|
312
|
+
active: boolean;
|
|
313
|
+
original: (...args: any[]) => unknown;
|
|
314
|
+
installed: (...args: any[]) => unknown;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
type HeaderContainerSwap = {
|
|
318
|
+
original: (child: any) => unknown;
|
|
319
|
+
installed: (child: any) => unknown;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Pi 在 session_start 之前就把原生启动头画上去了:init() 先建 header 再 requestRender,
|
|
324
|
+
* 之后才 await 工具检查、加载扩展,所以扩展最早只能到 session_start 换头,启动时会闪一下原生头。
|
|
325
|
+
* 这里补 init,在原生 header 刚进容器的瞬间换成 ccstyle 的,首次绘制就已经是我们的。
|
|
326
|
+
* 判定用 child === mode.builtInHeader,不依赖类名;配置关掉时不碰槽位,保留原生头。
|
|
327
|
+
*/
|
|
328
|
+
export function installEarlyStartupHeader(): void {
|
|
329
|
+
const prototype = (InteractiveMode as any)?.prototype;
|
|
330
|
+
if (!prototype || typeof prototype.init !== "function") return;
|
|
331
|
+
const previous = patchRegistry.get<EarlyHeaderPatch>(EARLY_STARTUP_HEADER_PATCH);
|
|
332
|
+
if (previous) previous.active = false;
|
|
333
|
+
const original =
|
|
334
|
+
previous && prototype.init === previous.installed ? previous.original : prototype.init;
|
|
335
|
+
const patch: EarlyHeaderPatch = {
|
|
336
|
+
active: true,
|
|
337
|
+
original,
|
|
338
|
+
installed: async function (this: any, ...args: any[]) {
|
|
339
|
+
installHeaderContainerSwap(this, patch);
|
|
340
|
+
return original.apply(this, args);
|
|
290
341
|
},
|
|
291
|
-
|
|
292
|
-
|
|
342
|
+
};
|
|
343
|
+
prototype.init = patch.installed;
|
|
344
|
+
patchRegistry.install(EARLY_STARTUP_HEADER_PATCH, patch);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 只包一次 headerContainer.addChild;/reload 后按所有权接管旧包装。 */
|
|
348
|
+
function installHeaderContainerSwap(mode: any, patch: EarlyHeaderPatch): void {
|
|
349
|
+
const container = mode?.headerContainer;
|
|
350
|
+
if (!container || typeof container.addChild !== "function") return;
|
|
351
|
+
const previous = container[EARLY_STARTUP_HEADER_SWAP_KEY] as HeaderContainerSwap | undefined;
|
|
352
|
+
const original =
|
|
353
|
+
previous && container.addChild === previous.installed
|
|
354
|
+
? previous.original
|
|
355
|
+
: container.addChild.bind(container);
|
|
356
|
+
const installed = function (child: any) {
|
|
357
|
+
const result = original(child);
|
|
358
|
+
if (!patch.active || !config.showStartupHeader || child !== mode.builtInHeader) return result;
|
|
359
|
+
if (typeof mode.setExtensionHeader !== "function") return result;
|
|
360
|
+
try {
|
|
361
|
+
mode.setExtensionHeader(headerFactory());
|
|
362
|
+
} catch {
|
|
363
|
+
// 换头失败就退回原生 header,不影响启动
|
|
364
|
+
}
|
|
365
|
+
return result;
|
|
366
|
+
};
|
|
367
|
+
container.addChild = installed;
|
|
368
|
+
container[EARLY_STARTUP_HEADER_SWAP_KEY] = { original, installed };
|
|
293
369
|
}
|
|
294
370
|
|
|
295
371
|
/** 用户从 /ccstyle 关掉本扩展启动头时,恢复官方 header。 */
|
|
@@ -115,18 +115,30 @@ export default function (pi: ExtensionAPI): void {
|
|
|
115
115
|
return parts.length ? `Working... (${parts.join(" · ")})` : "";
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
function workingUiAvailable(): boolean {
|
|
119
|
+
try {
|
|
120
|
+
return activeCtx?.hasUI === true;
|
|
121
|
+
} catch {
|
|
122
|
+
// 会话替换/reload 后捕获的 ctx 失效,getter 抛错;停止驱动 footer。
|
|
123
|
+
turnActive = false;
|
|
124
|
+
activeCtx = null;
|
|
125
|
+
stopRefreshLoop();
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
function restoreDefaultWorkingMessage(): void {
|
|
119
131
|
lastMessage = null;
|
|
120
|
-
if (!
|
|
132
|
+
if (!workingUiAvailable()) return;
|
|
121
133
|
try {
|
|
122
|
-
activeCtx
|
|
134
|
+
activeCtx?.ui?.setWorkingMessage();
|
|
123
135
|
} catch {
|
|
124
136
|
// Noop when the TUI is unavailable.
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
function syncWorkingMessage(force = false): void {
|
|
129
|
-
if (!
|
|
141
|
+
if (!workingUiAvailable()) return;
|
|
130
142
|
const next = buildWorkingMessage();
|
|
131
143
|
if (!next) {
|
|
132
144
|
if (force) restoreDefaultWorkingMessage();
|
|
@@ -135,7 +147,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
135
147
|
if (!force && next === lastMessage) return;
|
|
136
148
|
lastMessage = next;
|
|
137
149
|
try {
|
|
138
|
-
activeCtx
|
|
150
|
+
activeCtx?.ui?.setWorkingMessage(next);
|
|
139
151
|
} catch {
|
|
140
152
|
// Noop when the TUI is unavailable.
|
|
141
153
|
}
|
|
@@ -147,9 +159,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
147
159
|
refreshTimer = null;
|
|
148
160
|
try {
|
|
149
161
|
syncWorkingMessage();
|
|
150
|
-
}
|
|
151
|
-
|
|
162
|
+
} catch {
|
|
163
|
+
// 定时器内的异常会成为 uncaughtException 终止 Pi;装饰性刷新直接停止。
|
|
164
|
+
turnActive = false;
|
|
165
|
+
return;
|
|
152
166
|
}
|
|
167
|
+
scheduleRefreshTick();
|
|
153
168
|
}, REFRESH_INTERVAL_MS);
|
|
154
169
|
refreshTimer.unref?.();
|
|
155
170
|
}
|
package/extensions/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { config } from "./config/config.ts";
|
|
|
5
5
|
import piAliases from "./feature/shell/aliases.ts";
|
|
6
6
|
import { installFlushDockedBash } from "./feature/shell/flush-docked-bash.ts";
|
|
7
7
|
import customFooter from "./feature/shell/footer.ts";
|
|
8
|
-
import piStartupHeader from "./feature/shell/startup-header.ts";
|
|
8
|
+
import piStartupHeader, { installEarlyStartupHeader } from "./feature/shell/startup-header.ts";
|
|
9
9
|
import workingMessage from "./feature/shell/working-message.ts";
|
|
10
10
|
|
|
11
11
|
// feature
|
|
@@ -23,6 +23,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
23
23
|
// shell chrome
|
|
24
24
|
if (config.enableAliases) piAliases(pi);
|
|
25
25
|
installFlushDockedBash();
|
|
26
|
+
installEarlyStartupHeader();
|
|
26
27
|
piStartupHeader(pi);
|
|
27
28
|
if (config.enableWorkingMessage) workingMessage(pi);
|
|
28
29
|
customFooter(pi);
|
|
@@ -338,16 +338,18 @@ function toolCardBgRow(
|
|
|
338
338
|
const inset = (n: number) => (outerBg ? `${outerBg}${" ".repeat(n)}\x1b[49m` : " ".repeat(n));
|
|
339
339
|
return `${inset(leftInset)}${bgAnsi}${stable}\x1b[49m${inset(rightInset)}`;
|
|
340
340
|
}
|
|
341
|
-
/** edit/write
|
|
341
|
+
/** edit/write 展开卡:保持原样式(展开卡背景 Box),不应用工具卡深色/间距改动。 */
|
|
342
342
|
function editWriteExpandedCard(theme: any): any {
|
|
343
343
|
return new Box(
|
|
344
344
|
1,
|
|
345
345
|
1,
|
|
346
|
-
typeof theme.bg === "function"
|
|
346
|
+
typeof theme.bg === "function"
|
|
347
|
+
? (text: string) => theme.bg(config.expandedCardBackground, text)
|
|
348
|
+
: undefined,
|
|
347
349
|
);
|
|
348
350
|
}
|
|
349
351
|
|
|
350
|
-
/** compact
|
|
352
|
+
/** compact 展开面板:外卡片保持展开卡背景;展开的 thinking 另包一层更深的
|
|
351
353
|
* 内卡(自身 Box 背景与外卡同色,不区分会糊在一起),工具卡直接铺在外卡背景上。
|
|
352
354
|
* 每个子卡前面补 1 行分隔(上一条已经是空行时不再补),子卡自带的首尾空行会被裁掉,
|
|
353
355
|
* 避免与外卡/内卡 padding 叠出双空行。
|
|
@@ -364,7 +366,7 @@ function layoutExpandedToolCard(
|
|
|
364
366
|
toolHits = false,
|
|
365
367
|
live = false,
|
|
366
368
|
): { lines: string[]; hits: Array<{ child: any; start: number; end: number }> } {
|
|
367
|
-
const slot =
|
|
369
|
+
const slot = config.expandedCardBackground;
|
|
368
370
|
const toolBgAnsi = darkenBgAnsi(theme, slot);
|
|
369
371
|
/** live 每行前面的标记宽度(首行 `↳ `,其余对齐空格)。 */
|
|
370
372
|
const markerWidth = live ? 2 : 0;
|
|
@@ -471,6 +473,8 @@ function compactRoundCard(
|
|
|
471
473
|
| {
|
|
472
474
|
width: number;
|
|
473
475
|
theme: unknown;
|
|
476
|
+
/** 背景槽位也是面板行的输入:改配置后不能复用旧底色的整卡。 */
|
|
477
|
+
bgSlot: string;
|
|
474
478
|
paints: unknown[];
|
|
475
479
|
lines: string[];
|
|
476
480
|
hits: Array<{ child: any; start: number; end: number }>;
|
|
@@ -478,6 +482,7 @@ function compactRoundCard(
|
|
|
478
482
|
| undefined;
|
|
479
483
|
const layout = (width: number) => {
|
|
480
484
|
const theme = themeOf();
|
|
485
|
+
const bgSlot = config.expandedCardBackground;
|
|
481
486
|
const innerWidth = contentWidth(width);
|
|
482
487
|
const paints = children.map((child) => {
|
|
483
488
|
const lines = child.render?.(innerWidth);
|
|
@@ -487,6 +492,7 @@ function compactRoundCard(
|
|
|
487
492
|
paint &&
|
|
488
493
|
paint.width === width &&
|
|
489
494
|
paint.theme === theme &&
|
|
495
|
+
paint.bgSlot === bgSlot &&
|
|
490
496
|
paint.paints.length === paints.length &&
|
|
491
497
|
paint.paints.every((item, index) => item === paints[index])
|
|
492
498
|
) {
|
|
@@ -496,6 +502,7 @@ function compactRoundCard(
|
|
|
496
502
|
paint = {
|
|
497
503
|
width,
|
|
498
504
|
theme,
|
|
505
|
+
bgSlot,
|
|
499
506
|
paints,
|
|
500
507
|
lines: laid.lines,
|
|
501
508
|
hits: laid.hits,
|
|
@@ -697,7 +704,7 @@ function compactEditWriteLine(
|
|
|
697
704
|
const iconPart = `${options.flushLeft ? "" : " "}${theme.fg(iconColor, icon)} `;
|
|
698
705
|
const namePart = theme.fg("toolTitle", name);
|
|
699
706
|
const hintText =
|
|
700
|
-
options.hint !== false && component.expanded !== true ? `
|
|
707
|
+
options.hint !== false && component.expanded !== true ? ` · ${showMoreHintText()}` : "";
|
|
701
708
|
const fixedWidth =
|
|
702
709
|
visibleWidth(iconPart) +
|
|
703
710
|
visibleWidth(namePart) +
|
|
@@ -719,6 +726,8 @@ const compactRichDiffCache = new WeakMap<
|
|
|
719
726
|
type CompactEditPaintHit = {
|
|
720
727
|
width: number;
|
|
721
728
|
theme: unknown;
|
|
729
|
+
/** 展开卡的背景槽位同样参与命中判断。 */
|
|
730
|
+
bgSlot: string;
|
|
722
731
|
expanded: boolean;
|
|
723
732
|
result: unknown;
|
|
724
733
|
isPartial: boolean;
|
|
@@ -756,6 +765,7 @@ function compactEditWriteLines(
|
|
|
756
765
|
hit &&
|
|
757
766
|
hit.width === width &&
|
|
758
767
|
hit.theme === theme &&
|
|
768
|
+
hit.bgSlot === config.expandedCardBackground &&
|
|
759
769
|
hit.expanded === expanded &&
|
|
760
770
|
hit.result === component.result &&
|
|
761
771
|
hit.isPartial === isPartial &&
|
|
@@ -771,6 +781,7 @@ function compactEditWriteLines(
|
|
|
771
781
|
compactEditPaintCache.set(component, {
|
|
772
782
|
width,
|
|
773
783
|
theme,
|
|
784
|
+
bgSlot: config.expandedCardBackground,
|
|
774
785
|
expanded,
|
|
775
786
|
result: component.result,
|
|
776
787
|
isPartial,
|
|
@@ -901,7 +912,7 @@ function compactAssistantLineComponent(
|
|
|
901
912
|
const pad = Math.max(0, options.pad ?? (Number(self.outputPad) || 0));
|
|
902
913
|
const available = Math.max(0, width - pad);
|
|
903
914
|
const hint = options.hint !== false;
|
|
904
|
-
const hintText = hint ? `
|
|
915
|
+
const hintText = hint ? ` · ${showMoreHintText()}` : "";
|
|
905
916
|
const summaryWidth = Math.max(0, available - visibleWidth(hintText));
|
|
906
917
|
const resolved = typeof summary === "function" ? summary() : summary;
|
|
907
918
|
const runningActive = resolved.startsWith("Running...");
|
|
@@ -975,7 +986,7 @@ function compactStopStatusLine(status: string, pad = 0): any {
|
|
|
975
986
|
|
|
976
987
|
/**
|
|
977
988
|
* 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
|
|
978
|
-
* 提示有两处来源——我们的 showMoreHintText(`… +N more lines
|
|
989
|
+
* 提示有两处来源——我们的 showMoreHintText(`… +N more lines · click to show more`)与
|
|
979
990
|
* pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
|
|
980
991
|
* 只去掉动作词,`(N more lines)` 这类计数保留。
|
|
981
992
|
*/
|
|
@@ -987,7 +998,8 @@ const SLOT_HINT_PATTERNS: RegExp[] = [
|
|
|
987
998
|
// 只去掉动作词,收尾括号与计数保留。
|
|
988
999
|
new RegExp(String.raw`,\s*[^()]*?${EXPAND_ACTION}(?=\))`),
|
|
989
1000
|
// 直接挂在行尾:整段去掉。
|
|
990
|
-
|
|
1001
|
+
// 尾部分隔点:ccstyle 统一 `·`,同时兼容旧帧与 pi 原生的 `•`
|
|
1002
|
+
new RegExp(String.raw`\s*(?:[•·]\s*)?${EXPAND_ACTION}\s*$`),
|
|
991
1003
|
];
|
|
992
1004
|
|
|
993
1005
|
function stripSlotHint(line: string): string {
|
|
@@ -1167,6 +1179,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1167
1179
|
stopRoundTick();
|
|
1168
1180
|
return;
|
|
1169
1181
|
}
|
|
1182
|
+
settleStrandedRounds();
|
|
1170
1183
|
try {
|
|
1171
1184
|
// 非 force:保留 fullscreen 布局缓存和差分绘制。
|
|
1172
1185
|
uiRef?.requestRender?.();
|
|
@@ -1320,19 +1333,56 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1320
1333
|
return found;
|
|
1321
1334
|
};
|
|
1322
1335
|
|
|
1336
|
+
/** 组件在树里的直属父容器。TUI 切换/reparent 后缓存的 container 会过期。 */
|
|
1337
|
+
const containerOfHost = (host: any): any => {
|
|
1338
|
+
let found: any;
|
|
1339
|
+
walkComponentTree(getToolMouseTui(), (value: any) => {
|
|
1340
|
+
if (found) return false;
|
|
1341
|
+
if (Array.isArray(value?.children) && value.children.includes(host)) {
|
|
1342
|
+
found = value;
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
return undefined;
|
|
1346
|
+
});
|
|
1347
|
+
return found;
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
/** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
|
|
1351
|
+
const tailHostContainer = (tail: MountedRoundTail): any =>
|
|
1352
|
+
containerOfHost(tail.host) ??
|
|
1353
|
+
(tail.container?.children?.includes?.(tail.host) === true ? tail.container : undefined);
|
|
1354
|
+
|
|
1355
|
+
/** 摘尾行:按实际所在容器摘,避免缓存过期时摘了个空、行留在树上。 */
|
|
1356
|
+
const removeTailHost = (tail: MountedRoundTail): void => {
|
|
1357
|
+
tailHostContainer(tail)?.removeChild?.(tail.host);
|
|
1358
|
+
};
|
|
1359
|
+
|
|
1323
1360
|
const ensureTailHost = (round: CompactRound): MountedRoundTail => {
|
|
1324
1361
|
if (round.tail) return round.tail;
|
|
1325
1362
|
// 同一 anchor 上残留的旧回合尾行(reset/重组后建新回合)先摘除,避免双摘要。
|
|
1326
1363
|
const stale = tailByAnchor.get(round.anchor);
|
|
1327
1364
|
if (stale) {
|
|
1328
|
-
|
|
1365
|
+
removeTailHost(stale);
|
|
1329
1366
|
mountedTails.delete(stale);
|
|
1330
1367
|
tailByAnchor.delete(round.anchor);
|
|
1331
1368
|
}
|
|
1332
1369
|
const tail: MountedRoundTail = { host: undefined, container: undefined, parts: [], round };
|
|
1333
1370
|
const host: any = {
|
|
1334
1371
|
outputPad: Number(round.anchor?.outputPad) || 0,
|
|
1372
|
+
children: [],
|
|
1335
1373
|
render: (width: number) => tail.parts.flatMap((part: any) => part?.render?.(width) ?? []),
|
|
1374
|
+
childAtRow(localRow: number, width: number) {
|
|
1375
|
+
let offset = 0;
|
|
1376
|
+
for (const part of tail.parts) {
|
|
1377
|
+
const lines = part?.render?.(width);
|
|
1378
|
+
const count = Array.isArray(lines) ? lines.length : 0;
|
|
1379
|
+
if (localRow < offset + count) {
|
|
1380
|
+
return part?.childAtRow?.(localRow - offset, width) ?? null;
|
|
1381
|
+
}
|
|
1382
|
+
offset += count;
|
|
1383
|
+
}
|
|
1384
|
+
return null;
|
|
1385
|
+
},
|
|
1336
1386
|
invalidate: () => {
|
|
1337
1387
|
for (const part of tail.parts) part?.invalidate?.();
|
|
1338
1388
|
},
|
|
@@ -1348,6 +1398,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1348
1398
|
// 点击/悬停归属与 anchor 同一展开入口;toggle 每次 renderRound 重建,按值转发。
|
|
1349
1399
|
// 经 tail.round 动态解引用:重建过户时 anchor 可能换成别的组件。
|
|
1350
1400
|
ensureAssistantSetExpanded(host);
|
|
1401
|
+
Object.defineProperty(host, "roundAnchor", {
|
|
1402
|
+
configurable: true,
|
|
1403
|
+
get: () => tail.round?.anchor,
|
|
1404
|
+
});
|
|
1351
1405
|
host[ASSISTANT_TOGGLE_ROUND_KEY] = (expanded: boolean) =>
|
|
1352
1406
|
tail.round?.anchor?.[ASSISTANT_TOGGLE_ROUND_KEY]?.(expanded);
|
|
1353
1407
|
tail.host = host;
|
|
@@ -1410,7 +1464,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1410
1464
|
const tail = round.tail;
|
|
1411
1465
|
if (!tail) return;
|
|
1412
1466
|
round.tail = undefined;
|
|
1413
|
-
|
|
1467
|
+
removeTailHost(tail);
|
|
1414
1468
|
mountedTails.delete(tail);
|
|
1415
1469
|
if (tailByAnchor.get(round.anchor) === tail) tailByAnchor.delete(round.anchor);
|
|
1416
1470
|
};
|
|
@@ -1442,7 +1496,11 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1442
1496
|
const stale = tail.round as CompactRound | undefined;
|
|
1443
1497
|
if (
|
|
1444
1498
|
stale &&
|
|
1445
|
-
|
|
1499
|
+
// 先认组件:重放时消息对象未必是同一个(会话重读/重建),
|
|
1500
|
+
// 只认消息身份会漏掉本该接回的回合,摘要行就此掉队。
|
|
1501
|
+
(stale.anchor === round.anchor ||
|
|
1502
|
+
stale.messages.has(round.anchor) ||
|
|
1503
|
+
stale.detachedMessages.includes(message))
|
|
1446
1504
|
) {
|
|
1447
1505
|
prior = stale;
|
|
1448
1506
|
break;
|
|
@@ -1498,6 +1556,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1498
1556
|
}
|
|
1499
1557
|
const tail = ensureTailHost(round);
|
|
1500
1558
|
tail.parts = makeParts(tail.host);
|
|
1559
|
+
tail.host.children = tail.parts;
|
|
1501
1560
|
syncTailPosition(round);
|
|
1502
1561
|
};
|
|
1503
1562
|
|
|
@@ -1650,8 +1709,10 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1650
1709
|
}
|
|
1651
1710
|
|
|
1652
1711
|
if (round.anchor.expanded === true) {
|
|
1653
|
-
//
|
|
1654
|
-
|
|
1712
|
+
// 展开内容留在摘要行那个位置:回合最后一个成员(含 write/edit)之后。
|
|
1713
|
+
// 放进 anchor 内部会跳到回合起点,盖到中间的 write/edit 上面。
|
|
1714
|
+
// anchor 未挂进 transcript 时没有这个位置,退回 anchor 内部。
|
|
1715
|
+
const mounted = Boolean(tailContainerOf(round.anchor));
|
|
1655
1716
|
const toolsById = new Map<string, any>();
|
|
1656
1717
|
for (const tool of trackedToolComponents) {
|
|
1657
1718
|
if (typeof tool?.toolCallId === "string") toolsById.set(tool.toolCallId, tool);
|
|
@@ -1761,7 +1822,20 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1761
1822
|
!explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
|
|
1762
1823
|
);
|
|
1763
1824
|
flushPanel();
|
|
1764
|
-
|
|
1825
|
+
if (mounted) {
|
|
1826
|
+
// 正文留在各自的 assistant 消息上,面板挂回摘要行原位。
|
|
1827
|
+
const outside: any[] = [];
|
|
1828
|
+
const panel: any[] = [];
|
|
1829
|
+
for (const child of anchorChildren) {
|
|
1830
|
+
(typeof child?.childAtRow === "function" ? panel : outside).push(child);
|
|
1831
|
+
}
|
|
1832
|
+
for (const child of outside) round.anchor.contentContainer.addChild(child);
|
|
1833
|
+
if (panel.length > 0) mountRoundTail(round, () => panel);
|
|
1834
|
+
else unmountRoundTail(round);
|
|
1835
|
+
} else {
|
|
1836
|
+
unmountRoundTail(round);
|
|
1837
|
+
for (const child of anchorChildren) round.anchor.contentContainer.addChild(child);
|
|
1838
|
+
}
|
|
1765
1839
|
// 展开卡内工具会显示 error,外层仍挂 abort/length,避免只藏在折叠工具里。
|
|
1766
1840
|
appendStopStatus(round.anchor, stopStatus);
|
|
1767
1841
|
return;
|
|
@@ -1815,12 +1889,14 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1815
1889
|
// 一致;同 anchor 重建回合时 ensureTailHost 去重摘除),非 compact 摘掉,
|
|
1816
1890
|
// 别把紧凑摘要带进 default 渲染。
|
|
1817
1891
|
for (const tail of [...mountedTails]) {
|
|
1818
|
-
|
|
1892
|
+
const container = tailHostContainer(tail);
|
|
1893
|
+
if (!container) {
|
|
1819
1894
|
mountedTails.delete(tail);
|
|
1820
1895
|
continue;
|
|
1821
1896
|
}
|
|
1897
|
+
tail.container = container;
|
|
1822
1898
|
if (config.mode === "compact") continue;
|
|
1823
|
-
|
|
1899
|
+
container.removeChild(tail.host);
|
|
1824
1900
|
mountedTails.delete(tail);
|
|
1825
1901
|
if (tail.round?.tail === tail) tail.round.tail = undefined;
|
|
1826
1902
|
if (tail.round?.anchor && tailByAnchor.get(tail.round.anchor) === tail) {
|
|
@@ -1831,6 +1907,19 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1831
1907
|
stopRoundTick();
|
|
1832
1908
|
};
|
|
1833
1909
|
|
|
1910
|
+
/**
|
|
1911
|
+
* 兜底:resetRounds 会丢掉回合索引但保留已挂的尾行,重放没能接回的回合会一直
|
|
1912
|
+
* active——尾行永远显示 Running...(时长还在涨)却不再计入新工具。既不是活动
|
|
1913
|
+
* 回合也不在收尾静默期的活回合就地收尾,不让它永远挂在屏幕上。
|
|
1914
|
+
*/
|
|
1915
|
+
const settleStrandedRounds = (): void => {
|
|
1916
|
+
for (const tail of [...mountedTails]) {
|
|
1917
|
+
const round = tail.round as CompactRound | undefined;
|
|
1918
|
+
if (!round?.active || round === activeRound || pendingFoldRounds.has(round)) continue;
|
|
1919
|
+
endRound(round, true);
|
|
1920
|
+
}
|
|
1921
|
+
};
|
|
1922
|
+
|
|
1834
1923
|
patch.assistantInstalled = function (this: any, message: any, isStreaming?: boolean) {
|
|
1835
1924
|
const self = this as any;
|
|
1836
1925
|
// 同 compact-thinking:isStreaming 丢失 → mermaid 流式误渲染来回闪。
|