pi-cc-extensions 1.0.10 → 1.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/extensions/config/config.ts +37 -0
- package/extensions/config/panel.ts +13 -9
- package/extensions/feature/context.ts +22 -5
- package/extensions/renderer/compact-mode.ts +44 -14
- package/extensions/renderer/default-mode.ts +2 -2
- package/extensions/renderer/index.ts +4 -3
- package/extensions/renderer/tool/diff/diff-highlight.ts +41 -1
- package/extensions/renderer/tool/diff/write-execution.ts +42 -25
- package/extensions/renderer/tool/grouping.ts +25 -6
- package/extensions/utils/ansi-text.ts +50 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -46,7 +46,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
|
|
|
46
46
|
{
|
|
47
47
|
// style
|
|
48
48
|
"mode": "on", // on / compact / off
|
|
49
|
-
"excludeRenderers": [], // tools keeping the native renderer; Agent always keeps its dedicated renderer
|
|
49
|
+
"excludeRenderers": [], // tools keeping the native renderer, matched case-insensitively; Agent always keeps its dedicated renderer
|
|
50
50
|
|
|
51
51
|
// features
|
|
52
52
|
"enableSessionReference": true, // @ session references
|
|
@@ -91,7 +91,7 @@ Renderer snapshots for `on` / `compact` modes: [default](./docs/tool-render-exam
|
|
|
91
91
|
"footerGitStatsMode": "working", // working (default): uncommitted; branch: cumulative net changes
|
|
92
92
|
"footerHiddenKeys": [], // hidden chip keys
|
|
93
93
|
"footerLine1Keys": ["model", "context", "cache", "rate", "cost", "pi-usage"], // chip order per line; builtins and plugins share one layout
|
|
94
|
-
"footerLine2Keys": ["cwd", "git"],
|
|
94
|
+
"footerLine2Keys": ["cwd", "git"], // cwd: directory and session; git: branch and changes. Either can move to any line
|
|
95
95
|
"footerLine3Keys": [], // painted only when a chip is visible
|
|
96
96
|
}
|
|
97
97
|
```
|
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
46
46
|
{
|
|
47
47
|
// style
|
|
48
48
|
"mode": "on", // on / compact / off
|
|
49
|
-
"excludeRenderers": [], //
|
|
49
|
+
"excludeRenderers": [], // 走原生渲染的工具名,匹配忽略大小写;Agent 始终保留专用渲染器
|
|
50
50
|
|
|
51
51
|
// features
|
|
52
52
|
"enableSessionReference": true, // @ session 引用
|
|
@@ -91,7 +91,7 @@ pi install git:github.com/minuque/pi-cc-extensions
|
|
|
91
91
|
"footerGitStatsMode": "working", // working(默认):未提交改动;branch:分支累计净改动
|
|
92
92
|
"footerHiddenKeys": [], // 隐藏的芯片 key
|
|
93
93
|
"footerLine1Keys": ["model", "context", "cache", "rate", "cost", "pi-usage"], // 每行芯片顺序;内置项与插件同一套排布
|
|
94
|
-
"footerLine2Keys": ["cwd", "git"],
|
|
94
|
+
"footerLine2Keys": ["cwd", "git"], // cwd:目录和会话名;git:分支和改动。可换到任意行
|
|
95
95
|
"footerLine3Keys": [] // 有可见芯片才占行
|
|
96
96
|
}
|
|
97
97
|
```
|
|
@@ -340,6 +340,43 @@ export function formatExcludeRenderers(names: readonly string[]): string {
|
|
|
340
340
|
return names.length === 0 ? "none" : names.join(", ");
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/** excludeRenderers 与运行时工具名按大小写不敏感匹配(如候选 "agent" 对上 pi-subagents 的 "Agent")。 */
|
|
344
|
+
export function isExcludedRenderer(
|
|
345
|
+
toolName: string,
|
|
346
|
+
excludeRenderers: readonly string[] = config.excludeRenderers,
|
|
347
|
+
): boolean {
|
|
348
|
+
const key = toolName.toLowerCase();
|
|
349
|
+
return excludeRenderers.some((name) => name.toLowerCase() === key);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** 写入名单时去掉同名异大小写项,再按 id 写入,避免 agent/Agent 并存。 */
|
|
353
|
+
export function withExcludeRendererToggle(
|
|
354
|
+
excludeRenderers: readonly string[],
|
|
355
|
+
id: string,
|
|
356
|
+
exclude: boolean,
|
|
357
|
+
): string[] {
|
|
358
|
+
const key = id.toLowerCase();
|
|
359
|
+
const next = excludeRenderers.filter((name) => name.toLowerCase() !== key);
|
|
360
|
+
if (exclude) next.push(id);
|
|
361
|
+
return [...new Set(next)].sort((a, b) => a.localeCompare(b));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 面板候选:内置候选与用户名单按大小写不敏感合并,保留先出现的写法(候选在前,仍是 agent)。 */
|
|
365
|
+
export function mergeExcludeRendererCandidates(
|
|
366
|
+
names: readonly string[],
|
|
367
|
+
candidates: readonly string[] = EXCLUDE_RENDERER_CANDIDATES,
|
|
368
|
+
): string[] {
|
|
369
|
+
const seen = new Set<string>();
|
|
370
|
+
const merged: string[] = [];
|
|
371
|
+
for (const name of [...candidates, ...names]) {
|
|
372
|
+
const key = name.toLowerCase();
|
|
373
|
+
if (seen.has(key)) continue;
|
|
374
|
+
seen.add(key);
|
|
375
|
+
merged.push(name);
|
|
376
|
+
}
|
|
377
|
+
return merged.sort((a, b) => a.localeCompare(b));
|
|
378
|
+
}
|
|
379
|
+
|
|
343
380
|
export function formatConfigStatus(source: Config = config): string {
|
|
344
381
|
return [
|
|
345
382
|
`mode=${source.mode}`,
|
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
DIFF_INDICATOR_MODES,
|
|
39
39
|
DIFF_SPLIT_MIN_WIDTH_VALUES,
|
|
40
40
|
DIFF_VIEW_MODES,
|
|
41
|
-
EXCLUDE_RENDERER_CANDIDATES,
|
|
42
41
|
EXPANDED_INPUT_MAX_LINES_VALUES,
|
|
43
42
|
EXPANDED_OUTPUT_MAX_LINES_VALUES,
|
|
44
43
|
EXPANDED_PREVIEW_MAX_LINES_VALUES,
|
|
@@ -62,6 +61,9 @@ import {
|
|
|
62
61
|
type FooterGitStatsMode,
|
|
63
62
|
type FooterTokenRateMode,
|
|
64
63
|
type ScrollButtonMode,
|
|
64
|
+
isExcludedRenderer,
|
|
65
|
+
mergeExcludeRendererCandidates,
|
|
66
|
+
withExcludeRendererToggle,
|
|
65
67
|
} from "./config.ts";
|
|
66
68
|
|
|
67
69
|
/** renderer 注入的渲染副作用,面板自身不触碰渲染状态。 */
|
|
@@ -268,14 +270,13 @@ function buildExcludeRenderersSubmenu(
|
|
|
268
270
|
invalidate: () => void;
|
|
269
271
|
handleInput: (data: string) => void;
|
|
270
272
|
} {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
].sort((a, b) => a.localeCompare(b));
|
|
273
|
+
// 候选与名单按大小写不敏感合并:存过 Agent 时不再多出一行 agent。
|
|
274
|
+
const candidates = mergeExcludeRendererCandidates(config.excludeRenderers);
|
|
274
275
|
const items = candidates.map((name) => ({
|
|
275
276
|
id: name,
|
|
276
277
|
label: name,
|
|
277
278
|
description: `Use Pi native renderer for ${name} instead of Claude Code styling.`,
|
|
278
|
-
currentValue: config.excludeRenderers
|
|
279
|
+
currentValue: isExcludedRenderer(name, config.excludeRenderers) ? "exclude" : "style",
|
|
279
280
|
values: ["style", "exclude"],
|
|
280
281
|
}));
|
|
281
282
|
const list = new SettingsList(
|
|
@@ -283,10 +284,13 @@ function buildExcludeRenderersSubmenu(
|
|
|
283
284
|
Math.min(8, Math.max(4, items.length)),
|
|
284
285
|
getSettingsListTheme(),
|
|
285
286
|
(id: string, value: string) => {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
287
|
+
updateConfig({
|
|
288
|
+
excludeRenderers: withExcludeRendererToggle(
|
|
289
|
+
config.excludeRenderers,
|
|
290
|
+
id,
|
|
291
|
+
value === "exclude",
|
|
292
|
+
),
|
|
293
|
+
});
|
|
290
294
|
onLiveChange();
|
|
291
295
|
},
|
|
292
296
|
() => onClose(),
|
|
@@ -887,27 +887,44 @@ export default function contextUsageExtension(pi: ExtensionAPI) {
|
|
|
887
887
|
return theme.style("█".repeat(Math.max(0, cells)), { fg: part.color });
|
|
888
888
|
})
|
|
889
889
|
.join("");
|
|
890
|
+
const amountCells = allParts.map((part) => {
|
|
891
|
+
const pct = contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
|
|
892
|
+
const tokens = formatTokens(part.tokens).padStart(7);
|
|
893
|
+
const percent = `${pct.toFixed(1).padStart(5)}%`;
|
|
894
|
+
return { tokens, percent, plain: `${tokens} ${percent}` };
|
|
895
|
+
});
|
|
896
|
+
// 数字列整体贴右:label 最多占到「行宽 - 数字列宽 - 前缀 - 边距」为止。
|
|
897
|
+
const amountWidth = Math.max(
|
|
898
|
+
...amountCells.map((cell) => visibleWidth(cell.plain)),
|
|
899
|
+
);
|
|
890
900
|
const labelWidth = Math.min(
|
|
891
901
|
24,
|
|
892
902
|
Math.max(...allParts.map((part) => part.label.length)),
|
|
903
|
+
Math.max(1, inner - amountWidth - 5),
|
|
893
904
|
);
|
|
894
905
|
const selectedLabel = visiblePreviews[selectedPreviewIndex]?.label;
|
|
895
|
-
const partRows = allParts.map((part) => {
|
|
896
|
-
const pct = contextWindow > 0 ? (part.tokens / contextWindow) * 100 : 0;
|
|
906
|
+
const partRows = allParts.map((part, index) => {
|
|
897
907
|
const swatch = theme.style("■", { fg: part.color });
|
|
898
908
|
const label = part.label.padEnd(labelWidth);
|
|
899
|
-
const amount =
|
|
909
|
+
const amount = amountCells[index];
|
|
900
910
|
const selected = part.label === selectedLabel;
|
|
901
911
|
const hoverLabel = visiblePreviews.find((p) => p.key === hoveredKey)?.label;
|
|
902
912
|
const prefix = selected ? "› " : " ";
|
|
903
|
-
const
|
|
913
|
+
const left = `${prefix}${swatch} ${label}`;
|
|
914
|
+
const gap = Math.max(
|
|
915
|
+
1,
|
|
916
|
+
inner - 1 - visibleWidth(left) - visibleWidth(amount?.plain ?? ""),
|
|
917
|
+
);
|
|
918
|
+
// token 数字用次级色,百分比保持正文色以保留可读的主指标。
|
|
919
|
+
const right = `${theme.fg("dim", amount?.tokens ?? "")} ${amount?.percent ?? ""}`;
|
|
920
|
+
const row = padLine(`${left}${" ".repeat(gap)}${right}`, inner);
|
|
904
921
|
if (selected) return theme.bg("selectedBg", row);
|
|
905
922
|
return part.label === hoverLabel ? theme.bg("customMessageBg", row) : row;
|
|
906
923
|
});
|
|
907
924
|
const border = (text: string) => theme.fg("border", text);
|
|
908
925
|
const lines = [
|
|
909
926
|
border(`╭${"─".repeat(inner)}╮`),
|
|
910
|
-
`${border("│")}${padLine(` ${title} ${theme.fg("
|
|
927
|
+
`${border("│")}${padLine(` ${title} ${theme.fg("dim", subtitle)}`, inner - escWidth)}${theme.fg(escHovered ? "text" : "muted", "[esc]")}${border("│")}`,
|
|
911
928
|
`${border("├")}${border("─".repeat(inner))}${border("┤")}`,
|
|
912
929
|
`${border("│")}${padLine(` ${segments}`, inner)}${border("│")}`,
|
|
913
930
|
`${border("│")}${" ".repeat(inner)}${border("│")}`,
|
|
@@ -64,6 +64,7 @@ import { paddedBackgroundRow } from "./tool/grouping.ts";
|
|
|
64
64
|
import { styleChangeCounts } from "./tool/ccstyle-tool.ts";
|
|
65
65
|
import { formatDisplayPath } from "./tool/names.ts";
|
|
66
66
|
import {
|
|
67
|
+
contentRowRange,
|
|
67
68
|
hasVisibleText,
|
|
68
69
|
removeStyledRange,
|
|
69
70
|
stripBackgroundAnsi,
|
|
@@ -372,7 +373,7 @@ function editWriteExpandedCard(theme: any, component: any): any {
|
|
|
372
373
|
/** compact 展开面板:外卡片保持展开卡背景;展开的 thinking 另包一层更深的
|
|
373
374
|
* 内卡(自身 Box 背景与外卡同色,不区分会糊在一起),工具卡直接铺在外卡背景上。
|
|
374
375
|
* 每个子卡前面补 1 行分隔(上一条已经是空行时不再补),子卡自带的首尾空行会被裁掉,
|
|
375
|
-
* 避免与外卡/内卡 padding
|
|
376
|
+
* 避免与外卡/内卡 padding 叠出双空行(内联图片的占位行不裁,见 contentRowRange)。
|
|
376
377
|
* hits:子卡的行区间与命中目标(thinking 命中自身,工具命中 tool 组件),
|
|
377
378
|
* 供展开后点击 hint。未开启 toolHits 时不映射工具,点击仍归外层面板。
|
|
378
379
|
* live=true:live 槽位用,不铺底色也不留上下空行,首行紧跟 `↳`、其余行缩进对齐
|
|
@@ -434,14 +435,8 @@ function layoutExpandedToolCard(
|
|
|
434
435
|
const painted = paints?.[childIndex];
|
|
435
436
|
const childLines = Array.isArray(painted) ? painted : child.render(innerWidth);
|
|
436
437
|
const innerCard = !live && isThinkingPreview(child) && child.expanded === true;
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
for (let i = 0; i < childLines.length; i++) {
|
|
440
|
-
if (hasVisibleText(childLines[i])) {
|
|
441
|
-
if (first < 0) first = i;
|
|
442
|
-
last = i;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
438
|
+
// 内联图片按 placement 把范围撑开:锚点前后的空行是图片占的位置,不能当首尾空行裁。
|
|
439
|
+
const { first, last } = contentRowRange(childLines);
|
|
445
440
|
if (first < 0) {
|
|
446
441
|
if (live) continue;
|
|
447
442
|
for (const line of childLines) {
|
|
@@ -603,16 +598,20 @@ export type CompactModeHooks = {
|
|
|
603
598
|
/** 重新渲染包含指定 toolCallId 的 assistant 消息(思考收尾时刷新)。 */
|
|
604
599
|
refreshToolCallMessage(toolCallId: string | undefined): void;
|
|
605
600
|
/**
|
|
606
|
-
*
|
|
607
|
-
*
|
|
601
|
+
* 会话已空闲时收尾末尾回合。回放/切换样式/重载重建之后,末尾那条消息
|
|
602
|
+
* 后面没有消息来收口;活性只问会话,不从消息形状推断。
|
|
608
603
|
*/
|
|
609
|
-
|
|
604
|
+
settleIdleRound(): void;
|
|
605
|
+
/** agent 这轮跑完:收尾仍在 live 的末尾回合,别让它一直挂着 Running。 */
|
|
606
|
+
settleFinishedRound(): void;
|
|
610
607
|
shutdown(): void;
|
|
611
608
|
};
|
|
612
609
|
|
|
613
610
|
type CompactModeInstallDeps = {
|
|
614
611
|
query?: CompactThinkingQuery;
|
|
615
612
|
writeMetadata: WriteExecutionMetadataStore;
|
|
613
|
+
/** 会话是否空闲;测试注入用,正式路径取 ctx.isIdle。 */
|
|
614
|
+
isIdle?: () => boolean;
|
|
616
615
|
};
|
|
617
616
|
|
|
618
617
|
type CompactModePatch = {
|
|
@@ -1233,8 +1232,18 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1233
1232
|
const liveSlotToolIds = new Set<string>();
|
|
1234
1233
|
const pendingFoldRounds = new Set<CompactRound>();
|
|
1235
1234
|
let uiRef: { requestRender?: (force?: boolean) => void } | undefined;
|
|
1235
|
+
let idleRef: (() => boolean) | undefined = deps.isIdle;
|
|
1236
1236
|
let roundTickTimer: ReturnType<typeof setInterval> | undefined;
|
|
1237
1237
|
|
|
1238
|
+
/** 会话是否空闲:回合活性的事实来源,取不到就当没空闲(宁可保留 Running)。 */
|
|
1239
|
+
const sessionIdle = (): boolean => {
|
|
1240
|
+
try {
|
|
1241
|
+
return idleRef?.() === true;
|
|
1242
|
+
} catch {
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1238
1247
|
const roundWallMs = (round: CompactRound): number => {
|
|
1239
1248
|
const end = round.active ? Date.now() : (round.endedAt ?? Date.now());
|
|
1240
1249
|
return Math.max(1, end - round.startedAt);
|
|
@@ -2172,6 +2181,19 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2172
2181
|
}
|
|
2173
2182
|
};
|
|
2174
2183
|
|
|
2184
|
+
/**
|
|
2185
|
+
* 会话已空闲时收尾末尾回合。重建 pass 内仍按顺序并回合(同一 turn 的多条
|
|
2186
|
+
* 消息要合成一条摘要),只在 pass 结束时对账:会话没在跑,末尾那条就不会
|
|
2187
|
+
* 再长出内容,停在 Running 是假的。
|
|
2188
|
+
*/
|
|
2189
|
+
const settleTrailingRound = (): void => {
|
|
2190
|
+
if (!sessionIdle()) return;
|
|
2191
|
+
const round = activeRound;
|
|
2192
|
+
if (!round) return;
|
|
2193
|
+
finalizeRound(round);
|
|
2194
|
+
renderRound(round);
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2175
2197
|
patch.assistantInstalled = function (this: any, message: any, isStreaming?: boolean) {
|
|
2176
2198
|
const self = this as any;
|
|
2177
2199
|
// 同 compact-thinking:isStreaming 丢失 → mermaid 流式误渲染来回闪。
|
|
@@ -2423,6 +2445,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2423
2445
|
sync(ctx: any) {
|
|
2424
2446
|
if (!patch.active) return;
|
|
2425
2447
|
uiRef = ctx?.ui;
|
|
2448
|
+
if (typeof ctx?.isIdle === "function") idleRef = () => ctx.isIdle() === true;
|
|
2426
2449
|
resetRounds();
|
|
2427
2450
|
// 始终保持补丁在链上:mode=on/off 走 passThrough,仍写入 lastMessage
|
|
2428
2451
|
// 与 tracked 集合,这样 /ccstyle 切回 compact 时不必 /reload。
|
|
@@ -2431,6 +2454,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2431
2454
|
else hoveredAssistantComponent = undefined;
|
|
2432
2455
|
refreshTrackedComponents();
|
|
2433
2456
|
sweepOrphanTails();
|
|
2457
|
+
settleTrailingRound();
|
|
2434
2458
|
},
|
|
2435
2459
|
refresh() {
|
|
2436
2460
|
if (!patch.active) return;
|
|
@@ -2439,6 +2463,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2439
2463
|
if (config.mode !== "compact") hoveredAssistantComponent = undefined;
|
|
2440
2464
|
refreshTrackedComponents();
|
|
2441
2465
|
sweepOrphanTails();
|
|
2466
|
+
settleTrailingRound();
|
|
2442
2467
|
},
|
|
2443
2468
|
assertOwnership() {
|
|
2444
2469
|
if (!patch.active) return;
|
|
@@ -2459,11 +2484,16 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2459
2484
|
}
|
|
2460
2485
|
}
|
|
2461
2486
|
},
|
|
2462
|
-
|
|
2487
|
+
settleIdleRound() {
|
|
2488
|
+
if (!patch.active || config.mode !== "compact") return;
|
|
2489
|
+
settleTrailingRound();
|
|
2490
|
+
},
|
|
2491
|
+
settleFinishedRound() {
|
|
2463
2492
|
if (!patch.active || config.mode !== "compact") return;
|
|
2464
2493
|
const round = activeRound;
|
|
2465
2494
|
if (!round) return;
|
|
2466
|
-
|
|
2495
|
+
// 走静默期:摘要行当场翻成 Ran for,槽位卡多留一拍再收拢。
|
|
2496
|
+
endRound(round, true);
|
|
2467
2497
|
renderRound(round);
|
|
2468
2498
|
},
|
|
2469
2499
|
shutdown() {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 生命周期对齐 compact-mode:installDefaultMode → hooks.shutdown。
|
|
7
7
|
*/
|
|
8
8
|
import { ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { config, type CompactStyleMode } from "../config/config.ts";
|
|
9
|
+
import { config, isExcludedRenderer, type CompactStyleMode } from "../config/config.ts";
|
|
10
10
|
import {
|
|
11
11
|
COMPONENT_TOOL_RENDER_MODE,
|
|
12
12
|
GLOBAL_TOOL_RENDER_PATCH,
|
|
@@ -27,7 +27,7 @@ export function preservesOriginalRenderer(
|
|
|
27
27
|
builtInToolDefinition?: any,
|
|
28
28
|
excludeRenderers: readonly string[] = config.excludeRenderers,
|
|
29
29
|
): boolean {
|
|
30
|
-
if (!
|
|
30
|
+
if (!isExcludedRenderer(toolName, excludeRenderers)) return false;
|
|
31
31
|
return [extensionDefinition, builtInToolDefinition].some(
|
|
32
32
|
(definition) =>
|
|
33
33
|
definition?.renderShell === "self" ||
|
|
@@ -258,10 +258,9 @@ export default function (
|
|
|
258
258
|
syncCompactMode(ctx);
|
|
259
259
|
// compact-thinking 的 session_start 处理在本 handler 之后执行(在其之上再装
|
|
260
260
|
// 一层 updateContent);延迟再同步一次,保证 compact 补丁最终位于外层。
|
|
261
|
+
// 历史回放也在这之后跑完:sync 末尾按 ctx.isIdle() 收尾末尾回合,
|
|
262
|
+
// 它没有后续消息,不收就会停在 Running。
|
|
261
263
|
setTimeout(() => syncCompactMode(ctx), 0);
|
|
262
|
-
// 历史回放发生在 session_start 之后,延迟到回放结束再收尾最后一个回合,
|
|
263
|
-
// 否则它没有后续消息,摘要行会停在 Running。
|
|
264
|
-
setTimeout(() => compactModeHooks?.settleReplayedRound(), 0);
|
|
265
264
|
scheduleSessionRender(() => {
|
|
266
265
|
hooks.toolGrouping.refresh(getToolMouseTui());
|
|
267
266
|
// /reload、/resume 在 session_start 之前就重建了 transcript:已挂载的通知卡换上本插件的渲染器。
|
|
@@ -303,6 +302,8 @@ export default function (
|
|
|
303
302
|
|
|
304
303
|
pi.on("agent_end", async () => {
|
|
305
304
|
clearLiveToolCalls();
|
|
305
|
+
// 这轮跑完:末尾回合没有正文收口(纯工具回合)就一直挂着 Running。
|
|
306
|
+
compactModeHooks?.settleFinishedRound();
|
|
306
307
|
});
|
|
307
308
|
|
|
308
309
|
pi.on("session_shutdown", async () => {
|
|
@@ -45,11 +45,51 @@ export function createCodeLineHighlighter(
|
|
|
45
45
|
invalidate?: () => void,
|
|
46
46
|
): CodeLineHighlighter {
|
|
47
47
|
const codeEntries = entries.filter((entry): entry is DiffLineEntry => entry.kind === "line");
|
|
48
|
+
const allCode = codeEntries
|
|
49
|
+
.map((entry) => cleanCodeLine(normalizeCodeWhitespace(entry.content)))
|
|
50
|
+
.join("\n");
|
|
51
|
+
// Budget check stays on the whole visible code: over the limit nothing is highlighted.
|
|
52
|
+
// Per-segment checks would let a huge diff highlight itself one segment at a time.
|
|
53
|
+
const segmentLanguage = shouldHighlightCodeBlock(allCode) ? language : undefined;
|
|
54
|
+
const highlightByEntry = new WeakMap<DiffLineEntry, CodeLineHighlighter>();
|
|
55
|
+
let segment: DiffLineEntry[] = [];
|
|
56
|
+
|
|
57
|
+
const flushSegment = () => {
|
|
58
|
+
if (segment.length === 0) return;
|
|
59
|
+
const highlight = createContiguousCodeLineHighlighter(
|
|
60
|
+
segmentLanguage,
|
|
61
|
+
theme,
|
|
62
|
+
segment,
|
|
63
|
+
invalidate,
|
|
64
|
+
);
|
|
65
|
+
for (const entry of segment) highlightByEntry.set(entry, highlight);
|
|
66
|
+
segment = [];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Omitted source cannot carry lexer state across a diff discontinuity.
|
|
70
|
+
// Each segment is highlighted and cached on its own; the budget above still bounds one diff.
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.kind === "line") segment.push(entry);
|
|
73
|
+
else flushSegment();
|
|
74
|
+
}
|
|
75
|
+
flushSegment();
|
|
76
|
+
|
|
77
|
+
return (line, entry) => highlightByEntry.get(entry)?.(line, entry) ?? "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function createContiguousCodeLineHighlighter(
|
|
81
|
+
language: string | undefined,
|
|
82
|
+
theme: DiffTheme,
|
|
83
|
+
codeEntries: readonly DiffLineEntry[],
|
|
84
|
+
invalidate?: () => void,
|
|
85
|
+
): CodeLineHighlighter {
|
|
48
86
|
const cleanLines = codeEntries.map((entry) =>
|
|
49
87
|
cleanCodeLine(normalizeCodeWhitespace(entry.content)),
|
|
50
88
|
);
|
|
51
89
|
const code = cleanLines.join("\n");
|
|
52
|
-
|
|
90
|
+
// A segment is a subset of the visible code, so `segmentLanguage` already carries
|
|
91
|
+
// the total budget decision; only the per-segment highlight is left to decide here.
|
|
92
|
+
const shouldHighlight = language !== undefined;
|
|
53
93
|
const fallbackLines = shouldHighlight
|
|
54
94
|
? (() => {
|
|
55
95
|
try {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createWriteToolDefinition, type WriteOperations } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, isAbsolute, resolve } from "node:path";
|
|
4
3
|
|
|
5
4
|
export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
|
|
6
5
|
export const MAX_WRITE_METADATA_ENTRIES = 100;
|
|
@@ -81,33 +80,51 @@ export async function executeWriteWithMetadata(
|
|
|
81
80
|
signal: AbortSignal | undefined,
|
|
82
81
|
cwd: string,
|
|
83
82
|
): Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }> {
|
|
84
|
-
const absolutePath = isAbsolute(params.path) ? params.path : resolve(cwd, params.path);
|
|
85
83
|
store.delete(toolCallId);
|
|
84
|
+
// 覆盖层只揽写前快照:路径解析(含 Windows 下 /mnt/<drive>、/cygdrive/<drive> 转换)、
|
|
85
|
+
// 目录创建、mutation queue 与错误文案全交回 pi 官方 write,参数与官方逐字一致。
|
|
86
|
+
const definition = createWriteToolDefinition(cwd, {
|
|
87
|
+
operations: writeSnapshotOperations(store, toolCallId),
|
|
88
|
+
}) as any;
|
|
86
89
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (signal?.aborted) throw new Error("Operation aborted");
|
|
90
|
-
};
|
|
91
|
-
throwIfAborted();
|
|
92
|
-
const metadata = await capturePreviousContent(absolutePath);
|
|
93
|
-
throwIfAborted();
|
|
94
|
-
await mkdir(dirname(absolutePath), { recursive: true });
|
|
95
|
-
throwIfAborted();
|
|
96
|
-
await writeFile(absolutePath, params.content, "utf8");
|
|
97
|
-
// The file is already changed. A later abort must not report failure.
|
|
98
|
-
store.set(toolCallId, metadata);
|
|
99
|
-
return {
|
|
100
|
-
content: [
|
|
101
|
-
{
|
|
102
|
-
type: "text",
|
|
103
|
-
text: `Successfully wrote ${Buffer.byteLength(params.content, "utf8")} bytes to ${params.path}`,
|
|
104
|
-
},
|
|
105
|
-
],
|
|
106
|
-
details: undefined,
|
|
107
|
-
};
|
|
108
|
-
});
|
|
90
|
+
await definition.execute(toolCallId, params, signal, undefined, { cwd });
|
|
91
|
+
return writeSuccessResult(params);
|
|
109
92
|
} catch (error) {
|
|
93
|
+
// 文件已落盘,只是官方写后那次 abort 检查抦了错:保持「文件已改即成功」。
|
|
94
|
+
if (store.get(toolCallId) && signal?.aborted) return writeSuccessResult(params);
|
|
110
95
|
store.delete(toolCallId);
|
|
111
96
|
throw error;
|
|
112
97
|
}
|
|
113
98
|
}
|
|
99
|
+
|
|
100
|
+
/** 官方 write 的落盘操作:拿到的是官方解析后的绝对路径,快照才跟真实落盘一致。 */
|
|
101
|
+
function writeSnapshotOperations(
|
|
102
|
+
store: WriteExecutionMetadataStore,
|
|
103
|
+
toolCallId: string,
|
|
104
|
+
): WriteOperations {
|
|
105
|
+
return {
|
|
106
|
+
mkdir: (dir) => mkdir(dir, { recursive: true }).then(() => {}),
|
|
107
|
+
writeFile: async (absolutePath, content) => {
|
|
108
|
+
store.delete(toolCallId);
|
|
109
|
+
const metadata = await capturePreviousContent(absolutePath);
|
|
110
|
+
await writeFile(absolutePath, content, "utf8");
|
|
111
|
+
// 写已生效:后续 abort 不得把这次写报成失败,快照先落下。
|
|
112
|
+
store.set(toolCallId, metadata);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeSuccessResult(params: { path: string; content: string }): {
|
|
118
|
+
content: Array<{ type: "text"; text: string }>;
|
|
119
|
+
details: undefined;
|
|
120
|
+
} {
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: "text",
|
|
125
|
+
text: `Successfully wrote ${Buffer.byteLength(params.content, "utf8")} bytes to ${params.path}`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
details: undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -24,7 +24,13 @@ import {
|
|
|
24
24
|
import { getToolMouseTui } from "../mouse/scroll.ts";
|
|
25
25
|
import { mcpToolTitle } from "./mcp-title.ts";
|
|
26
26
|
import { collapseHintText, isToolTuiFullscreen, showMoreHintText } from "./names.ts";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
contentRowRange,
|
|
29
|
+
imagePlacement,
|
|
30
|
+
stripAnsi,
|
|
31
|
+
stripBackgroundAnsi,
|
|
32
|
+
stripLeadingStatusIcon,
|
|
33
|
+
} from "../../utils/ansi-text.ts";
|
|
28
34
|
import { walkComponentTree } from "../../utils/component-tree.ts";
|
|
29
35
|
import {
|
|
30
36
|
fitToolCallSummary,
|
|
@@ -193,8 +199,22 @@ function requestAnimationRender(patch: Patch): void {
|
|
|
193
199
|
getToolMouseTui()?.requestRender?.();
|
|
194
200
|
}
|
|
195
201
|
|
|
202
|
+
/** 丢掉空白行;内联图片锚点行与它占用的空行要留着,否则终端里图片盖住后续行。 */
|
|
196
203
|
function visibleLines(lines: string[]): string[] {
|
|
197
|
-
|
|
204
|
+
const keep = new Set<number>();
|
|
205
|
+
for (let index = 0; index < lines.length; index++) {
|
|
206
|
+
const placement = imagePlacement(lines[index]);
|
|
207
|
+
if (!placement) {
|
|
208
|
+
if (stripAnsi(lines[index]).trim()) keep.add(index);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const start = placement.grows === "up" ? index - placement.rows + 1 : index;
|
|
212
|
+
const end = placement.grows === "down" ? index + placement.rows - 1 : index;
|
|
213
|
+
for (let row = Math.max(0, start); row <= Math.min(lines.length - 1, end); row++) {
|
|
214
|
+
keep.add(row);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return lines.filter((_line, index) => keep.has(index));
|
|
198
218
|
}
|
|
199
219
|
|
|
200
220
|
function stripLeadingSpaces(line: string, count: number): string {
|
|
@@ -388,12 +408,11 @@ function collapsedGroupLine(
|
|
|
388
408
|
return truncateToWidth(`${prefix}${item.summary(room, theme)}`, rowWidth, "");
|
|
389
409
|
}
|
|
390
410
|
|
|
391
|
-
/**
|
|
411
|
+
/** 末项结尾的尾随空行不计入卡高(内联图片的占位行除外,见 contentRowRange)。 */
|
|
392
412
|
function panelRows(rows: string[], last: boolean): string[] {
|
|
393
413
|
if (!last) return rows;
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
return end === rows.length ? rows : rows.slice(0, end);
|
|
414
|
+
const end = Math.max(1, contentRowRange(rows).last + 1);
|
|
415
|
+
return end >= rows.length ? rows : rows.slice(0, end);
|
|
397
416
|
}
|
|
398
417
|
|
|
399
418
|
type GroupHeaderCounts = { pending: number; success: number; error: number };
|
|
@@ -33,6 +33,56 @@ export function hasVisibleText(line: string): boolean {
|
|
|
33
33
|
return line.replace(CSI_SEQUENCE_RE, "").replace(OSC_SEQUENCE_STRICT_RE, "").trim().length > 0;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
const KITTY_IMAGE_PREFIX = "\x1b_G";
|
|
37
|
+
const ITERM2_IMAGE_PREFIX = "\x1b]1337;File=";
|
|
38
|
+
|
|
39
|
+
/** 行内是否带内联图片序列(kitty APC 或 iTerm2 OSC 1337)。 */
|
|
40
|
+
export function isImageLine(line: string): boolean {
|
|
41
|
+
return line.includes(KITTY_IMAGE_PREFIX) || line.includes(ITERM2_IMAGE_PREFIX);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 内联图片在终端里占用的行数,以及它相对锚点行的铺开方向。
|
|
46
|
+
* kitty 的锚点在首行、向下铺(`r=N`);iTerm2 的锚点在末行、先上移再画(`\x1b[NA`)。
|
|
47
|
+
*/
|
|
48
|
+
export function imagePlacement(line: string): { rows: number; grows: "down" | "up" } | undefined {
|
|
49
|
+
const kittyControls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
|
50
|
+
if (kittyControls !== undefined) {
|
|
51
|
+
const rows = Number(/(?:^|,)r=(\d+)/.exec(kittyControls)?.[1]);
|
|
52
|
+
return { rows: rows >= 1 ? rows : 1, grows: "down" };
|
|
53
|
+
}
|
|
54
|
+
if (line.includes(ITERM2_IMAGE_PREFIX)) {
|
|
55
|
+
const up = Number(/\x1b\[(\d+)A/.exec(line)?.[1]);
|
|
56
|
+
return { rows: up >= 1 ? up + 1 : 1, grows: "up" };
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 行区里必须保留的范围(首行/末行下标;没有内容时 first 为 -1)。
|
|
63
|
+
* 内联图片的行要按 placement 整体算进来:锚点前后那些空行是图片自己占的位置,
|
|
64
|
+
* 当首尾空行裁掉后终端里图片照样铺开,后面画的内容会被盖住。
|
|
65
|
+
*/
|
|
66
|
+
export function contentRowRange(rows: string[]): { first: number; last: number } {
|
|
67
|
+
let first = -1;
|
|
68
|
+
let last = -1;
|
|
69
|
+
const keep = (start: number, end: number) => {
|
|
70
|
+
const boundedStart = Math.max(0, start);
|
|
71
|
+
first = first < 0 ? boundedStart : Math.min(first, boundedStart);
|
|
72
|
+
last = Math.max(last, Math.min(rows.length - 1, end));
|
|
73
|
+
};
|
|
74
|
+
for (let index = 0; index < rows.length; index++) {
|
|
75
|
+
const placement = imagePlacement(rows[index]);
|
|
76
|
+
if (placement) {
|
|
77
|
+
if (placement.grows === "down") keep(index, index + placement.rows - 1);
|
|
78
|
+
else keep(index - placement.rows + 1, index);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (hasVisibleText(rows[index])) keep(index, index);
|
|
82
|
+
}
|
|
83
|
+
return { first, last };
|
|
84
|
+
}
|
|
85
|
+
|
|
36
86
|
/** 剥离背景色 ANSI(用于重新铺背景行)。 */
|
|
37
87
|
export function stripBackgroundAnsi(line: string): string {
|
|
38
88
|
return line.replace(/\x1b\[(?:4[0-9]|10[0-7]|48(?:(?:;|:)[0-9]+)+|49)m/g, "");
|