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.
@@ -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
+ }