pi-minimalist 0.1.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.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/index.ts +132 -0
- package/package.json +21 -0
- package/src/bridge.ts +124 -0
- package/src/components.ts +173 -0
- package/src/config-file.ts +277 -0
- package/src/config-ui.ts +208 -0
- package/src/config.ts +246 -0
- package/src/core-patch.ts +510 -0
- package/src/row.ts +193 -0
- package/src/run-grouping.ts +378 -0
- package/src/thinking.ts +100 -0
- package/src/tool-renderer.ts +260 -0
- package/src/tools.ts +195 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUNTIME core patches — the replacement for the old patch-pi.sh bundle edits.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS WORKS (the discovery that removed the patch script)
|
|
5
|
+
* -----------------------------------------------------------
|
|
6
|
+
* Pi's bundled CLI loads extensions through jiti with
|
|
7
|
+
* `virtualModules: VIRTUAL_MODULES` (see core/extensions/loader.ts; the bundle
|
|
8
|
+
* sets `isBundledNode = true`). Those virtual modules are the bundle's OWN live
|
|
9
|
+
* module namespaces. So when this extension imports
|
|
10
|
+
* `@earendil-works/pi-coding-agent`, it receives the very same class objects the
|
|
11
|
+
* running TUI instantiates — not a second copy from `dist/`.
|
|
12
|
+
*
|
|
13
|
+
* `ToolExecutionComponent` and `AssistantMessageComponent` are public exports,
|
|
14
|
+
* and every seam the old patch script rewrote is a `prototype` method. Wrapping
|
|
15
|
+
* those prototypes at load time therefore produces exactly the behavior the perl
|
|
16
|
+
* substitutions produced, with three advantages:
|
|
17
|
+
*
|
|
18
|
+
* - a Pi upgrade cannot silently revert it (nothing on disk is modified);
|
|
19
|
+
* - no `node --check`, no marker greps, no per-release regex maintenance;
|
|
20
|
+
* - the wrappers are ordinary TypeScript, unit-testable against the real
|
|
21
|
+
* components (see test/core-patch.test.ts).
|
|
22
|
+
*
|
|
23
|
+
* Each wrapper reads its behavior from the process-global bridge slots at CALL
|
|
24
|
+
* time (see bridge.ts), so `/reload` swaps behavior without re-wrapping — and
|
|
25
|
+
* an unloaded extension degrades to Pi's native rendering.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
29
|
+
import { BRIDGE_SYMBOLS } from "./bridge.ts";
|
|
30
|
+
import { FoldableProse } from "./components.ts";
|
|
31
|
+
import type { Config } from "./config.ts";
|
|
32
|
+
import type { Row, ThemeLike } from "./row.ts";
|
|
33
|
+
|
|
34
|
+
type Globals = Record<symbol, unknown>;
|
|
35
|
+
|
|
36
|
+
function bridge<T>(key: keyof typeof BRIDGE_SYMBOLS): T | undefined {
|
|
37
|
+
return (globalThis as Globals)[Symbol.for(BRIDGE_SYMBOLS[key])] as T | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Applied-once marker per prototype, so /reload never double-wraps. */
|
|
41
|
+
const PATCHED = Symbol.for("pi.minimalist.corePatched");
|
|
42
|
+
|
|
43
|
+
function once(target: object, name: string, apply: () => void): void {
|
|
44
|
+
const marks = ((target as any)[PATCHED] ??= {});
|
|
45
|
+
if (marks[name]) return;
|
|
46
|
+
apply();
|
|
47
|
+
marks[name] = true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// The shapes this module needs from Pi. Structural, and deliberately loose:
|
|
52
|
+
// these are core internals, so a narrow `any` at the seam beats a fake
|
|
53
|
+
// full-fidelity type that would silently drift on the next release.
|
|
54
|
+
//
|
|
55
|
+
// Pi declares most of these members `private`, which makes its real classes
|
|
56
|
+
// structurally INCOMPATIBLE with any interface naming them (TS treats a private
|
|
57
|
+
// member as nominal). `private` is a compile-time notion only — at runtime these
|
|
58
|
+
// are ordinary properties — so patchCore() accepts the class objects loosely and
|
|
59
|
+
// each patch function keeps its precise internal shape for the body below.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
type ToolRendererBridge = {
|
|
63
|
+
renderShell?: "self" | "default";
|
|
64
|
+
handles?: (name: string) => boolean;
|
|
65
|
+
renderCall?: (name: string, args: unknown, theme: unknown, context: unknown) => Component;
|
|
66
|
+
renderResult?: (
|
|
67
|
+
name: string,
|
|
68
|
+
result: unknown,
|
|
69
|
+
options: unknown,
|
|
70
|
+
theme: unknown,
|
|
71
|
+
context: unknown,
|
|
72
|
+
nativeRenderer?: unknown,
|
|
73
|
+
) => Component | undefined;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type ToolExecutionProto = {
|
|
77
|
+
toolName: string;
|
|
78
|
+
toolCallId: string;
|
|
79
|
+
toolDefinition?: { renderCall?: unknown; renderResult?: unknown; renderShell?: string };
|
|
80
|
+
children: Component[];
|
|
81
|
+
contentBox: Component;
|
|
82
|
+
contentTextRegion: Component;
|
|
83
|
+
selfRenderContainer: Component;
|
|
84
|
+
callRendererComponent?: Component;
|
|
85
|
+
resultRendererComponent?: Component;
|
|
86
|
+
rendererState: { callLine?: unknown; originalResult?: unknown };
|
|
87
|
+
updateDisplay(): void;
|
|
88
|
+
getCallRenderer(): unknown;
|
|
89
|
+
getResultRenderer(): unknown;
|
|
90
|
+
hasRendererDefinition(): boolean;
|
|
91
|
+
getRenderShell(): string;
|
|
92
|
+
createResultFallback(): Component | undefined;
|
|
93
|
+
render(width: number): string[];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* PATCH 1 — give the compact renderer priority and remove the self-shell spacer
|
|
98
|
+
* when a tool row temporarily hosts the one folded-activity summary.
|
|
99
|
+
*
|
|
100
|
+
* Replaces the old `getCallRenderer` / `getResultRenderer` /
|
|
101
|
+
* `hasRendererDefinition` / `getRenderShell` bundle rewrite, plus both
|
|
102
|
+
* `create*Fallback` rewrites. The fallbacks no longer need patching: because
|
|
103
|
+
* `hasRendererDefinition()` and the two getters now always answer for a handled
|
|
104
|
+
* tool, core never reaches its verbose `formatToolExecution()` branch, and
|
|
105
|
+
* late-registered tools with `toolDefinition === undefined` are covered by the
|
|
106
|
+
* same condition.
|
|
107
|
+
*
|
|
108
|
+
* The native tool definitions are NOT touched: this is render-only, so built-ins
|
|
109
|
+
* keep their builtin source ownership and pi-subagents keeps exposing
|
|
110
|
+
* read/bash/write to child runtimes.
|
|
111
|
+
*/
|
|
112
|
+
export function patchToolExecution(proto: ToolExecutionProto): void {
|
|
113
|
+
const nativeResultFallback = proto.createResultFallback;
|
|
114
|
+
const nativeRender = proto.render;
|
|
115
|
+
const nativeUpdateDisplay = proto.updateDisplay;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* "Ours" when the bridge claims this tool name — and ONLY then.
|
|
119
|
+
*
|
|
120
|
+
* `handles()` is now the single authority (blacklist plus the master switch),
|
|
121
|
+
* so it covers rendererless MCP/third-party tools and late-registered tools
|
|
122
|
+
* whose definition is missing from the UI lookup entirely.
|
|
123
|
+
*
|
|
124
|
+
* There used to be an extra "...or the tool has no renderer of its own"
|
|
125
|
+
* fallback here. Under the old whitelist it was load-bearing. With a blacklist
|
|
126
|
+
* it silently OVERRODE the user: an excluded tool with no renderer, and every
|
|
127
|
+
* tool when `compactToolRows` was off, got compacted anyway.
|
|
128
|
+
*/
|
|
129
|
+
function claims(this: ToolExecutionProto): ToolRendererBridge | undefined {
|
|
130
|
+
const renderer = bridge<ToolRendererBridge>("toolRenderer");
|
|
131
|
+
if (!renderer?.renderCall) return undefined;
|
|
132
|
+
return renderer.handles?.(this.toolName) ? renderer : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
once(proto, "toolExecution", () => {
|
|
136
|
+
// Pi attaches exactly one shell after its leading spacer in the constructor.
|
|
137
|
+
// Its getters change live, but updateDisplay() never replaces that child.
|
|
138
|
+
const ownership = new WeakMap<ToolExecutionProto, boolean>();
|
|
139
|
+
function syncShell(this: ToolExecutionProto): boolean {
|
|
140
|
+
const claimed = claims.call(this) !== undefined;
|
|
141
|
+
const shell = this.hasRendererDefinition()
|
|
142
|
+
? this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox
|
|
143
|
+
: this.contentTextRegion;
|
|
144
|
+
const previous = ownership.get(this);
|
|
145
|
+
ownership.set(this, claimed);
|
|
146
|
+
if (previous === claimed && this.children[1] === shell) return false;
|
|
147
|
+
|
|
148
|
+
if (previous !== undefined) {
|
|
149
|
+
// /reload can replace the CompactLine class while this wrapper survives.
|
|
150
|
+
const callLine = this.rendererState.callLine as { stopTicker?: () => void } | undefined;
|
|
151
|
+
if (typeof callLine?.stopTicker === "function") callLine.stopTicker();
|
|
152
|
+
delete this.rendererState.callLine;
|
|
153
|
+
delete this.rendererState.originalResult;
|
|
154
|
+
this.callRendererComponent = undefined;
|
|
155
|
+
this.resultRendererComponent = undefined;
|
|
156
|
+
}
|
|
157
|
+
// Keep the spacer first and Pi's existing image/spacer children after the
|
|
158
|
+
// shell; native updateDisplay() refreshes their content on this same pass.
|
|
159
|
+
this.children[1] = shell;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
proto.updateDisplay = function () {
|
|
164
|
+
syncShell.call(this);
|
|
165
|
+
nativeUpdateDisplay.call(this);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
proto.getCallRenderer = function () {
|
|
169
|
+
const renderer = claims.call(this);
|
|
170
|
+
if (!renderer?.renderCall) return this.toolDefinition?.renderCall;
|
|
171
|
+
return (args: unknown, theme: unknown, context: unknown) =>
|
|
172
|
+
renderer.renderCall!(this.toolName, args, theme, context);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
proto.getResultRenderer = function () {
|
|
176
|
+
const renderer = claims.call(this);
|
|
177
|
+
if (!renderer?.renderResult) return this.toolDefinition?.renderResult;
|
|
178
|
+
const nativeRenderer = this.toolDefinition?.renderResult;
|
|
179
|
+
return (result: unknown, options: unknown, theme: unknown, context: unknown) => {
|
|
180
|
+
const component = renderer.renderResult!(this.toolName, result, options, theme, context, nativeRenderer);
|
|
181
|
+
// `undefined` means "show Pi's own text output". Core's RESULT-RENDERER
|
|
182
|
+
// path (unlike its fallback path) would wrap undefined in a MouseRegion
|
|
183
|
+
// and crash at render, so resolve the fallback here instead.
|
|
184
|
+
return component ?? nativeResultFallback.call(this) ?? EMPTY;
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// A handled tool always has a renderer, so core must never treat the row as
|
|
189
|
+
// definition-less and fall back to the verbose JSON card.
|
|
190
|
+
const nativeHasDefinition = proto.hasRendererDefinition;
|
|
191
|
+
proto.hasRendererDefinition = function (this: ToolExecutionProto) {
|
|
192
|
+
return nativeHasDefinition.call(this) || claims.call(this) !== undefined;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const nativeRenderShell = proto.getRenderShell;
|
|
196
|
+
proto.getRenderShell = function () {
|
|
197
|
+
const renderer = claims.call(this);
|
|
198
|
+
return renderer?.renderShell ?? nativeRenderShell.call(this);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
proto.render = function (width: number) {
|
|
202
|
+
// A settings toggle may only request a repaint, not an updateDisplay().
|
|
203
|
+
// Rebuild once on transition, never on every render (or during a renderer).
|
|
204
|
+
if (syncShell.call(this)) nativeUpdateDisplay.call(this);
|
|
205
|
+
const lines = nativeRender.call(this, width);
|
|
206
|
+
const ownsSummary = bridge<(id: string) => boolean>("activitySummaryRow")?.(this.toolCallId);
|
|
207
|
+
// The summary must have the same one-line separation regardless of which
|
|
208
|
+
// tool/assistant component happened to become its host.
|
|
209
|
+
return ownsSummary ? withOneLeadingBlank(lines) : lines;
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Renders nothing, for the "no result content at all" case. */
|
|
215
|
+
const EMPTY: Component = { render: () => [], invalidate: () => {} };
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// PATCH 2 — thinking blocks
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
type ThinkingBridge = (
|
|
222
|
+
text: string,
|
|
223
|
+
theme: unknown,
|
|
224
|
+
pad: number,
|
|
225
|
+
streaming?: boolean,
|
|
226
|
+
owner?: object,
|
|
227
|
+
runIndex?: number,
|
|
228
|
+
) => Component;
|
|
229
|
+
|
|
230
|
+
type AssistantProto = {
|
|
231
|
+
contentContainer: { children: Component[] };
|
|
232
|
+
hideThinkingBlock: boolean;
|
|
233
|
+
thinkingVisibilityOverrides: Map<number, boolean>;
|
|
234
|
+
markdownTheme: Record<string, unknown>;
|
|
235
|
+
outputPad: number;
|
|
236
|
+
isStreaming: boolean;
|
|
237
|
+
lastMessage?: any;
|
|
238
|
+
updateContent(message: any, isStreaming?: boolean): void;
|
|
239
|
+
render(width: number): string[];
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** One thinking run: consecutive `thinking` blocks, exactly as core groups them. */
|
|
243
|
+
function thinkingRuns(message: any): string[][] {
|
|
244
|
+
const runs: string[][] = [];
|
|
245
|
+
const content: any[] = message?.content ?? [];
|
|
246
|
+
for (let i = 0; i < content.length; i++) {
|
|
247
|
+
if (content[i]?.type !== "thinking") continue;
|
|
248
|
+
const blocks: string[] = [];
|
|
249
|
+
for (; i < content.length && content[i]?.type === "thinking"; i++) {
|
|
250
|
+
const text = String(content[i].thinking ?? "").trim();
|
|
251
|
+
if (text) blocks.push(text);
|
|
252
|
+
}
|
|
253
|
+
i--;
|
|
254
|
+
if (blocks.length) runs.push(blocks);
|
|
255
|
+
}
|
|
256
|
+
return runs;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Core wraps every thinking run — and ONLY a thinking run — in a MouseRegion, so
|
|
261
|
+
* the Nth MouseRegion in contentContainer is the Nth thinking run. That is the
|
|
262
|
+
* seam this patch uses instead of the old bundle rewrite.
|
|
263
|
+
*
|
|
264
|
+
* Recognized STRUCTURALLY, not by `instanceof` or `constructor.name`, because
|
|
265
|
+
* neither is reliable across Pi's loading modes:
|
|
266
|
+
* - `instanceof` fails when the bundle INLINES its own copy of pi-tui: the
|
|
267
|
+
* bundle's MouseRegion is then a different class object from the one an
|
|
268
|
+
* `import` resolves to (verified against the real bundle);
|
|
269
|
+
* - `constructor.name` depends on the minifier keeping inferred class names.
|
|
270
|
+
* Field names are part of the runtime behavior of these classes, so `child` +
|
|
271
|
+
* `onMouse` is the stable signal.
|
|
272
|
+
*/
|
|
273
|
+
function isMouseRegion(value: any): boolean {
|
|
274
|
+
return typeof value?.onMouse === "function" && value.child !== undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function thinkingRegions(children: Component[]): any[] {
|
|
278
|
+
return children.filter(isMouseRegion);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* PATCH 2 — collapsed preview, streaming expansion, all-purple expanded
|
|
283
|
+
* Markdown, and the run-grouping chronology/spacer hooks.
|
|
284
|
+
*
|
|
285
|
+
* Replaces five bundle rewrites inside `updateContent` with one wrapper:
|
|
286
|
+
*
|
|
287
|
+
* - active thinking: a streaming block stays COLLAPSED by default (its compact
|
|
288
|
+
* preview already shows the latest text). `keepActiveThinkingExpanded` forces
|
|
289
|
+
* it open for the duration of the original call by neutralizing
|
|
290
|
+
* `hideThinkingBlock` + the override map, then restoring both. Either way the
|
|
291
|
+
* click handler stores into the LIVE map, so an explicit Ctrl+T expansion
|
|
292
|
+
* always survives — the default only decides what happens when the user has
|
|
293
|
+
* not expressed a preference.
|
|
294
|
+
* - collapsed preview: swap the hidden run's `Text` for our compact line.
|
|
295
|
+
* - expanded theme: recolor the run's `Markdown` in place (its private `theme`
|
|
296
|
+
* field is read at render time, and this runs before the first render).
|
|
297
|
+
* - chronology: observe prose/thinking in transcript order, which is what run
|
|
298
|
+
* folding needs.
|
|
299
|
+
* - message spacer: replace the leading `Spacer` with one that asks, at render
|
|
300
|
+
* time, whether it still belongs.
|
|
301
|
+
*/
|
|
302
|
+
export function patchAssistantMessage(proto: AssistantProto): void {
|
|
303
|
+
once(proto, "assistantMessage", () => {
|
|
304
|
+
const nativeUpdateContent = proto.updateContent;
|
|
305
|
+
const nativeRender = proto.render;
|
|
306
|
+
const compactState = new WeakMap<AssistantProto, boolean>();
|
|
307
|
+
|
|
308
|
+
proto.updateContent = function (this: AssistantProto, message: any, isStreaming = this.isStreaming) {
|
|
309
|
+
const savedHide = this.hideThinkingBlock;
|
|
310
|
+
const savedOverrides = this.thinkingVisibilityOverrides;
|
|
311
|
+
// Default: a streaming block stays collapsed to its compact preview, which
|
|
312
|
+
// already shows the newest text. Opt in to force it open instead.
|
|
313
|
+
const forceOpen = isStreaming && bridge<() => boolean>("keepActiveThinkingExpanded")?.() === true;
|
|
314
|
+
if (forceOpen) {
|
|
315
|
+
this.hideThinkingBlock = false;
|
|
316
|
+
this.thinkingVisibilityOverrides = new Map();
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
nativeUpdateContent.call(this, message, isStreaming);
|
|
320
|
+
} finally {
|
|
321
|
+
this.hideThinkingBlock = savedHide;
|
|
322
|
+
this.thinkingVisibilityOverrides = savedOverrides;
|
|
323
|
+
}
|
|
324
|
+
const compact = compactThinking();
|
|
325
|
+
decorateThinking(this, message, isStreaming, compact);
|
|
326
|
+
compactState.set(this, compact);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
proto.render = function (width: number) {
|
|
330
|
+
// A settings toggle requests a repaint, not necessarily updateContent().
|
|
331
|
+
// Rebuild from Pi's original components only on an ownership transition;
|
|
332
|
+
// this also restores native Markdown colors and preserves click overrides.
|
|
333
|
+
if (this.lastMessage && compactState.get(this) !== compactThinking()) {
|
|
334
|
+
this.updateContent(this.lastMessage, this.isStreaming);
|
|
335
|
+
}
|
|
336
|
+
const lines = nativeRender.call(this, width);
|
|
337
|
+
const view = bridge<(owner: object) => "normal" | "hidden" | "summary">("activityMessageView")?.(this);
|
|
338
|
+
if (view === "hidden") return [];
|
|
339
|
+
return view === "summary" ? withOneLeadingBlank(lines) : lines;
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function compactThinking(): boolean {
|
|
345
|
+
return bridge<Config>("config")?.get("thinkingAsToolCall") === true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function decorateThinking(component: AssistantProto, message: any, isStreaming: boolean, compact: boolean): void {
|
|
349
|
+
const preview = bridge<ThinkingBridge>("thinkingPreview");
|
|
350
|
+
const purple = bridge<(base: Record<string, unknown>, theme: unknown) => Record<string, unknown>>(
|
|
351
|
+
"thinkingMarkdownTheme",
|
|
352
|
+
);
|
|
353
|
+
const observeThinking = bridge<(owner: object, run: number, streaming: boolean, hidden: boolean) => string>(
|
|
354
|
+
"observeThinking",
|
|
355
|
+
);
|
|
356
|
+
const observeProse = bridge<(
|
|
357
|
+
owner: object,
|
|
358
|
+
contentIndex: number,
|
|
359
|
+
signal: {
|
|
360
|
+
phase?: "commentary" | "final_answer";
|
|
361
|
+
stopReason?: string;
|
|
362
|
+
streaming?: boolean;
|
|
363
|
+
timestamp?: number;
|
|
364
|
+
},
|
|
365
|
+
) => string>("observeProse");
|
|
366
|
+
const proseView = bridge<(id: string, theme: ThemeLike) => Row | null | undefined>("proseView");
|
|
367
|
+
const messageSpacer = bridge<(owner: object) => boolean>("messageSpacer");
|
|
368
|
+
const theme = liveTheme();
|
|
369
|
+
|
|
370
|
+
const children = component.contentContainer.children;
|
|
371
|
+
const regions = thinkingRegions(children);
|
|
372
|
+
const runs = thinkingRuns(message);
|
|
373
|
+
|
|
374
|
+
// Direct Markdown children correspond one-for-one with non-empty text blocks;
|
|
375
|
+
// thinking Markdown lives inside MouseRegion and is deliberately excluded.
|
|
376
|
+
const proseChildren = children
|
|
377
|
+
.map((child, index) => ({ child: child as any, index }))
|
|
378
|
+
.filter(({ child }) => typeof child?.text === "string" && child?.theme !== undefined);
|
|
379
|
+
|
|
380
|
+
// Transcript order: core interleaves prose and thinking while walking
|
|
381
|
+
// message.content, and run folding depends on that order.
|
|
382
|
+
let run = 0;
|
|
383
|
+
let prose = 0;
|
|
384
|
+
for (let i = 0; i < (message?.content?.length ?? 0); i++) {
|
|
385
|
+
const content = message.content[i];
|
|
386
|
+
if (content?.type === "text" && String(content.text ?? "").trim()) {
|
|
387
|
+
const phase = textPhase(content.textSignature);
|
|
388
|
+
const id = observeProse?.(component, i, {
|
|
389
|
+
phase,
|
|
390
|
+
stopReason: message.stopReason,
|
|
391
|
+
streaming: isStreaming,
|
|
392
|
+
timestamp: message.timestamp,
|
|
393
|
+
});
|
|
394
|
+
const target = proseChildren[prose++];
|
|
395
|
+
if (id && target && proseView && theme) {
|
|
396
|
+
children[target.index] = new FoldableProse(target.child, () => proseView(id, theme as ThemeLike));
|
|
397
|
+
}
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (content?.type !== "thinking") continue;
|
|
401
|
+
while (i + 1 < message.content.length && message.content[i + 1]?.type === "thinking") i++;
|
|
402
|
+
const blocks = runs[run];
|
|
403
|
+
if (!blocks) continue;
|
|
404
|
+
const entry = regions[run];
|
|
405
|
+
const runIndex = run++;
|
|
406
|
+
if (!entry) continue;
|
|
407
|
+
|
|
408
|
+
const inner = entry.child as any;
|
|
409
|
+
// Collapsed runs are a Text (the hidden label); expanded runs are a Markdown.
|
|
410
|
+
// Only Markdown carries a `theme` field, which is also the field the
|
|
411
|
+
// all-purple recolor needs, so one check serves both branches.
|
|
412
|
+
const hidden = inner?.theme === undefined;
|
|
413
|
+
// Pass `hidden` THROUGH, never `!hidden`. bridge.ts negates it into
|
|
414
|
+
// `expanded` itself, so negating here too inverted all run folding: expanded
|
|
415
|
+
// thinking became foldable (swallowing whole runs of tool rows into one
|
|
416
|
+
// summary) and collapsed thinking stopped folding entirely.
|
|
417
|
+
const id = observeThinking?.(component, runIndex, isStreaming, hidden);
|
|
418
|
+
|
|
419
|
+
if (!compact) {
|
|
420
|
+
// Native thinking still participates in the separately enabled activity
|
|
421
|
+
// fold, just like native prose. Otherwise keep Pi's component untouched.
|
|
422
|
+
if (id && proseView && theme) {
|
|
423
|
+
entry.child = new FoldableProse(inner, () => proseView(id, theme as ThemeLike));
|
|
424
|
+
}
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (hidden) {
|
|
429
|
+
if (!preview || !theme) continue;
|
|
430
|
+
// MouseRegion.child is `private` in TS only; reassigning keeps the
|
|
431
|
+
// existing click handler (and therefore the expand/collapse toggle).
|
|
432
|
+
entry.child = preview(
|
|
433
|
+
blocks.join("\n"),
|
|
434
|
+
theme,
|
|
435
|
+
component.outputPad,
|
|
436
|
+
isStreaming,
|
|
437
|
+
component,
|
|
438
|
+
runIndex,
|
|
439
|
+
);
|
|
440
|
+
} else if (purple && theme && inner?.theme !== undefined) {
|
|
441
|
+
// Markdown reads its (TS-private) theme at render time, and this runs
|
|
442
|
+
// before the first render, so recoloring in place needs no reconstruction.
|
|
443
|
+
inner.theme = purple(component.markdownTheme, theme);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Leading spacer: hidden only when every row of this message is hidden.
|
|
448
|
+
// Identified by what it DOES (renders exactly one blank line) rather than by
|
|
449
|
+
// class, for the same cross-loading-mode reason as isMouseRegion.
|
|
450
|
+
const first = children[0] as any;
|
|
451
|
+
if (messageSpacer && first && typeof first.setLines === "function") {
|
|
452
|
+
children[0] = {
|
|
453
|
+
render: () => (messageSpacer(component) === false ? [] : [""]),
|
|
454
|
+
invalidate: () => {},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Exactly one physical blank before a visible activity summary. */
|
|
460
|
+
function withOneLeadingBlank(lines: string[]): string[] {
|
|
461
|
+
const firstVisible = lines.findIndex((line) => !visiblyBlank(line));
|
|
462
|
+
if (firstVisible === -1) return [];
|
|
463
|
+
if (firstVisible === 0) return ["", ...lines];
|
|
464
|
+
// Keep the first blank because it may carry Pi's OSC 133 zone-start marker;
|
|
465
|
+
// discard every other spacer-only line before the summary.
|
|
466
|
+
return [lines[0], ...lines.slice(firstVisible)];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function visiblyBlank(line: string): boolean {
|
|
470
|
+
return line
|
|
471
|
+
.replace(/\x1b\[[0-9;]*m/g, "")
|
|
472
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
473
|
+
.trim() === "";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function textPhase(signature: unknown): "commentary" | "final_answer" | undefined {
|
|
477
|
+
if (typeof signature !== "string" || !signature.startsWith("{")) return undefined;
|
|
478
|
+
try {
|
|
479
|
+
const phase = JSON.parse(signature).phase;
|
|
480
|
+
return phase === "commentary" || phase === "final_answer" ? phase : undefined;
|
|
481
|
+
} catch {
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Pi's live Theme instance, shared through globalThis by its theme module. */
|
|
487
|
+
function liveTheme(): unknown {
|
|
488
|
+
return (globalThis as Globals)[Symbol.for("@earendil-works/pi-coding-agent:theme")];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Install every runtime core patch. Idempotent across /reload.
|
|
493
|
+
*
|
|
494
|
+
* Takes the module namespace of `@earendil-works/pi-coding-agent`. Each class is
|
|
495
|
+
* optional so a Pi release that drops or renames one degrades to native
|
|
496
|
+
* rendering instead of throwing during extension load (which would disable the
|
|
497
|
+
* whole extension). test/integration.test.ts fails loudly in that case.
|
|
498
|
+
*/
|
|
499
|
+
export function patchCore(core: {
|
|
500
|
+
ToolExecutionComponent?: new (...args: never[]) => unknown;
|
|
501
|
+
AssistantMessageComponent?: new (...args: never[]) => unknown;
|
|
502
|
+
}): void {
|
|
503
|
+
// Pi's `private` members make these classes structurally unassignable to the
|
|
504
|
+
// interfaces above, so cross the boundary once, here, explicitly.
|
|
505
|
+
const proto = (target: unknown) => (target as { prototype: any } | undefined)?.prototype;
|
|
506
|
+
const tool = proto(core.ToolExecutionComponent);
|
|
507
|
+
const assistant = proto(core.AssistantMessageComponent);
|
|
508
|
+
if (tool) patchToolExecution(tool);
|
|
509
|
+
if (assistant) patchAssistantMessage(assistant);
|
|
510
|
+
}
|