@herbertgao/pi-extensions 2026.10.4 → 2026.10.5
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.md +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +12 -7
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +12 -7
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +67 -16
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +240 -48
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer-currency.ts +29 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/footer.ts +92 -59
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/git-stats.ts +162 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/token-rate.ts +192 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +51 -65
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +113 -25
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +160 -24
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +30 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +26 -24
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/sticky-prompt.ts +402 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/ask-user-question.ts +173 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -8
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-edit-render.ts +64 -11
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-presentation.ts +41 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +5 -1
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-write-render.ts +35 -9
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +101 -31
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/names.ts +3 -3
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +106 -15
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/format.ts +9 -0
- package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/patch-keys.ts +5 -0
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +4 -4
- package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -1
- package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +1 -1
- package/package.json +2 -2
package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts
CHANGED
|
@@ -5,43 +5,28 @@ import {
|
|
|
5
5
|
getToolMouseTui,
|
|
6
6
|
isFullscreenAtBottom,
|
|
7
7
|
} from "../../renderer/mouse/scroll.ts";
|
|
8
|
-
import { formatDuration } from "../../utils/format.ts";
|
|
8
|
+
import { formatDuration, formatTokenCount } from "../../utils/format.ts";
|
|
9
|
+
import {
|
|
10
|
+
resetTokenRate,
|
|
11
|
+
carryTokenRate,
|
|
12
|
+
pauseTokenRate,
|
|
13
|
+
resumeTokenRate,
|
|
14
|
+
markFirstToken,
|
|
15
|
+
sampleTokenDelta,
|
|
16
|
+
recordProviderUsage,
|
|
17
|
+
startMessageSegment,
|
|
18
|
+
totalTokens,
|
|
19
|
+
} from "./token-rate.ts";
|
|
9
20
|
|
|
10
21
|
const REFRESH_INTERVAL_MS = 1_000;
|
|
11
22
|
/** Elapsed time is only shown once the turn has run this long. */
|
|
12
23
|
const SHOW_TIMER_AFTER_MS = 3_000;
|
|
13
24
|
|
|
14
|
-
function formatCount(value: number): string {
|
|
15
|
-
return new Intl.NumberFormat("en-US").format(value);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
type ContentBlock = {
|
|
19
|
-
type?: unknown;
|
|
20
|
-
text?: unknown;
|
|
21
|
-
thinkingSignature?: { body?: unknown };
|
|
22
|
-
};
|
|
23
|
-
|
|
24
25
|
type StreamMessage = {
|
|
25
26
|
content?: unknown;
|
|
26
27
|
usage?: { output?: unknown };
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
/** 每个 content index 的可见文本/思考长度;无对应块的 index 保持稀疏洞。 */
|
|
30
|
-
function textBlockLengths(message: StreamMessage): number[] {
|
|
31
|
-
const content = message.content;
|
|
32
|
-
if (!Array.isArray(content)) return [];
|
|
33
|
-
const lengths: number[] = [];
|
|
34
|
-
for (let index = 0; index < content.length; index++) {
|
|
35
|
-
const block = content[index] as ContentBlock;
|
|
36
|
-
if (block?.type === "text" && typeof block.text === "string") {
|
|
37
|
-
lengths[index] = block.text.length;
|
|
38
|
-
} else if (block?.type === "thinking" && block.thinkingSignature?.body) {
|
|
39
|
-
lengths[index] = (block.thinkingSignature.body as string).length;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
return lengths;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
30
|
function outputUsage(message: StreamMessage): number {
|
|
46
31
|
const value = Number(message?.usage?.output);
|
|
47
32
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
|
|
@@ -55,39 +40,24 @@ type WorkingUi = {
|
|
|
55
40
|
* Extend Pi's footer working row while preserving its spinner and "Working...":
|
|
56
41
|
* `⠋ Working... (↓ 1,234 tokens · 12s)`
|
|
57
42
|
*
|
|
58
|
-
*
|
|
59
|
-
* provider `usage.output` whenever the stream exposes an actual count.
|
|
60
|
-
*/
|
|
43
|
+
* token 数直接读 token-rate 的累计(估算 → provider usage 同源),不再本地重数。 */
|
|
61
44
|
export default function (pi: ExtensionAPI): void {
|
|
62
45
|
let turnActive = false;
|
|
63
46
|
let agentStartTime = 0;
|
|
64
47
|
let turnStartTime = 0;
|
|
65
|
-
|
|
66
|
-
let
|
|
67
|
-
let providerOutputTokens = 0;
|
|
48
|
+
/** 当前用户指令的起点;工具调用后的 turn 复用它,直到下一条指令。 */
|
|
49
|
+
let rateRunStartTime = 0;
|
|
68
50
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
51
|
let lastMessage: string | null = null;
|
|
70
52
|
let activeCtx: { ui: WorkingUi | undefined; hasUI: boolean } | null = null;
|
|
71
53
|
|
|
72
54
|
function tokenCount(): number {
|
|
73
|
-
return
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function setTextBlockLength(index: number, length: number): void {
|
|
77
|
-
const previous = responseTextBlockLengths[index] ?? 0;
|
|
78
|
-
responseTextBlockLengths[index] = Math.max(0, length);
|
|
79
|
-
responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function resetResponseTracking(message?: StreamMessage): void {
|
|
83
|
-
responseTextBlockLengths = message ? textBlockLengths(message) : [];
|
|
84
|
-
responseLength = responseTextBlockLengths.reduce((sum, length) => sum + length, 0);
|
|
85
|
-
providerOutputTokens = message ? outputUsage(message) : 0;
|
|
55
|
+
return totalTokens();
|
|
86
56
|
}
|
|
87
57
|
|
|
88
58
|
function updateProviderUsage(message: StreamMessage): void {
|
|
89
59
|
const output = outputUsage(message);
|
|
90
|
-
if (output > 0)
|
|
60
|
+
if (output > 0) recordProviderUsage(output);
|
|
91
61
|
}
|
|
92
62
|
|
|
93
63
|
/**
|
|
@@ -105,7 +75,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
105
75
|
if (!turnActive) return "";
|
|
106
76
|
const parts: string[] = [];
|
|
107
77
|
const tokens = tokenCount();
|
|
108
|
-
if (tokens > 0) parts.push(`↓ ${
|
|
78
|
+
if (tokens > 0) parts.push(`↓ ${formatTokenCount(tokens)} tokens`);
|
|
109
79
|
// compact 活动回合且已滚出摘要行:直接用摘要行文案(自带回合时长,不叠 agent 计时)。
|
|
110
80
|
const compactStatus = compactMirrorText();
|
|
111
81
|
if (compactStatus) return [compactStatus, ...parts].join(" · ");
|
|
@@ -181,7 +151,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
181
151
|
stopRefreshLoop();
|
|
182
152
|
agentStartTime = 0;
|
|
183
153
|
turnStartTime = 0;
|
|
184
|
-
|
|
154
|
+
// 不重置 token-rate:footer 定格显示上一回合速率
|
|
185
155
|
restoreDefaultWorkingMessage();
|
|
186
156
|
}
|
|
187
157
|
|
|
@@ -189,12 +159,19 @@ export default function (pi: ExtensionAPI): void {
|
|
|
189
159
|
if (!agentStartTime) agentStartTime = Date.now();
|
|
190
160
|
});
|
|
191
161
|
|
|
192
|
-
pi.on("turn_start", async (
|
|
162
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
193
163
|
turnActive = true;
|
|
194
164
|
activeCtx = ctx;
|
|
195
|
-
|
|
165
|
+
// 事件时间戳在 handler 之前产生;用 Date.now() 会和紧随的采样落在同一毫秒,分母变成 0。
|
|
166
|
+
turnStartTime = event?.timestamp || Date.now();
|
|
196
167
|
if (!agentStartTime) agentStartTime = turnStartTime;
|
|
197
|
-
|
|
168
|
+
// 工具调用后的 turn 属于同一条用户指令:计时、TTFT 与累计 token 保持到下一条指令。
|
|
169
|
+
// 速率起点用本指令第一个 turn 的时间戳;before_agent_start 只当指令 id。
|
|
170
|
+
if (rateRunStartTime !== agentStartTime) {
|
|
171
|
+
rateRunStartTime = agentStartTime;
|
|
172
|
+
resetTokenRate(turnStartTime);
|
|
173
|
+
}
|
|
174
|
+
startMessageSegment(turnStartTime);
|
|
198
175
|
syncWorkingMessage(true);
|
|
199
176
|
scheduleRefreshTick();
|
|
200
177
|
});
|
|
@@ -205,25 +182,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
205
182
|
if (!evt) return;
|
|
206
183
|
|
|
207
184
|
if (evt.type === "start") {
|
|
208
|
-
|
|
209
|
-
} else if (
|
|
210
|
-
|
|
185
|
+
// 新 assistant 消息起点:速率分段已在 turn_start 处理。
|
|
186
|
+
} else if (
|
|
187
|
+
evt.type === "thinking_start" ||
|
|
188
|
+
evt.type === "text_start" ||
|
|
189
|
+
evt.type === "toolcall_start"
|
|
190
|
+
) {
|
|
191
|
+
// TTFT 的首块含 toolcall_start:模型直接发起 tool call(thinking/text 省略)时也算首 token。
|
|
192
|
+
// 分段锚点只在 turn_start 重置。这里再重置会把本段累计 usage.output 当成新的首报,live 速率翻倍。
|
|
193
|
+
markFirstToken();
|
|
211
194
|
updateProviderUsage(evt.partial);
|
|
212
195
|
} else if (evt.type === "thinking_delta" || evt.type === "text_delta") {
|
|
213
|
-
|
|
214
|
-
setTextBlockLength(evt.contentIndex, (responseTextBlockLengths[evt.contentIndex] ?? 0) + add);
|
|
196
|
+
if (typeof evt.delta === "string") sampleTokenDelta(evt.delta);
|
|
215
197
|
updateProviderUsage(evt.partial);
|
|
216
|
-
} else if (evt.type === "text_end") {
|
|
217
|
-
setTextBlockLength(
|
|
218
|
-
evt.contentIndex,
|
|
219
|
-
typeof evt.content === "string" ? evt.content.length : 0,
|
|
220
|
-
);
|
|
198
|
+
} else if (evt.type === "text_end" || evt.type === "thinking_end") {
|
|
221
199
|
updateProviderUsage(evt.partial);
|
|
222
200
|
} else if (evt.type === "done") {
|
|
223
|
-
|
|
201
|
+
updateProviderUsage(evt.message);
|
|
224
202
|
} else if (evt.type === "error") {
|
|
225
203
|
// provider 错误事件的 usage.output 不可信,不得计入 token 统计。
|
|
226
|
-
resetResponseTracking();
|
|
227
204
|
} else {
|
|
228
205
|
updateProviderUsage(evt.partial);
|
|
229
206
|
}
|
|
@@ -232,11 +209,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
232
209
|
scheduleRefreshTick();
|
|
233
210
|
});
|
|
234
211
|
|
|
212
|
+
pi.on("tool_execution_start", async () => {
|
|
213
|
+
pauseTokenRate();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
pi.on("tool_execution_end", async () => {
|
|
217
|
+
resumeTokenRate();
|
|
218
|
+
});
|
|
219
|
+
|
|
235
220
|
pi.on("turn_end", async (_event, ctx) => {
|
|
236
221
|
turnActive = false;
|
|
237
222
|
activeCtx = ctx;
|
|
238
223
|
stopRefreshLoop();
|
|
239
|
-
|
|
224
|
+
// 本段 token 结转进累计,下一段 turn 继续累加;此处不清速率。
|
|
225
|
+
carryTokenRate();
|
|
240
226
|
// No completion message: return immediately to Pi's default idle state.
|
|
241
227
|
restoreDefaultWorkingMessage();
|
|
242
228
|
});
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* renderer)。edit/write 复用 mode=on 的 rich diff
|
|
7
7
|
* 与 `editDiffCollapsedLines` / `writeDiffCollapsedLines` / `expandedPreviewMaxLines`。
|
|
8
8
|
* Agent/Task 族:调用只进摘要计数,tool 卡始终折叠(避免 pending→完成高度闪动)。
|
|
9
|
+
* edit/write 与 ask_user_question 留在外层:不进摘要计数,折叠态也单独成行。
|
|
10
|
+
* 用户消息是回合的硬边界:steer 消息插进回合中间、或上一回合没正文就收尾时,回合
|
|
11
|
+
* 就地收口、后续消息另起一回合,区间里的用户消息不会被当成外来行挪走(摘要行、
|
|
12
|
+
* 展开面板因此留在自己回合的位置上)。
|
|
9
13
|
* 底部 Agents/Tasks 面板由 pi-subagents/pi-tasks 独立 widget 负责,不经 tool 卡外置。
|
|
10
14
|
*
|
|
11
15
|
* live 围观态(回合进行中 / 收尾静默期):正文跟折叠态同形;摘要行与单一槽位卡
|
|
@@ -26,11 +30,14 @@
|
|
|
26
30
|
*/
|
|
27
31
|
import {
|
|
28
32
|
AssistantMessageComponent,
|
|
33
|
+
SkillInvocationMessageComponent,
|
|
29
34
|
ToolExecutionComponent,
|
|
35
|
+
UserMessageComponent,
|
|
30
36
|
type Theme,
|
|
31
37
|
} from "@earendil-works/pi-coding-agent";
|
|
32
38
|
import { truncateToWidth, visibleWidth, Box, Spacer } from "@earendil-works/pi-tui";
|
|
33
39
|
import { config, getToolDisplayConfig } from "../config/config.ts";
|
|
40
|
+
import { ASK_USER_QUESTION_TOOL_NAME } from "./tool/ask-user-question.ts";
|
|
34
41
|
import { toolLoadingIcon } from "../utils/tool-loading-icon.ts";
|
|
35
42
|
import { sanitizeToolResultText } from "../utils/tool-result-sanitize.ts";
|
|
36
43
|
import { refreshTranscriptComponent } from "./transcript-refresh.ts";
|
|
@@ -42,10 +49,16 @@ import {
|
|
|
42
49
|
isRichDiffComponent,
|
|
43
50
|
} from "./tool/diff/diff-renderer.ts";
|
|
44
51
|
import { renderRichToolResult } from "./tool/diff/index.ts";
|
|
52
|
+
import { displayConfigCacheKey } from "./tool/diff/diff-component.ts";
|
|
45
53
|
import type { WriteExecutionMetadataStore } from "./tool/diff/write-execution.ts";
|
|
46
54
|
import { isToolCallHovered } from "./mouse/hover.ts";
|
|
47
55
|
import { getToolMouseTui } from "./mouse/scroll.ts";
|
|
48
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
insetComponent,
|
|
58
|
+
isLiveToolCall,
|
|
59
|
+
renderExpandedToolResult,
|
|
60
|
+
scheduleAnimation,
|
|
61
|
+
} from "./tool/result.ts";
|
|
49
62
|
import { paddedBackgroundRow } from "./tool/grouping.ts";
|
|
50
63
|
import { formatDisplayPath } from "./tool/names.ts";
|
|
51
64
|
import {
|
|
@@ -81,6 +94,13 @@ export type CompactThinkingQuery = {
|
|
|
81
94
|
|
|
82
95
|
const EDIT_WRITE_TOOLS = new Set(["edit", "write"]);
|
|
83
96
|
|
|
97
|
+
/**
|
|
98
|
+
* 留在外层的工具:不进摘要计数,折叠态也单独成行。
|
|
99
|
+
* edit/write 走 compact 自己的 rich diff;ask_user_question 走 default 模式的专用渲染
|
|
100
|
+
*(标题 (+N) 加折叠问答树)。
|
|
101
|
+
*/
|
|
102
|
+
const OUTER_TOOLS = new Set([...EDIT_WRITE_TOOLS, ASK_USER_QUESTION_TOOL_NAME]);
|
|
103
|
+
|
|
84
104
|
/**
|
|
85
105
|
* 回合收尾后延迟收拢的静默期(ms)。正文 token 往往先于 toolCall 到达,
|
|
86
106
|
* 立刻收拢会得到「先收再开」两次转场;静默期内被新回合接替则一并收拢。
|
|
@@ -205,7 +225,7 @@ function buildMessagesSummary(
|
|
|
205
225
|
for (const item of content) {
|
|
206
226
|
if (item?.type !== "toolCall") continue;
|
|
207
227
|
const rawName = typeof item.name === "string" ? item.name : "tool";
|
|
208
|
-
if (
|
|
228
|
+
if (OUTER_TOOLS.has(rawName)) continue;
|
|
209
229
|
const name = sanitizeToolResultText(rawName);
|
|
210
230
|
if (rawName.split(".").pop() === "read") {
|
|
211
231
|
const args = item.arguments ?? item.args ?? {};
|
|
@@ -674,7 +694,7 @@ function compactEditWriteLine(
|
|
|
674
694
|
component: any,
|
|
675
695
|
width: number,
|
|
676
696
|
writeMetadata?: WriteExecutionMetadataStore,
|
|
677
|
-
options: { hint?: boolean; flushLeft?: boolean } = {},
|
|
697
|
+
options: { hint?: boolean; flushLeft?: boolean; showStats?: boolean } = {},
|
|
678
698
|
): string[] {
|
|
679
699
|
const theme = themeOf();
|
|
680
700
|
const name = String(component.toolName ?? "tool");
|
|
@@ -691,7 +711,8 @@ function compactEditWriteLine(
|
|
|
691
711
|
const iconColor = isError ? "error" : isPending ? "accent" : "success";
|
|
692
712
|
let statsText = "";
|
|
693
713
|
let statsStyled = "";
|
|
694
|
-
|
|
714
|
+
// 统计只显示一处:正文自带 +A -D 时标题不再重复(showStats=false)。
|
|
715
|
+
if (!isError && !isPending && options.showStats !== false) {
|
|
695
716
|
const stats =
|
|
696
717
|
name === "edit"
|
|
697
718
|
? countEditDiffStats(component.result?.details)
|
|
@@ -729,6 +750,12 @@ function compactEditWriteLine(
|
|
|
729
750
|
return ["", truncateToWidth(line, width, "")];
|
|
730
751
|
}
|
|
731
752
|
|
|
753
|
+
/** 正文自带 +A -D 的声明:diff 组件自报,折叠行数/宽度这些细节由它自己判断。 */
|
|
754
|
+
function richDiffBodyShowsStats(component: unknown): boolean {
|
|
755
|
+
const probe = (component as { bodyShowsStats?: () => boolean } | undefined)?.bodyShowsStats;
|
|
756
|
+
return typeof probe === "function" && probe.call(component) === true;
|
|
757
|
+
}
|
|
758
|
+
|
|
732
759
|
/** expanded 写进 rich diff 闭包,折叠/展开分槽缓存,避免每帧 parseDiff。 */
|
|
733
760
|
const compactRichDiffCache = new WeakMap<
|
|
734
761
|
object,
|
|
@@ -740,6 +767,7 @@ type CompactEditPaintHit = {
|
|
|
740
767
|
theme: unknown;
|
|
741
768
|
/** 展开卡的背景槽位同样参与命中判断。 */
|
|
742
769
|
bgSlot: string;
|
|
770
|
+
diffConfigKey: string;
|
|
743
771
|
expanded: boolean;
|
|
744
772
|
result: unknown;
|
|
745
773
|
isPartial: boolean;
|
|
@@ -771,6 +799,7 @@ function compactEditWriteLines(
|
|
|
771
799
|
const hover = isToolCallHovered(component.toolCallId);
|
|
772
800
|
const ioHover = compactEditIoHover(component);
|
|
773
801
|
const theme = themeOf();
|
|
802
|
+
const diffConfigKey = displayConfigCacheKey(getToolDisplayConfig());
|
|
774
803
|
if (!pending) {
|
|
775
804
|
const hit = compactEditPaintCache.get(component);
|
|
776
805
|
if (
|
|
@@ -778,6 +807,7 @@ function compactEditWriteLines(
|
|
|
778
807
|
hit.width === width &&
|
|
779
808
|
hit.theme === theme &&
|
|
780
809
|
hit.bgSlot === config.expandedCardBackground &&
|
|
810
|
+
hit.diffConfigKey === diffConfigKey &&
|
|
781
811
|
hit.expanded === expanded &&
|
|
782
812
|
hit.result === component.result &&
|
|
783
813
|
hit.isPartial === isPartial &&
|
|
@@ -794,6 +824,7 @@ function compactEditWriteLines(
|
|
|
794
824
|
width,
|
|
795
825
|
theme,
|
|
796
826
|
bgSlot: config.expandedCardBackground,
|
|
827
|
+
diffConfigKey,
|
|
797
828
|
expanded,
|
|
798
829
|
result: component.result,
|
|
799
830
|
isPartial,
|
|
@@ -848,7 +879,12 @@ function paintCompactEditWrite(
|
|
|
848
879
|
component.resultRendererComponent = candidate;
|
|
849
880
|
}
|
|
850
881
|
|
|
851
|
-
|
|
882
|
+
// 统计只显示一处:正文里已经有 +A -D(diff 头行/0 行摘要行)时标题不再重复。
|
|
883
|
+
const showStats = !(hasRich && richDiffBodyShowsStats(candidate));
|
|
884
|
+
const title = compactEditWriteLine(component, width, writeMetadata, {
|
|
885
|
+
hint: !hasRich,
|
|
886
|
+
showStats,
|
|
887
|
+
});
|
|
852
888
|
if (!hasRich && !expanded) {
|
|
853
889
|
return title;
|
|
854
890
|
}
|
|
@@ -888,6 +924,7 @@ function paintCompactEditWrite(
|
|
|
888
924
|
return compactEditWriteLine(component, innerWidth, writeMetadata, {
|
|
889
925
|
hint: false,
|
|
890
926
|
flushLeft: true,
|
|
927
|
+
showStats,
|
|
891
928
|
}).slice(1);
|
|
892
929
|
},
|
|
893
930
|
invalidate() {},
|
|
@@ -1001,7 +1038,7 @@ function compactStopStatusLine(status: string, pad = 0): any {
|
|
|
1001
1038
|
|
|
1002
1039
|
/**
|
|
1003
1040
|
* 槽位卡内的展开入口:卡里是只读围观,展开走摘要行自己的 hint,卡内不再重复提示。
|
|
1004
|
-
* 提示有两处来源——我们的 showMoreHintText(`…
|
|
1041
|
+
* 提示有两处来源——我们的 showMoreHintText(`… N more lines · click to show more`)与
|
|
1005
1042
|
* pi 原生工具卡(`… (N earlier lines, ctrl+o to expand)`),所以按渲染结果收尾裁剪。
|
|
1006
1043
|
* 只去掉动作词,`(N more lines)` 这类计数保留。
|
|
1007
1044
|
*/
|
|
@@ -1362,6 +1399,54 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1362
1399
|
return found;
|
|
1363
1400
|
};
|
|
1364
1401
|
|
|
1402
|
+
/** 回合成员判定:本回合的 assistant 组件,或归属本回合的工具(含分组容器)。 */
|
|
1403
|
+
const roundMemberMatcher = (round: CompactRound): ((value: any) => boolean) => {
|
|
1404
|
+
const ids = roundToolCallIds(round);
|
|
1405
|
+
const matchesTool = (value: any): boolean =>
|
|
1406
|
+
typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
|
|
1407
|
+
return (value: any): boolean =>
|
|
1408
|
+
round.messages.has(value) ||
|
|
1409
|
+
matchesTool(value) ||
|
|
1410
|
+
(Array.isArray(value?.children) && value.children.some(matchesTool));
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
/** 用户消息(含 skill 调用块)是回合的硬边界。 */
|
|
1414
|
+
const isUserMessageComponent = (value: any): boolean =>
|
|
1415
|
+
value instanceof UserMessageComponent || value instanceof SkillInvocationMessageComponent;
|
|
1416
|
+
|
|
1417
|
+
/**
|
|
1418
|
+
* 新组件与回合已有成员之间是否隔着一条用户消息。隔着就说明这是下一回合的内容:
|
|
1419
|
+
* steer 消息插进了回合中间,或上一回合没有正文就收尾、用户又问了新问题。
|
|
1420
|
+
* 组件还没挂进容器时判断不了,按不隔开处理(会话重放的 refresh 会再求值一次)。
|
|
1421
|
+
*/
|
|
1422
|
+
const crossesUserMessage = (round: CompactRound, component: any): boolean => {
|
|
1423
|
+
const container = tailContainerOf(round.anchor);
|
|
1424
|
+
const children = container?.children;
|
|
1425
|
+
if (!Array.isArray(children)) return false;
|
|
1426
|
+
const index = children.indexOf(component);
|
|
1427
|
+
if (index < 0) return false;
|
|
1428
|
+
const isMember = roundMemberMatcher(round);
|
|
1429
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
1430
|
+
if (isMember(children[i])) return false;
|
|
1431
|
+
if (isUserMessageComponent(children[i])) return true;
|
|
1432
|
+
}
|
|
1433
|
+
return false;
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
/**
|
|
1437
|
+
* 可以并入的活动回合。被用户消息隔开时返回 undefined,调用方收掉旧回合另起一个,
|
|
1438
|
+
* 保证回合不跨用户消息——否则区间里的用户消息会被当成外来行挪到回合末尾之后。
|
|
1439
|
+
*/
|
|
1440
|
+
const joinableActiveRound = (component: any): CompactRound | undefined =>
|
|
1441
|
+
activeRound && !crossesUserMessage(activeRound, component) ? activeRound : undefined;
|
|
1442
|
+
|
|
1443
|
+
/** 取并入目标;旧回合被用户消息隔开时先收尾(静默期一转场,新回合立即接手)。 */
|
|
1444
|
+
const joinOrCreateRound = (component: any, message: any): CompactRound => {
|
|
1445
|
+
const joinable = joinableActiveRound(component);
|
|
1446
|
+
if (!joinable && activeRound) finishRound(activeRound);
|
|
1447
|
+
return joinable ?? createRound(component, message);
|
|
1448
|
+
};
|
|
1449
|
+
|
|
1365
1450
|
/** 尾行实际所在容器;缓存的 container 过期时按树找回来。 */
|
|
1366
1451
|
const tailHostContainer = (tail: MountedRoundTail): any =>
|
|
1367
1452
|
containerOfHost(tail.host) ??
|
|
@@ -1444,13 +1529,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1444
1529
|
const children = container.children as any[];
|
|
1445
1530
|
const current = children.indexOf(tail.host);
|
|
1446
1531
|
if (current >= 0) children.splice(current, 1);
|
|
1447
|
-
const
|
|
1448
|
-
const matchesTool = (value: any): boolean =>
|
|
1449
|
-
typeof value?.toolCallId === "string" && ids.has(value.toolCallId);
|
|
1450
|
-
const isMember = (value: any): boolean =>
|
|
1451
|
-
round.messages.has(value) ||
|
|
1452
|
-
matchesTool(value) ||
|
|
1453
|
-
(Array.isArray(value?.children) && value.children.some(matchesTool));
|
|
1532
|
+
const isMember = roundMemberMatcher(round);
|
|
1454
1533
|
let first = -1;
|
|
1455
1534
|
let last = -1;
|
|
1456
1535
|
for (let i = 0; i < children.length; i++) {
|
|
@@ -1461,7 +1540,9 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1461
1540
|
const lastMember = last >= 0 ? children[last] : undefined;
|
|
1462
1541
|
const intruders: any[] = [];
|
|
1463
1542
|
for (let i = last - 1; i > first; i--) {
|
|
1464
|
-
|
|
1543
|
+
// 用户消息永远留在原地:挪走会把别人的消息挪到用户消息下面去。
|
|
1544
|
+
if (isMember(children[i]) || isUserMessageComponent(children[i])) continue;
|
|
1545
|
+
intruders.unshift(children.splice(i, 1)[0]);
|
|
1465
1546
|
}
|
|
1466
1547
|
if (lastMember) last = children.indexOf(lastMember);
|
|
1467
1548
|
const insertAt = last < 0 ? children.length : last + 1;
|
|
@@ -1643,7 +1724,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1643
1724
|
if (item?.type !== "toolCall" || typeof item.id !== "string") continue;
|
|
1644
1725
|
const tool = toolsById.get(item.id);
|
|
1645
1726
|
// edit/write 保留自己的 compact 行,不进槽位。
|
|
1646
|
-
if (
|
|
1727
|
+
if (OUTER_TOOLS.has(String(tool?.toolName ?? item.name ?? ""))) continue;
|
|
1647
1728
|
round.liveSlotToolIds.add(item.id);
|
|
1648
1729
|
liveSlotToolIds.add(item.id);
|
|
1649
1730
|
if (!tool) continue;
|
|
@@ -1700,8 +1781,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1700
1781
|
for (const id of ids) explicitRoundToolIds.delete(id);
|
|
1701
1782
|
collapseTools(
|
|
1702
1783
|
(tool) =>
|
|
1703
|
-
ids.has(String(tool.toolCallId ?? "")) &&
|
|
1704
|
-
!EDIT_WRITE_TOOLS.has(String(tool.toolName ?? "")),
|
|
1784
|
+
ids.has(String(tool.toolCallId ?? "")) && !OUTER_TOOLS.has(String(tool.toolName ?? "")),
|
|
1705
1785
|
);
|
|
1706
1786
|
}
|
|
1707
1787
|
for (const id of round.liveSlotToolIds) liveSlotToolIds.delete(id);
|
|
@@ -1771,7 +1851,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1771
1851
|
const placedToolIds = new Set<string>();
|
|
1772
1852
|
const placeTool = (id: string, fallbackName?: string) => {
|
|
1773
1853
|
const tool = toolsById.get(id);
|
|
1774
|
-
if (!tool ||
|
|
1854
|
+
if (!tool || OUTER_TOOLS.has(String(tool.toolName ?? fallbackName ?? ""))) return;
|
|
1775
1855
|
if (placedToolIds.has(id)) return;
|
|
1776
1856
|
placedToolIds.add(id);
|
|
1777
1857
|
cardItems.push({ tool });
|
|
@@ -1837,7 +1917,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1837
1917
|
collapseTools(
|
|
1838
1918
|
(tool) =>
|
|
1839
1919
|
ids.has(String(tool.toolCallId ?? "")) &&
|
|
1840
|
-
!
|
|
1920
|
+
!OUTER_TOOLS.has(String(tool.toolName ?? "")) &&
|
|
1841
1921
|
!explicitRoundToolIds.has(String(tool.toolCallId ?? "")),
|
|
1842
1922
|
);
|
|
1843
1923
|
flushPanel();
|
|
@@ -1973,7 +2053,8 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
1973
2053
|
round = createRound(this, message);
|
|
1974
2054
|
roundByComponent.set(this, round);
|
|
1975
2055
|
} else if (!round) {
|
|
1976
|
-
|
|
2056
|
+
// 中间隔着用户消息就另起一回合,不并进上一个回合。
|
|
2057
|
+
round = joinOrCreateRound(this, message);
|
|
1977
2058
|
roundByComponent.set(this, round);
|
|
1978
2059
|
}
|
|
1979
2060
|
round.messages.set(this, message);
|
|
@@ -2009,7 +2090,7 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2009
2090
|
if (hasThinking) {
|
|
2010
2091
|
let round = roundByComponent.get(this);
|
|
2011
2092
|
if (!round) {
|
|
2012
|
-
round =
|
|
2093
|
+
round = joinOrCreateRound(this, message);
|
|
2013
2094
|
roundByComponent.set(this, round);
|
|
2014
2095
|
}
|
|
2015
2096
|
round.messages.set(this, message);
|
|
@@ -2035,10 +2116,17 @@ export function installCompactMode(deps: CompactModeInstallDeps): CompactModeHoo
|
|
|
2035
2116
|
return patch.toolOriginalRender.call(this, width);
|
|
2036
2117
|
}
|
|
2037
2118
|
const name = String(this.toolName ?? "");
|
|
2038
|
-
if (
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2119
|
+
if (OUTER_TOOLS.has(name)) {
|
|
2120
|
+
// edit/write 走 compact 自己的 rich diff;ask_user_question 走 default 模式的专用渲染。
|
|
2121
|
+
// 两者都留在外层,不进摘要、不进展开面板。
|
|
2122
|
+
if (EDIT_WRITE_TOOLS.has(name)) {
|
|
2123
|
+
const pending = !this.result || this.isPartial === true;
|
|
2124
|
+
// 等待窗口(声明流式中或等待派发)也要转,否则这一行冻在第一帧。
|
|
2125
|
+
if (pending && (this.executionStarted || isLiveToolCall(this.toolCallId)))
|
|
2126
|
+
scheduleAnimation(this);
|
|
2127
|
+
return compactEditWriteLines(this, width, deps.writeMetadata);
|
|
2128
|
+
}
|
|
2129
|
+
return patch.toolOriginalRender.call(this, width);
|
|
2042
2130
|
}
|
|
2043
2131
|
// Agent/Task 等同普通工具:折叠不外置(live 面板走独立 widget)。
|
|
2044
2132
|
if (expandedRoundToolIds.has(String(this.toolCallId ?? ""))) return [];
|