pi-my-compact-ui 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,282 @@
1
+ /** Pi 命令与事件接入;呈现逻辑委托给各职责模块。 */
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { installGrouping, ensureThinkingGroup, flushPendingTextSeal } from "./grouping.js";
4
+ import { installNativeThinkingSuppression, removeGroupFromContainer, insertTurnDivider } from "./assistant-patches.js";
5
+ import { installCompactionSummaryRendering } from "./compaction.js";
6
+ import { openInspectSelectorModal, closeDetailModal, closeInspectSelectorModal } from "./modals.js";
7
+ import { runtime } from "./state.js";
8
+ import { isKeyRelease, matchesKey, Key } from "@earendil-works/pi-tui";
9
+ import { isCtrlI } from "./helpers.js";
10
+ import { rememberToolStart, clearPreparingTools, noteStreamingToolCall, loadSessionToolTimings } from "./streaming-tools.js";
11
+ import { finalizeActiveThinking, createThinkingEntry, updateActiveThinkingTokens, refreshSealedThinkingTokens } from "./thinking.js";
12
+ import { registerConfigCommand } from "./settings.js";
13
+
14
+ export default function (pi: ExtensionAPI) {
15
+ installGrouping();
16
+ installNativeThinkingSuppression();
17
+ installCompactionSummaryRendering();
18
+
19
+ // Do not use pi.registerShortcut("ctrl+i") directly because pi's internal
20
+ // matchesKey treats "\t" as "ctrl+i", which breaks regular Tab completion.
21
+ // Instead, recognize Ctrl+I under extended protocols via onTerminalInput + isCtrlI.
22
+
23
+ pi.registerCommand("compact-inspect", {
24
+ description: "Select a tool call or thinking entry to inspect details (Shortcut: Ctrl+I)",
25
+ handler: async () => {
26
+ openInspectSelectorModal();
27
+ },
28
+ });
29
+
30
+ pi.on("session_start", async (_event, ctx) => {
31
+ runtime.currentTheme = ctx.ui.theme;
32
+ runtime.activeUIContext = ctx.ui;
33
+ ctx.ui.setHiddenThinkingLabel("");
34
+
35
+ ctx.ui.onTerminalInput((data: string) => {
36
+ if (isKeyRelease(data)) {
37
+ return runtime.activeModalHandle || runtime.activeSelectorHandle ? { consume: true } : undefined;
38
+ }
39
+
40
+ if (runtime.activeModalHandle) {
41
+ if (matchesKey(data, Key.escape) || data === "q" || data === "Q" || isCtrlI(data)) {
42
+ closeDetailModal();
43
+ return { consume: true };
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ if (runtime.activeSelectorHandle) {
49
+ if (matchesKey(data, Key.escape) || data === "q" || data === "Q" || isCtrlI(data)) {
50
+ closeInspectSelectorModal();
51
+ return { consume: true };
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ if (isCtrlI(data)) {
57
+ openInspectSelectorModal();
58
+ return { consume: true };
59
+ }
60
+
61
+ return undefined;
62
+ });
63
+
64
+ // Capture the TUI instance via setWidget's factory so the animation can
65
+ // call its throttled requestRender() to repaint just the changed cells.
66
+ ctx.ui.setWidget("compact-anim", (tui: any) => {
67
+ runtime.capturedTui = tui;
68
+ return { render: () => [] as string[], invalidate() {} };
69
+ });
70
+ installGrouping();
71
+ installNativeThinkingSuppression();
72
+ installCompactionSummaryRendering();
73
+
74
+ if ((ctx as any)?.sessionManager?.getEntries) {
75
+ loadSessionToolTimings((ctx as any).sessionManager.getEntries());
76
+ }
77
+
78
+ // Replaying history builds groups that belong to finished past turns.
79
+ // Seal them once history replay finishes so they do not animate or tick.
80
+ queueMicrotask(() => {
81
+ for (const g of runtime.groups) {
82
+ g.sealed = true;
83
+ for (const t of g.children as any[]) {
84
+ if (t.result && t._groupEndAt === undefined) {
85
+ t._groupEndAt = Date.now();
86
+ }
87
+ }
88
+ g.invalidate();
89
+ }
90
+ runtime.lastActiveGroup = null;
91
+ if (runtime.animTimer) {
92
+ clearTimeout(runtime.animTimer);
93
+ runtime.animTimer = null;
94
+ }
95
+ runtime.capturedTui?.requestRender?.();
96
+ });
97
+ });
98
+
99
+ pi.on("session_shutdown", async () => {
100
+ closeDetailModal();
101
+ closeInspectSelectorModal();
102
+ });
103
+
104
+ pi.on("tool_execution_start", async (event) => {
105
+ // Keep the timestamp captured when arguments started streaming.
106
+ rememberToolStart(event.toolCallId);
107
+ runtime.lastActiveGroup?.invalidate();
108
+ runtime.activeSelectorComponent?.refreshEntries();
109
+ runtime.capturedTui?.requestRender?.();
110
+ });
111
+
112
+ pi.on("tool_execution_end", async (event) => {
113
+ const end = Date.now();
114
+ const start = runtime.toolStarts.get(event.toolCallId);
115
+ if (start !== undefined && !runtime.toolDurations.has(event.toolCallId)) {
116
+ runtime.toolDurations.set(event.toolCallId, Math.max(0, (end - start) / 1000));
117
+ }
118
+ for (const g of runtime.groups) {
119
+ for (const t of g.children as any[]) {
120
+ if (t.toolCallId === event.toolCallId) t._groupEndAt = end;
121
+ }
122
+ }
123
+ runtime.lastActiveGroup?.invalidate();
124
+ runtime.activeSelectorComponent?.refreshEntries();
125
+ runtime.capturedTui?.requestRender?.();
126
+ });
127
+
128
+ pi.on("message_start", async (event) => {
129
+ const role = (event.message as any)?.role;
130
+ // A new user message is a hard turn boundary: seal whatever block is still
131
+ // open. Assistant/toolResult message boundaries do NOT seal — thinking and
132
+ // tool calls stay in one block until real (non-thinking) text appears.
133
+ if (role === "user" && runtime.lastActiveGroup && !runtime.lastActiveGroup.sealed) {
134
+ runtime.lastActiveGroup.sealed = true;
135
+ finalizeActiveThinking();
136
+ runtime.lastActiveGroup.invalidate();
137
+ }
138
+ if (role === "user") {
139
+ for (const g of [...runtime.groups]) {
140
+ if (!g.hasVisibleEntries()) {
141
+ if (runtime.lastChatContainer) removeGroupFromContainer(runtime.lastChatContainer, g);
142
+ runtime.groups.delete(g);
143
+ }
144
+ }
145
+ runtime.turnStartMs = Date.now();
146
+ runtime.activeThinking = null;
147
+ runtime.activeThinkingIndex = null;
148
+ runtime.activeThinkingBlocks.clear();
149
+ runtime.unattachedThinking.length = 0;
150
+ clearPreparingTools();
151
+ runtime.lastSealedThinkingEntry = null;
152
+ runtime.lastReportedReasoningTokens = 0;
153
+ runtime.handledTextIndexes.clear();
154
+ runtime.sealedThinkingIndexes.clear();
155
+ runtime.pendingTextSeal = false;
156
+ runtime.pendingTextOrdinal = null;
157
+ runtime.lastStreamingComp = null;
158
+ } else if (role === "assistant") {
159
+ // contentIndex values are local to one streamed assistant message. Close
160
+ // any run left open by the previous message; a late thinking_end for it is
161
+ // then rejected through sealedThinkingIndexes.
162
+ finalizeActiveThinking();
163
+ runtime.handledTextIndexes.clear();
164
+ runtime.sealedThinkingIndexes.clear();
165
+ // Reported reasoning usage belongs to one assistant message; a run sealed
166
+ // later must not inherit the previous message's value.
167
+ runtime.lastSealedThinkingEntry = null;
168
+ runtime.lastReportedReasoningTokens = 0;
169
+ runtime.pendingTextSeal = false;
170
+ runtime.pendingTextOrdinal = null;
171
+ // Do not insert early thinking beside the previous assistant message.
172
+ // The addChild patch fills this with the current streaming component.
173
+ runtime.lastStreamingComp = null;
174
+ }
175
+ });
176
+
177
+ pi.on("message_update", async (event) => {
178
+ const msg = event.message as any;
179
+ if (!msg || msg.role !== "assistant") return;
180
+ const content = Array.isArray(msg.content) ? msg.content : [];
181
+ const streamEvent = event.assistantMessageEvent as any;
182
+ const streamType = String(streamEvent?.type ?? "");
183
+
184
+ if (streamType.startsWith("thinking_")) {
185
+ // Only read the block targeted by this stream event. The surrounding
186
+ // message is cumulative and may still contain thinking from before a
187
+ // text boundary; scanning all content would resurrect that old block.
188
+ const contentIndex = Number(streamEvent.contentIndex);
189
+ const hasIndex = Number.isInteger(contentIndex);
190
+ // A thinking_end re-emitted after a visible text boundary already sealed
191
+ // this run must not create a second row for the same reasoning.
192
+ if (hasIndex && runtime.sealedThinkingIndexes.has(contentIndex)) return;
193
+ const startsRun =
194
+ streamType === "thinking_start" || !runtime.activeThinking || (hasIndex && runtime.activeThinkingIndex !== contentIndex);
195
+ if (startsRun) {
196
+ // Each run is its own entry: close the previous one instead of merging.
197
+ finalizeActiveThinking();
198
+ runtime.activeThinkingIndex = hasIndex ? contentIndex : null;
199
+ createThinkingEntry();
200
+ }
201
+ const block = hasIndex ? content[contentIndex] : undefined;
202
+ const blockText =
203
+ block?.type === "thinking"
204
+ ? String(block.thinking ?? "")
205
+ : streamType === "thinking_end"
206
+ ? String(streamEvent.content ?? "")
207
+ : "";
208
+ if (hasIndex) runtime.activeThinkingBlocks.set(contentIndex, blockText);
209
+ updateActiveThinkingTokens(msg);
210
+ // Drop the row in as soon as the run starts — even before its first delta —
211
+ // so the spinner reports the in-progress state.
212
+ ensureThinkingGroup();
213
+ if (streamType === "thinking_end") finalizeActiveThinking();
214
+ } else if (streamType.startsWith("text_")) {
215
+ const contentIndex = Number(streamEvent.contentIndex);
216
+ const block = Number.isInteger(contentIndex) ? content[contentIndex] : undefined;
217
+ const text = block?.type === "text" ? String(block.text ?? "").trim() : "";
218
+ // The first non-whitespace text is a boundary. Deduplicate by content
219
+ // index so every later cumulative delta extends the same text block.
220
+ if (text.length > 0 && !runtime.handledTextIndexes.has(contentIndex)) {
221
+ if (runtime.activeThinking) updateActiveThinkingTokens(msg);
222
+ runtime.handledTextIndexes.add(contentIndex);
223
+ runtime.pendingTextSeal = true;
224
+ runtime.pendingTextOrdinal =
225
+ content
226
+ .slice(0, Number.isInteger(contentIndex) ? contentIndex + 1 : content.length)
227
+ .filter((item: any) => item?.type === "text" && String(item.text ?? "").trim()).length - 1;
228
+ flushPendingTextSeal();
229
+ }
230
+ } else if (streamType.startsWith("toolcall_")) {
231
+ // Arguments are still streaming. Show the busy row before execution starts.
232
+ if (runtime.activeThinking) updateActiveThinkingTokens(msg);
233
+ noteStreamingToolCall(content, streamEvent);
234
+ } else if (streamType === "done" || streamType === "error") {
235
+ if (runtime.activeThinking) updateActiveThinkingTokens(msg);
236
+ finalizeActiveThinking();
237
+ refreshSealedThinkingTokens(msg?.usage?.reasoning);
238
+ }
239
+
240
+ // Refresh the active block when thinking starts/stops (event-driven only;
241
+ // no timer, so the transcript scroll position is never yanked around).
242
+ runtime.lastActiveGroup?.invalidate();
243
+ runtime.activeSelectorComponent?.refreshEntries();
244
+ runtime.capturedTui?.requestRender?.();
245
+ });
246
+
247
+ pi.on("agent_end", async () => {
248
+ // Turn finished: seal the final block so it stops spinning and shows a
249
+ // stable summary until the user starts the next turn.
250
+ clearPreparingTools();
251
+ if (runtime.lastActiveGroup && !runtime.lastActiveGroup.sealed) {
252
+ runtime.lastActiveGroup.sealed = true;
253
+ finalizeActiveThinking();
254
+ runtime.lastActiveGroup.invalidate();
255
+ }
256
+ for (const g of [...runtime.groups]) {
257
+ if (!g.hasVisibleEntries()) {
258
+ if (runtime.lastChatContainer) removeGroupFromContainer(runtime.lastChatContainer, g);
259
+ runtime.groups.delete(g);
260
+ }
261
+ }
262
+ refreshSealedThinkingTokens();
263
+ runtime.activeSelectorComponent?.refreshEntries();
264
+ runtime.capturedTui?.requestRender?.();
265
+ // Separate the final visible text from the preceding work with a divider
266
+ // that reports how long this turn ran.
267
+ const elapsedMs = Date.now() - runtime.turnStartMs;
268
+ insertTurnDivider(elapsedMs);
269
+ runtime.activeThinking = null;
270
+ runtime.activeThinkingIndex = null;
271
+ runtime.activeThinkingBlocks.clear();
272
+ runtime.unattachedThinking.length = 0;
273
+ runtime.lastSealedThinkingEntry = null;
274
+ runtime.lastReportedReasoningTokens = 0;
275
+ runtime.handledTextIndexes.clear();
276
+ runtime.sealedThinkingIndexes.clear();
277
+ runtime.pendingTextSeal = false;
278
+ runtime.pendingTextOrdinal = null;
279
+ });
280
+
281
+ registerConfigCommand(pi);
282
+ }
@@ -0,0 +1,327 @@
1
+ /** 按流顺序组合思考与工具,并在可见正文边界封组。 */
2
+ import { isToolExecution, isSpacer, isAssistantMessage, isToolGroup, isContainer } from "./guards.js";
3
+ import { ToolGroupComponent } from "./tool-group.js";
4
+ import { thinkingAlreadyShown, isVisibleTextMarkdown, anchorGroupBeforeCurrentText, detachGroup, noteGroupMount, removeGroupFromContainer, ensureAssistantThinkingPatched, stripAssistantPhantomPadding, restoreAssistantAnchor, releaseAssistantAnchors, getAssistantContentState } from "./assistant-patches.js";
5
+ import { runtime } from "./state.js";
6
+ import { estimateTextTokens } from "./helpers.js";
7
+ import { findOpenGroup, placeGroupAfter } from "./streaming-tools.js";
8
+ import { finalizeActiveThinking } from "./thinking.js";
9
+ import type { ThinkingEntry, PatchState } from "./types.js";
10
+ import { Container } from "@earendil-works/pi-tui";
11
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
12
+ import { PATCH_KEY, LIVE_ASSISTANT_KEY, IS_STREAMING_COMP, PARENT_KEY } from "./constants.js";
13
+
14
+ export function isGroupable(value: any): boolean {
15
+ return isToolExecution(value);
16
+ }
17
+
18
+ export function previousGroupable(children: any[], start: number): { child: any; index: number } | undefined {
19
+ for (let i = start; i >= 0; i--) {
20
+ const child = children[i];
21
+ if (isSpacer(child)) continue;
22
+ if (isAssistantMessage(child)) continue;
23
+ // A sealed block is a text boundary, not a reason to ignore an earlier open group.
24
+ // Notices and other chat widgets between tool rounds are not boundaries either.
25
+ if (isToolGroup(child) && child.sealed) continue;
26
+ if (isToolGroup(child) || isGroupable(child)) return { child, index: i };
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ export function assistantHasVisibleText(component: any): boolean {
32
+ const content = component?.lastMessage?.content;
33
+ if (!Array.isArray(content)) return false;
34
+ return content.some((item: any) => item?.type === "text" && String(item.text ?? "").trim());
35
+ }
36
+
37
+ export function absorbThinkingBeforeText(group: ToolGroupComponent, message: any): void {
38
+ const content = message?.content;
39
+ if (!Array.isArray(content)) return;
40
+ for (const item of content) {
41
+ if (item?.type === "text" && String(item.text ?? "").trim()) break;
42
+ if (item?.type !== "thinking") continue;
43
+ const text = String(item.thinking ?? "").trim();
44
+ if (!text || thinkingAlreadyShown(text)) continue;
45
+ group.entries.push({
46
+ kind: "thinking",
47
+ id: runtime.thinkingEntrySeq++,
48
+ text,
49
+ tokens: estimateTextTokens(text),
50
+ tokensExact: false,
51
+ active: false,
52
+ owner: group,
53
+ });
54
+ }
55
+ }
56
+
57
+ export function sealOpenGroupAtAssistantText(assistant: any): void {
58
+ const open = findOpenGroup();
59
+ if (!open) return;
60
+ absorbThinkingBeforeText(open, assistant.lastMessage);
61
+ open.sealed = true;
62
+ finalizeActiveThinking();
63
+ const contentContainer = assistant.contentContainer;
64
+ if (isContainer(contentContainer) && contentContainer.children.some(isVisibleTextMarkdown)) {
65
+ const previous = runtime.lastStreamingComp;
66
+ runtime.lastStreamingComp = assistant;
67
+ anchorGroupBeforeCurrentText(open, 0);
68
+ runtime.lastStreamingComp = previous ?? assistant;
69
+ }
70
+ open.invalidate();
71
+ }
72
+
73
+ export function ensureThinkingGroup(): void {
74
+ if (runtime.unattachedThinking.length === 0) return;
75
+ let group = findOpenGroup();
76
+ if (group && runtime.lastChatContainer && runtime.lastStreamingComp) {
77
+ placeGroupAfter(runtime.lastChatContainer, runtime.lastStreamingComp, group);
78
+ }
79
+ if (!group) {
80
+ if (!runtime.lastChatContainer || !runtime.lastStreamingComp) return; // retry on the next event
81
+ const parent = runtime.lastChatContainer;
82
+ const children = parent.children;
83
+ if (!Array.isArray(children)) return;
84
+ const idx = children.indexOf(runtime.lastStreamingComp);
85
+ group = new ToolGroupComponent();
86
+ detachGroup(group);
87
+ children.splice(idx >= 0 ? idx + 1 : children.length, 0, group);
88
+ noteGroupMount(parent, group);
89
+ runtime.groups.add(group);
90
+ runtime.lastActiveGroup = group;
91
+ parent.invalidate?.();
92
+ }
93
+ while (runtime.unattachedThinking.length > 0) group.addThinking(runtime.unattachedThinking.shift()!);
94
+ group.invalidate();
95
+ runtime.capturedTui?.requestRender?.();
96
+ }
97
+
98
+ export function flushPendingTextSeal(): void {
99
+ if (!runtime.pendingTextSeal) return;
100
+
101
+ // A run still streaming when the answer starts belongs to the block being
102
+ // sealed, and may not have a group yet.
103
+ if (runtime.activeThinking || runtime.unattachedThinking.length > 0) ensureThinkingGroup();
104
+
105
+ if (runtime.lastActiveGroup && !runtime.lastActiveGroup.sealed) {
106
+ // The active group contains every thinking/tool event since the previous
107
+ // visible text. Anchor it immediately before this text block so the visual
108
+ // component order matches the stream order:
109
+ // group -> visible text -> next group -> next visible text
110
+ if (runtime.lastActiveGroup.hasVisibleEntries()) {
111
+ if (runtime.pendingTextOrdinal !== null) {
112
+ anchorGroupBeforeCurrentText(runtime.lastActiveGroup, runtime.pendingTextOrdinal);
113
+ }
114
+ } else {
115
+ if (runtime.lastChatContainer) removeGroupFromContainer(runtime.lastChatContainer, runtime.lastActiveGroup);
116
+ runtime.groups.delete(runtime.lastActiveGroup);
117
+ }
118
+ runtime.lastActiveGroup.sealed = true;
119
+ finalizeActiveThinking();
120
+ runtime.lastActiveGroup.invalidate();
121
+ runtime.pendingTextSeal = false;
122
+ runtime.pendingTextOrdinal = null;
123
+ return;
124
+ }
125
+
126
+ // No thinking or tools preceded this text, so there is no compact block to
127
+ // seal. Do not let the boundary leak forward and close a later tool group.
128
+ runtime.pendingTextSeal = false;
129
+ runtime.pendingTextOrdinal = null;
130
+ }
131
+
132
+ export function getPrecedingAssistantThinking(children: any[], beforeIndex: number): string[] {
133
+ for (let i = beforeIndex; i >= 0; i--) {
134
+ const child = children[i];
135
+ if (isSpacer(child)) continue;
136
+ if (isAssistantMessage(child)) {
137
+ const content = (child as any).lastMessage?.content;
138
+ if (!Array.isArray(content)) return [];
139
+ const texts: string[] = [];
140
+ for (const item of content) {
141
+ if (item?.type === "thinking") {
142
+ const t = String(item.thinking ?? "").trim();
143
+ if (t && !thinkingAlreadyShown(t)) texts.push(t);
144
+ }
145
+ }
146
+ return texts;
147
+ }
148
+ break;
149
+ }
150
+ return [];
151
+ }
152
+
153
+ export function attachPrecedingThinkingToGroup(group: ToolGroupComponent, children: any[], beforeIndex: number): void {
154
+ const texts = getPrecedingAssistantThinking(children, beforeIndex);
155
+ if (texts.length === 0) return;
156
+ for (const text of texts) {
157
+ if (thinkingAlreadyShown(text)) continue;
158
+ const entry: ThinkingEntry = {
159
+ kind: "thinking",
160
+ id: runtime.thinkingEntrySeq++,
161
+ text,
162
+ tokens: estimateTextTokens(text),
163
+ tokensExact: false,
164
+ active: false,
165
+ owner: group,
166
+ };
167
+ group.entries.push(entry);
168
+ }
169
+ }
170
+
171
+ export function joinOpenGroup(parent: any, index: number, component: any): boolean {
172
+ if (!runtime.lastChatContainer || parent !== runtime.lastChatContainer) return false;
173
+ const open = findOpenGroup();
174
+ if (!open) return false;
175
+ const children = parent.children;
176
+ children.splice(index, 1);
177
+ if (runtime.lastStreamingComp && children.includes(runtime.lastStreamingComp)) {
178
+ placeGroupAfter(parent, runtime.lastStreamingComp, open);
179
+ } else if (!children.includes(open)) {
180
+ children.push(open);
181
+ noteGroupMount(parent, open);
182
+ }
183
+ attachPrecedingThinkingToGroup(open, children, children.indexOf(open) - 1);
184
+ open.addTool(component);
185
+ runtime.lastActiveGroup = open;
186
+ open.invalidate();
187
+ return true;
188
+ }
189
+
190
+ export function maybeGroup(parent: any, component: any): void {
191
+ if (!isGroupable(component) || isToolGroup(parent)) return;
192
+ const children = parent?.children;
193
+ if (!Array.isArray(children)) return;
194
+ const index = children.indexOf(component);
195
+ if (index < 0) return;
196
+ if (joinOpenGroup(parent, index, component)) return;
197
+ const prior = previousGroupable(children, index - 1);
198
+
199
+ // Previous sibling is an open (not-yet-sealed) group → join it.
200
+ if (isToolGroup(prior?.child) && !prior.child.sealed) {
201
+ attachPrecedingThinkingToGroup(prior.child, children, index - 1);
202
+ children.splice(index, 1);
203
+ prior.child.addTool(component);
204
+ runtime.lastActiveGroup = prior.child;
205
+ return;
206
+ }
207
+ // Previous sibling is a bare tool → merge both into a new group.
208
+ if (prior && isGroupable(prior.child)) {
209
+ const group = new ToolGroupComponent();
210
+ attachPrecedingThinkingToGroup(group, children, prior.index - 1);
211
+ group.addTool(prior.child);
212
+ attachPrecedingThinkingToGroup(group, children, index - 1);
213
+ group.addTool(component);
214
+ (parent as any).children[prior.index] = group;
215
+ children.splice(index, 1);
216
+ noteGroupMount(parent, group);
217
+ runtime.groups.add(group);
218
+ runtime.lastActiveGroup = group;
219
+ return;
220
+ }
221
+ // Otherwise (sealed group before, or nothing groupable) → wrap the tool in a
222
+ // fresh open group so it stays visible.
223
+ const group = new ToolGroupComponent();
224
+ attachPrecedingThinkingToGroup(group, children, index - 1);
225
+ group.addTool(component);
226
+ (parent as any).children[index] = group;
227
+ noteGroupMount(parent, group);
228
+ runtime.groups.add(group);
229
+ runtime.lastActiveGroup = group;
230
+ }
231
+
232
+ export function installGrouping(): void {
233
+ const host = globalThis as any;
234
+ const prototypes: any[] = [Container.prototype];
235
+ const agentContainerProto = Object.getPrototypeOf(AssistantMessageComponent.prototype);
236
+ if (agentContainerProto && agentContainerProto !== Object.prototype && !prototypes.includes(agentContainerProto)) {
237
+ prototypes.push(agentContainerProto);
238
+ }
239
+
240
+ const patchKey = PATCH_KEY;
241
+ const previous = host[patchKey] as Map<any, PatchState> | undefined;
242
+ const stateMap = new Map<any, PatchState>();
243
+
244
+ for (const prototype of prototypes) {
245
+ const prev = previous instanceof Map ? previous.get(prototype) : (previous as any as PatchState | undefined);
246
+ const original = {
247
+ addChild: prev && prototype.addChild === prev.installed.addChild ? prev.original.addChild : prototype.addChild,
248
+ removeChild: prev && prototype.removeChild === prev.installed.removeChild ? prev.original.removeChild : prototype.removeChild,
249
+ clear: prev && prototype.clear === prev.installed.clear ? prev.original.clear : prototype.clear,
250
+ };
251
+ const state: PatchState = {
252
+ active: true,
253
+ prototype,
254
+ original,
255
+ installed: undefined as any,
256
+ };
257
+ state.installed = {
258
+ addChild: function (this: any, component: any) {
259
+ const result = state.original.addChild.call(this, component);
260
+ if (component && typeof component === "object") {
261
+ // Remember where the current assistant message component lives so a
262
+ // thinking-only group can be inserted right after it later.
263
+ if (isAssistantMessage(component)) {
264
+ const assistant = component as any;
265
+ ensureAssistantThinkingPatched(assistant);
266
+ const streaming = assistant.isStreaming === true;
267
+ if (streaming) {
268
+ assistant[LIVE_ASSISTANT_KEY] = true;
269
+ }
270
+ runtime.lastChatContainer = this;
271
+ runtime.lastStreamingComp = assistant;
272
+ assistant[IS_STREAMING_COMP] = streaming;
273
+ // Keep one open block across assistant messages until visible text.
274
+ // A rebuilt message that already has text is that boundary.
275
+ if (assistantHasVisibleText(assistant)) {
276
+ sealOpenGroupAtAssistantText(assistant);
277
+ } else {
278
+ const open = findOpenGroup();
279
+ if (open) placeGroupAfter(this, assistant, open);
280
+ }
281
+ // A run that started before its message component existed can now
282
+ // be placed in the transcript, in front of this message's text.
283
+ if (runtime.unattachedThinking.length > 0) ensureThinkingGroup();
284
+ flushPendingTextSeal();
285
+ }
286
+ maybeGroup(this, component);
287
+ stripAssistantPhantomPadding(this, component);
288
+ restoreAssistantAnchor(this, component);
289
+ }
290
+ return result;
291
+ },
292
+ removeChild: function (this: any, component: any) {
293
+ const group = component?.[PARENT_KEY];
294
+ if (isToolGroup(group) && (group as any)[PARENT_KEY] === this) {
295
+ group.removeTool(component);
296
+ if (group.children.length === 0 && group.entries.length === 0) runtime.groups.delete(group);
297
+ return;
298
+ }
299
+ releaseAssistantAnchors(component);
300
+ return state.original.removeChild.call(this, component);
301
+ },
302
+ clear: function (this: any) {
303
+ if (runtime.assistantContentContainers.has(this)) {
304
+ // AssistantMessageComponent rebuilds this container for every
305
+ // cumulative stream update. Keep sealed compact groups in the
306
+ // anchor map; restoreAssistantAnchor() reinserts each one before
307
+ // its matching Markdown child as the rebuild proceeds.
308
+ getAssistantContentState(this).nextTextOrdinal = 0;
309
+ return state.original.clear.call(this);
310
+ }
311
+ for (const child of [...(this.children ?? [])]) {
312
+ if (isToolGroup(child)) {
313
+ for (const tool of [...child.children] as any[]) delete tool[PARENT_KEY];
314
+ runtime.groups.delete(child);
315
+ }
316
+ releaseAssistantAnchors(child);
317
+ }
318
+ return state.original.clear.call(this);
319
+ },
320
+ };
321
+ prototype.addChild = state.installed.addChild;
322
+ prototype.removeChild = state.installed.removeChild;
323
+ prototype.clear = state.installed.clear;
324
+ stateMap.set(prototype, state);
325
+ }
326
+ host[patchKey] = stateMap;
327
+ }
package/src/guards.ts ADDED
@@ -0,0 +1,58 @@
1
+ /** 兼容不同 Pi/TUI 模块实例的组件识别与原生工具渲染抑制。 */
2
+ import { Container, Spacer, Markdown, Text } from "@earendil-works/pi-tui";
3
+ import { AssistantMessageComponent, ToolExecutionComponent } from "@earendil-works/pi-coding-agent";
4
+ import { ToolGroupComponent } from "./tool-group.js";
5
+ import { TOOL_RENDER_PATCH_KEY, PARENT_KEY } from "./constants.js";
6
+
7
+ export function isContainer(val: any): val is Container {
8
+ return Boolean(
9
+ val &&
10
+ (val instanceof Container ||
11
+ val.constructor?.name === "Container" ||
12
+ (Array.isArray((val as any).children) && typeof (val as any).addChild === "function")),
13
+ );
14
+ }
15
+
16
+ export function isAssistantMessage(val: any): val is AssistantMessageComponent {
17
+ return Boolean(
18
+ val &&
19
+ (val instanceof AssistantMessageComponent ||
20
+ val.constructor?.name === "AssistantMessageComponent"),
21
+ );
22
+ }
23
+
24
+ export function isToolExecution(val: any): val is ToolExecutionComponent {
25
+ return Boolean(
26
+ val &&
27
+ (val instanceof ToolExecutionComponent ||
28
+ val.constructor?.name === "ToolExecutionComponent"),
29
+ );
30
+ }
31
+
32
+ export function isSpacer(val: any): val is Spacer {
33
+ return Boolean(val && (val instanceof Spacer || val.constructor?.name === "Spacer"));
34
+ }
35
+
36
+ export function isMarkdown(val: any): val is Markdown {
37
+ return Boolean(val && (val instanceof Markdown || val.constructor?.name === "Markdown"));
38
+ }
39
+
40
+ export function isText(val: any): val is Text {
41
+ return Boolean(val && (val instanceof Text || val.constructor?.name === "Text"));
42
+ }
43
+
44
+ export function isToolGroup(val: any): val is ToolGroupComponent {
45
+ return Boolean(val && (val instanceof ToolGroupComponent || val.constructor?.name === "ToolGroupComponent"));
46
+ }
47
+
48
+ export function hideGroupedToolRender(tool: any): void {
49
+ const prototype = tool ? Object.getPrototypeOf(tool) : undefined;
50
+ if (!prototype || typeof prototype.render !== "function" || prototype[TOOL_RENDER_PATCH_KEY]) return;
51
+ const original = prototype.render as (this: any, width: number) => string[];
52
+ const installed = function (this: any, width: number): string[] {
53
+ if (this?.[PARENT_KEY]) return [];
54
+ return original.call(this, width);
55
+ };
56
+ prototype.render = installed;
57
+ prototype[TOOL_RENDER_PATCH_KEY] = { original, installed };
58
+ }