pi-minimalist 0.1.2 → 0.2.2
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 +26 -5
- package/index.ts +13 -35
- package/package.json +1 -1
- package/src/bridge.ts +76 -55
- package/src/components.ts +97 -32
- package/src/config-file.ts +26 -81
- package/src/config-ui.ts +42 -34
- package/src/config.ts +59 -37
- package/src/core-patch.ts +167 -178
- package/src/row.ts +44 -28
- package/src/run-grouping.ts +98 -52
- package/src/thinking.ts +5 -8
- package/src/tool-renderer.ts +21 -10
- package/src/tools.ts +57 -42
package/src/core-patch.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RUNTIME core patches
|
|
2
|
+
* RUNTIME core patches: wrappers on Pi's real component prototypes.
|
|
3
3
|
*
|
|
4
|
-
* WHY THIS WORKS
|
|
5
|
-
*
|
|
4
|
+
* WHY THIS WORKS
|
|
5
|
+
* --------------
|
|
6
6
|
* Pi's bundled CLI loads extensions through jiti with
|
|
7
7
|
* `virtualModules: VIRTUAL_MODULES` (see core/extensions/loader.ts; the bundle
|
|
8
8
|
* sets `isBundledNode = true`). Those virtual modules are the bundle's OWN live
|
|
@@ -10,32 +10,26 @@
|
|
|
10
10
|
* `@earendil-works/pi-coding-agent`, it receives the very same class objects the
|
|
11
11
|
* running TUI instantiates — not a second copy from `dist/`.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Every seam is a `prototype` method on a public export, so wrapping at load time
|
|
14
|
+
* leaves nothing on disk modified (a Pi upgrade cannot silently revert it) and
|
|
15
|
+
* the wrappers are ordinary TypeScript, unit-testable against the real
|
|
16
|
+
* components (see test/core-patch.test.ts).
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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.
|
|
18
|
+
* Each wrapper reads its behavior from the process-global `Bridge` at CALL time
|
|
19
|
+
* (see bridge.ts), so `/reload` swaps behavior without re-wrapping — and an
|
|
20
|
+
* unloaded extension degrades to Pi's native rendering.
|
|
26
21
|
*/
|
|
27
22
|
|
|
28
23
|
import type { Component } from "@earendil-works/pi-tui";
|
|
29
|
-
import {
|
|
30
|
-
import { FoldableProse } from "./components.ts";
|
|
31
|
-
import type {
|
|
32
|
-
import type {
|
|
24
|
+
import { readBridge as bridge, type Bridge } from "./bridge.ts";
|
|
25
|
+
import { EmptyComponent, FoldableProse } from "./components.ts";
|
|
26
|
+
import type { ThemeLike } from "./row.ts";
|
|
27
|
+
import type { NativeResultRenderer, ToolRenderer } from "./tool-renderer.ts";
|
|
33
28
|
|
|
34
29
|
type Globals = Record<symbol, unknown>;
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
31
|
+
const EMPTY = new EmptyComponent();
|
|
32
|
+
const barrier = (id: string) => bridge()?.observeBarrier(id);
|
|
39
33
|
|
|
40
34
|
/** Applied-once marker per prototype, so /reload never double-wraps. */
|
|
41
35
|
const PATCHED = Symbol.for("pi.minimalist.corePatched");
|
|
@@ -59,20 +53,6 @@ function once(target: object, name: string, apply: () => void): void {
|
|
|
59
53
|
// each patch function keeps its precise internal shape for the body below.
|
|
60
54
|
// ---------------------------------------------------------------------------
|
|
61
55
|
|
|
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
56
|
type ToolExecutionProto = {
|
|
77
57
|
toolName: string;
|
|
78
58
|
toolCallId: string;
|
|
@@ -94,16 +74,12 @@ type ToolExecutionProto = {
|
|
|
94
74
|
};
|
|
95
75
|
|
|
96
76
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
77
|
+
* Give the compact renderer priority and normalize the self-shell spacer when a
|
|
78
|
+
* tool row hosts the one folded-activity summary.
|
|
99
79
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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.
|
|
80
|
+
* `hasRendererDefinition()` and the two getters always answer for a handled tool,
|
|
81
|
+
* so core never reaches its verbose `formatToolExecution()` branch, and
|
|
82
|
+
* late-registered tools with `toolDefinition === undefined` are covered too.
|
|
107
83
|
*
|
|
108
84
|
* The native tool definitions are NOT touched: this is render-only, so built-ins
|
|
109
85
|
* keep their builtin source ownership and pi-subagents keeps exposing
|
|
@@ -117,19 +93,15 @@ export function patchToolExecution(proto: ToolExecutionProto): void {
|
|
|
117
93
|
/**
|
|
118
94
|
* "Ours" when the bridge claims this tool name — and ONLY then.
|
|
119
95
|
*
|
|
120
|
-
* `handles()` is
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
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.
|
|
96
|
+
* `handles()` is the single authority (blacklist plus the master switch), so it
|
|
97
|
+
* covers rendererless MCP/third-party tools and late-registered tools whose
|
|
98
|
+
* definition is missing from the UI lookup entirely. Do NOT also claim "any
|
|
99
|
+
* tool with no renderer of its own": that overrides the user's exclusions and
|
|
100
|
+
* the master switch.
|
|
128
101
|
*/
|
|
129
|
-
function claims(this: ToolExecutionProto):
|
|
130
|
-
const renderer = bridge
|
|
131
|
-
|
|
132
|
-
return renderer.handles?.(this.toolName) ? renderer : undefined;
|
|
102
|
+
function claims(this: ToolExecutionProto): ToolRenderer | undefined {
|
|
103
|
+
const renderer = bridge()?.toolRenderer;
|
|
104
|
+
return renderer?.handles(this.toolName) ? renderer : undefined;
|
|
133
105
|
}
|
|
134
106
|
|
|
135
107
|
once(proto, "toolExecution", () => {
|
|
@@ -138,6 +110,8 @@ export function patchToolExecution(proto: ToolExecutionProto): void {
|
|
|
138
110
|
const ownership = new WeakMap<ToolExecutionProto, boolean>();
|
|
139
111
|
function syncShell(this: ToolExecutionProto): boolean {
|
|
140
112
|
const claimed = claims.call(this) !== undefined;
|
|
113
|
+
// A card we do not draw is still visible: let it cut folded runs.
|
|
114
|
+
if (!claimed && this.toolCallId) barrier(this.toolCallId);
|
|
141
115
|
const shell = this.hasRendererDefinition()
|
|
142
116
|
? this.getRenderShell() === "self" ? this.selfRenderContainer : this.contentBox
|
|
143
117
|
: this.contentTextRegion;
|
|
@@ -167,17 +141,17 @@ export function patchToolExecution(proto: ToolExecutionProto): void {
|
|
|
167
141
|
|
|
168
142
|
proto.getCallRenderer = function () {
|
|
169
143
|
const renderer = claims.call(this);
|
|
170
|
-
if (!renderer
|
|
144
|
+
if (!renderer) return this.toolDefinition?.renderCall;
|
|
171
145
|
return (args: unknown, theme: unknown, context: unknown) =>
|
|
172
|
-
renderer.renderCall
|
|
146
|
+
renderer.renderCall(this.toolName, args, theme, context);
|
|
173
147
|
};
|
|
174
148
|
|
|
175
149
|
proto.getResultRenderer = function () {
|
|
176
150
|
const renderer = claims.call(this);
|
|
177
|
-
if (!renderer
|
|
178
|
-
const nativeRenderer = this.toolDefinition?.renderResult;
|
|
151
|
+
if (!renderer) return this.toolDefinition?.renderResult;
|
|
152
|
+
const nativeRenderer = this.toolDefinition?.renderResult as NativeResultRenderer | undefined;
|
|
179
153
|
return (result: unknown, options: unknown, theme: unknown, context: unknown) => {
|
|
180
|
-
const component = renderer.renderResult
|
|
154
|
+
const component = renderer.renderResult(this.toolName, result, options, theme, context, nativeRenderer);
|
|
181
155
|
// `undefined` means "show Pi's own text output". Core's RESULT-RENDERER
|
|
182
156
|
// path (unlike its fallback path) would wrap undefined in a MouseRegion
|
|
183
157
|
// and crash at render, so resolve the fallback here instead.
|
|
@@ -203,7 +177,7 @@ export function patchToolExecution(proto: ToolExecutionProto): void {
|
|
|
203
177
|
// Rebuild once on transition, never on every render (or during a renderer).
|
|
204
178
|
if (syncShell.call(this)) nativeUpdateDisplay.call(this);
|
|
205
179
|
const lines = nativeRender.call(this, width);
|
|
206
|
-
const ownsSummary = bridge
|
|
180
|
+
const ownsSummary = bridge()?.activitySummaryRow(this.toolCallId);
|
|
207
181
|
// The summary must have the same one-line separation regardless of which
|
|
208
182
|
// tool/assistant component happened to become its host.
|
|
209
183
|
return ownsSummary ? withOneLeadingBlank(lines) : lines;
|
|
@@ -211,34 +185,40 @@ export function patchToolExecution(proto: ToolExecutionProto): void {
|
|
|
211
185
|
});
|
|
212
186
|
}
|
|
213
187
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
188
|
+
/**
|
|
189
|
+
* A user message is visible but never observed otherwise, so without this a
|
|
190
|
+
* folded run could jump over it. It is a barrier: it only cuts runs.
|
|
191
|
+
*/
|
|
192
|
+
export function patchUserMessage(proto: { rebuild(): void }): void {
|
|
193
|
+
once(proto, "userMessage", () => {
|
|
194
|
+
const nativeRebuild = proto.rebuild;
|
|
195
|
+
// Keyed on the text: Pi builds NEW components for the same messages on every
|
|
196
|
+
// chat rebuild, and a per-component id would append a fresh barrier each time.
|
|
197
|
+
proto.rebuild = function (this: { rebuild(): void; text: string }) {
|
|
198
|
+
nativeRebuild.call(this);
|
|
199
|
+
barrier("user:" + this.text);
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
}
|
|
216
203
|
|
|
217
204
|
// ---------------------------------------------------------------------------
|
|
218
|
-
//
|
|
205
|
+
// Assistant messages: thinking, prose, summaries
|
|
219
206
|
// ---------------------------------------------------------------------------
|
|
220
207
|
|
|
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
208
|
type AssistantProto = {
|
|
231
209
|
contentContainer: { children: Component[] };
|
|
232
210
|
hideThinkingBlock: boolean;
|
|
233
211
|
thinkingVisibilityOverrides: Map<number, boolean>;
|
|
234
212
|
markdownTheme: Record<string, unknown>;
|
|
235
|
-
outputPad: number;
|
|
236
213
|
isStreaming: boolean;
|
|
237
214
|
lastMessage?: any;
|
|
238
215
|
updateContent(message: any, isStreaming?: boolean): void;
|
|
239
216
|
render(width: number): string[];
|
|
217
|
+
handleMouse(event: MouseLike): unknown;
|
|
240
218
|
};
|
|
241
219
|
|
|
220
|
+
type MouseLike = { y: number; height: number };
|
|
221
|
+
|
|
242
222
|
/** One thinking run: consecutive `thinking` blocks, exactly as core groups them. */
|
|
243
223
|
function thinkingRuns(message: any): string[][] {
|
|
244
224
|
const runs: string[][] = [];
|
|
@@ -258,8 +238,7 @@ function thinkingRuns(message: any): string[][] {
|
|
|
258
238
|
|
|
259
239
|
/**
|
|
260
240
|
* 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.
|
|
262
|
-
* seam this patch uses instead of the old bundle rewrite.
|
|
241
|
+
* the Nth MouseRegion in contentContainer is the Nth thinking run.
|
|
263
242
|
*
|
|
264
243
|
* Recognized STRUCTURALLY, not by `instanceof` or `constructor.name`, because
|
|
265
244
|
* neither is reliable across Pi's loading modes:
|
|
@@ -274,15 +253,10 @@ function isMouseRegion(value: any): boolean {
|
|
|
274
253
|
return typeof value?.onMouse === "function" && value.child !== undefined;
|
|
275
254
|
}
|
|
276
255
|
|
|
277
|
-
function thinkingRegions(children: Component[]): any[] {
|
|
278
|
-
return children.filter(isMouseRegion);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
256
|
/**
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* Replaces five bundle rewrites inside `updateContent` with one wrapper:
|
|
257
|
+
* Collapsed preview, streaming expansion, all-purple expanded Markdown, the
|
|
258
|
+
* run-grouping chronology/spacer hooks, and the summary spacing and mouse
|
|
259
|
+
* correction in `render` / `handleMouse`. `updateContent` does:
|
|
286
260
|
*
|
|
287
261
|
* - active thinking: a streaming block stays COLLAPSED by default (its compact
|
|
288
262
|
* preview already shows the latest text). `keepActiveThinkingExpanded` forces
|
|
@@ -303,14 +277,19 @@ export function patchAssistantMessage(proto: AssistantProto): void {
|
|
|
303
277
|
once(proto, "assistantMessage", () => {
|
|
304
278
|
const nativeUpdateContent = proto.updateContent;
|
|
305
279
|
const nativeRender = proto.render;
|
|
280
|
+
const nativeHandleMouse = proto.handleMouse;
|
|
306
281
|
const compactState = new WeakMap<AssistantProto, boolean>();
|
|
282
|
+
// withOneLeadingBlank() changes the lines AFTER Container.render() recorded
|
|
283
|
+
// its mouse layout, so Pi's y (relative to the lines we returned) would
|
|
284
|
+
// miss the summary by that shift. Remember it to translate clicks back.
|
|
285
|
+
const mouseShift = new WeakMap<AssistantProto, { shift: number; height: number }>();
|
|
307
286
|
|
|
308
287
|
proto.updateContent = function (this: AssistantProto, message: any, isStreaming = this.isStreaming) {
|
|
309
288
|
const savedHide = this.hideThinkingBlock;
|
|
310
289
|
const savedOverrides = this.thinkingVisibilityOverrides;
|
|
311
290
|
// Default: a streaming block stays collapsed to its compact preview, which
|
|
312
291
|
// already shows the newest text. Opt in to force it open instead.
|
|
313
|
-
const forceOpen = isStreaming && bridge
|
|
292
|
+
const forceOpen = isStreaming && bridge()?.keepActiveThinkingExpanded() === true;
|
|
314
293
|
if (forceOpen) {
|
|
315
294
|
this.hideThinkingBlock = false;
|
|
316
295
|
this.thinkingVisibilityOverrides = new Map();
|
|
@@ -334,41 +313,43 @@ export function patchAssistantMessage(proto: AssistantProto): void {
|
|
|
334
313
|
this.updateContent(this.lastMessage, this.isStreaming);
|
|
335
314
|
}
|
|
336
315
|
const lines = nativeRender.call(this, width);
|
|
337
|
-
const view = bridge
|
|
316
|
+
const view = bridge()?.activityMessageView(this);
|
|
338
317
|
if (view === "hidden") return [];
|
|
339
|
-
|
|
318
|
+
if (view !== "summary") {
|
|
319
|
+
mouseShift.delete(this);
|
|
320
|
+
return lines;
|
|
321
|
+
}
|
|
322
|
+
mouseShift.set(this, { shift: lines.findIndex((line) => !visiblyBlank(line)) - 1, height: lines.length });
|
|
323
|
+
return withOneLeadingBlank(lines);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
proto.handleMouse = function (this: AssistantProto, event: MouseLike) {
|
|
327
|
+
const moved = mouseShift.get(this);
|
|
328
|
+
return nativeHandleMouse.call(this, moved ? { ...event, y: event.y + moved.shift, height: moved.height } : event);
|
|
340
329
|
};
|
|
341
330
|
});
|
|
342
331
|
}
|
|
343
332
|
|
|
344
333
|
function compactThinking(): boolean {
|
|
345
|
-
return bridge
|
|
334
|
+
return bridge()?.compactThinking() === true;
|
|
346
335
|
}
|
|
347
336
|
|
|
337
|
+
type Decorating = {
|
|
338
|
+
bridge: Bridge;
|
|
339
|
+
theme: ThemeLike | undefined;
|
|
340
|
+
component: AssistantProto;
|
|
341
|
+
message: any;
|
|
342
|
+
isStreaming: boolean;
|
|
343
|
+
compact: boolean;
|
|
344
|
+
};
|
|
345
|
+
|
|
348
346
|
function decorateThinking(component: AssistantProto, message: any, isStreaming: boolean, compact: boolean): void {
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
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();
|
|
347
|
+
const b = bridge();
|
|
348
|
+
if (!b) return;
|
|
349
|
+
const ctx: Decorating = { bridge: b, theme: liveTheme(), component, message, isStreaming, compact };
|
|
369
350
|
|
|
370
351
|
const children = component.contentContainer.children;
|
|
371
|
-
const regions =
|
|
352
|
+
const regions: any[] = children.filter(isMouseRegion);
|
|
372
353
|
const runs = thinkingRuns(message);
|
|
373
354
|
|
|
374
355
|
// Direct Markdown children correspond one-for-one with non-empty text blocks;
|
|
@@ -384,73 +365,88 @@ function decorateThinking(component: AssistantProto, message: any, isStreaming:
|
|
|
384
365
|
for (let i = 0; i < (message?.content?.length ?? 0); i++) {
|
|
385
366
|
const content = message.content[i];
|
|
386
367
|
if (content?.type === "text" && String(content.text ?? "").trim()) {
|
|
387
|
-
|
|
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
|
-
}
|
|
368
|
+
wrapProse(ctx, i, proseChildren[prose++]);
|
|
398
369
|
continue;
|
|
399
370
|
}
|
|
400
371
|
if (content?.type !== "thinking") continue;
|
|
401
372
|
while (i + 1 < message.content.length && message.content[i + 1]?.type === "thinking") i++;
|
|
402
373
|
const blocks = runs[run];
|
|
403
374
|
if (!blocks) continue;
|
|
404
|
-
const
|
|
375
|
+
const region = regions[run];
|
|
405
376
|
const runIndex = run++;
|
|
406
|
-
if (
|
|
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
|
-
}
|
|
377
|
+
if (region) wrapThinkingRun(ctx, region, blocks, runIndex);
|
|
378
|
+
}
|
|
427
379
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
380
|
+
wrapSpacer(ctx, children);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Wrap a host in the folding component for entry `id`. */
|
|
384
|
+
function foldable({ bridge: b, theme }: Decorating, inner: Component, id: string): Component {
|
|
385
|
+
return new FoldableProse(
|
|
386
|
+
inner,
|
|
387
|
+
() => b.proseView(id, theme!),
|
|
388
|
+
() => b.proseHeader(id, theme!),
|
|
389
|
+
(onHeader) => b.click(id, onHeader),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Observe one prose block and, when a theme is live, let it fold. */
|
|
394
|
+
function wrapProse(ctx: Decorating, contentIndex: number, target: { child: any; index: number } | undefined): void {
|
|
395
|
+
const { bridge: b, component, message, isStreaming, theme } = ctx;
|
|
396
|
+
const id = b.observeProse(component, contentIndex, {
|
|
397
|
+
stopReason: message.stopReason,
|
|
398
|
+
streaming: isStreaming,
|
|
399
|
+
timestamp: message.timestamp,
|
|
400
|
+
});
|
|
401
|
+
if (id && target && theme) component.contentContainer.children[target.index] = foldable(ctx, target.child, id);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Observe one thinking run (a MouseRegion) and replace what it shows. */
|
|
405
|
+
function wrapThinkingRun(ctx: Decorating, region: any, blocks: string[], runIndex: number): void {
|
|
406
|
+
const { bridge: b, component, message, isStreaming, theme, compact } = ctx;
|
|
407
|
+
const inner = region.child as any;
|
|
408
|
+
// Collapsed runs are a Text (the hidden label); expanded runs are a Markdown.
|
|
409
|
+
// Only Markdown carries a `theme` field, which is also the field the
|
|
410
|
+
// all-purple recolor needs, so one check serves both branches.
|
|
411
|
+
const hidden = inner?.theme === undefined;
|
|
412
|
+
// Pass `hidden` THROUGH, never `!hidden`. The bridge negates it into
|
|
413
|
+
// `expanded` itself, so negating here too inverted all run folding: expanded
|
|
414
|
+
// thinking became foldable (swallowing whole runs of tool rows into one
|
|
415
|
+
// summary) and collapsed thinking stopped folding entirely.
|
|
416
|
+
const id = b.observeThinking(component, runIndex, isStreaming, hidden, message.timestamp);
|
|
417
|
+
|
|
418
|
+
if (!compact) {
|
|
419
|
+
// Native thinking still participates in the separately enabled activity
|
|
420
|
+
// fold, just like native prose. Otherwise keep Pi's component untouched.
|
|
421
|
+
if (id && theme) region.child = foldable(ctx, inner, id);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (!theme) return;
|
|
425
|
+
|
|
426
|
+
if (hidden) {
|
|
427
|
+
// MouseRegion.child is `private` in TS only; reassigning keeps the
|
|
428
|
+
// existing click handler (and therefore the expand/collapse toggle).
|
|
429
|
+
region.child = b.thinkingPreview(blocks.join("\n"), theme, isStreaming, component, runIndex);
|
|
430
|
+
} else {
|
|
431
|
+
// Markdown reads its (TS-private) theme at render time, and this runs
|
|
432
|
+
// before the first render, so recoloring in place needs no reconstruction.
|
|
433
|
+
inner.theme = b.thinkingMarkdownTheme(component.markdownTheme, theme);
|
|
434
|
+
// Host the header like native thinking does, so an opened run's header
|
|
435
|
+
// can sit above this row too.
|
|
436
|
+
if (id) region.child = foldable(ctx, inner, id);
|
|
445
437
|
}
|
|
438
|
+
}
|
|
446
439
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
440
|
+
/**
|
|
441
|
+
* Leading spacer: hidden only when every row of this message is hidden.
|
|
442
|
+
* Identified by what it DOES (renders exactly one blank line) rather than by
|
|
443
|
+
* class, for the same cross-loading-mode reason as isMouseRegion.
|
|
444
|
+
*/
|
|
445
|
+
function wrapSpacer({ bridge: b, component }: Decorating, children: Component[]): void {
|
|
450
446
|
const first = children[0] as any;
|
|
451
|
-
if (
|
|
447
|
+
if (first && typeof first.setLines === "function") {
|
|
452
448
|
children[0] = {
|
|
453
|
-
render: () => (messageSpacer(component) === false ? [] : [""]),
|
|
449
|
+
render: () => (b.messageSpacer(component) === false ? [] : [""]),
|
|
454
450
|
invalidate: () => {},
|
|
455
451
|
};
|
|
456
452
|
}
|
|
@@ -473,19 +469,9 @@ function visiblyBlank(line: string): boolean {
|
|
|
473
469
|
.trim() === "";
|
|
474
470
|
}
|
|
475
471
|
|
|
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
472
|
/** Pi's live Theme instance, shared through globalThis by its theme module. */
|
|
487
|
-
function liveTheme():
|
|
488
|
-
return (globalThis as Globals)[Symbol.for("@earendil-works/pi-coding-agent:theme")];
|
|
473
|
+
function liveTheme(): ThemeLike | undefined {
|
|
474
|
+
return (globalThis as Globals)[Symbol.for("@earendil-works/pi-coding-agent:theme")] as ThemeLike | undefined;
|
|
489
475
|
}
|
|
490
476
|
|
|
491
477
|
/**
|
|
@@ -499,6 +485,7 @@ function liveTheme(): unknown {
|
|
|
499
485
|
export function patchCore(core: {
|
|
500
486
|
ToolExecutionComponent?: new (...args: never[]) => unknown;
|
|
501
487
|
AssistantMessageComponent?: new (...args: never[]) => unknown;
|
|
488
|
+
UserMessageComponent?: new (...args: never[]) => unknown;
|
|
502
489
|
}): void {
|
|
503
490
|
// Pi's `private` members make these classes structurally unassignable to the
|
|
504
491
|
// interfaces above, so cross the boundary once, here, explicitly.
|
|
@@ -507,4 +494,6 @@ export function patchCore(core: {
|
|
|
507
494
|
const assistant = proto(core.AssistantMessageComponent);
|
|
508
495
|
if (tool) patchToolExecution(tool);
|
|
509
496
|
if (assistant) patchAssistantMessage(assistant);
|
|
497
|
+
const user = proto(core.UserMessageComponent);
|
|
498
|
+
if (user) patchUserMessage(user);
|
|
510
499
|
}
|
package/src/row.ts
CHANGED
|
@@ -4,15 +4,11 @@
|
|
|
4
4
|
* TWO RULES THIS FILE ENFORCES
|
|
5
5
|
* ---------------------------
|
|
6
6
|
* 1. Rows are described as DATA and painted exactly once. Nothing downstream may
|
|
7
|
-
* re-parse painted text
|
|
8
|
-
*
|
|
9
|
-
* assumed no label ever contains a space; run summaries were joined into
|
|
10
|
-
* `"read ×2, edit ×1"` and split back apart the same way.
|
|
7
|
+
* re-parse painted text (splitting `"read src/a.ts"` on the first space
|
|
8
|
+
* assumes no label contains a space).
|
|
11
9
|
*
|
|
12
|
-
* 2. Theme and settings are bound ONCE, in a `Painter
|
|
13
|
-
*
|
|
14
|
-
* functions, which made painting depend on load order and let one test leak
|
|
15
|
-
* settings into the next.
|
|
10
|
+
* 2. Theme and settings are bound ONCE, in a `Painter`, so painting never
|
|
11
|
+
* depends on load order and one test cannot leak settings into the next.
|
|
16
12
|
*
|
|
17
13
|
* Free of Pi extension-API runtime imports, so tests exercise real painting
|
|
18
14
|
* without an extension runtime.
|
|
@@ -20,12 +16,8 @@
|
|
|
20
16
|
|
|
21
17
|
// Type-only: erased at runtime, but makes a mistyped token ("succes") a compile
|
|
22
18
|
// error instead of a theme.fg() throw that core turns into a verbose card.
|
|
23
|
-
import type {
|
|
24
|
-
import type
|
|
25
|
-
|
|
26
|
-
// ThemeBg is not exported publicly, so recover it from Theme.bg's own signature
|
|
27
|
-
// rather than importing a deep dist path (which is not a package export).
|
|
28
|
-
type ThemeBg = Parameters<Theme["bg"]>[0];
|
|
19
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { OPEN_MARKER, type Config } from "./config.ts";
|
|
29
21
|
|
|
30
22
|
/**
|
|
31
23
|
* Minimal theme surface. Pi's real Theme satisfies it; tests pass a fake that
|
|
@@ -37,7 +29,6 @@ type ThemeBg = Parameters<Theme["bg"]>[0];
|
|
|
37
29
|
*/
|
|
38
30
|
export type ThemeLike = {
|
|
39
31
|
fg(token: ThemeColor, text: string): string;
|
|
40
|
-
bg(token: ThemeBg, text: string): string;
|
|
41
32
|
};
|
|
42
33
|
|
|
43
34
|
/** One painted row. `text` is already colored; truncation happens at render. */
|
|
@@ -46,16 +37,21 @@ export type Row = {
|
|
|
46
37
|
gutter: string;
|
|
47
38
|
/** Fully colored row content. */
|
|
48
39
|
text: string;
|
|
49
|
-
/**
|
|
50
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Set on an expanded row: wrap instead of truncating. `head` is the colored
|
|
42
|
+
* `glyph label` prefix (no timer badge, whose width changes) and `body` the
|
|
43
|
+
* colored `[badge] details`; continuation lines are indented under `head`.
|
|
44
|
+
*/
|
|
45
|
+
wrap?: { head: string; body: string };
|
|
46
|
+
/** Header line drawn above an opened run's first row (then one blank line); clicking it folds the run back. */
|
|
47
|
+
header?: Row;
|
|
51
48
|
};
|
|
52
49
|
|
|
53
50
|
/**
|
|
54
51
|
* Visible columns a gutter occupies: one space, the glyph, one space.
|
|
55
52
|
*
|
|
56
|
-
* DERIVED from the row's own gutter text, never configured
|
|
57
|
-
*
|
|
58
|
-
* shift every row.
|
|
53
|
+
* DERIVED from the row's own gutter text, never configured, so it cannot
|
|
54
|
+
* disagree with the real glyph width and shift every row.
|
|
59
55
|
*/
|
|
60
56
|
export function gutterWidth(gutter: string): number {
|
|
61
57
|
return gutter === "" ? 0 : 3;
|
|
@@ -78,8 +74,8 @@ export type Labeled = {
|
|
|
78
74
|
badge?: string;
|
|
79
75
|
/** Path, command, or preview text. */
|
|
80
76
|
details?: string;
|
|
81
|
-
/**
|
|
82
|
-
|
|
77
|
+
/** Wrap long details onto more lines instead of truncating (expanded tool rows). */
|
|
78
|
+
wrap?: boolean;
|
|
83
79
|
};
|
|
84
80
|
|
|
85
81
|
/** One folded name and how many times it appeared in a run. */
|
|
@@ -101,8 +97,6 @@ export type RunSummary = {
|
|
|
101
97
|
running: Count[];
|
|
102
98
|
};
|
|
103
99
|
|
|
104
|
-
export const EMPTY_SUMMARY: RunSummary = { done: [], failed: [], running: [] };
|
|
105
|
-
|
|
106
100
|
/**
|
|
107
101
|
* Paints rows for one theme and one settings object.
|
|
108
102
|
*
|
|
@@ -144,14 +138,24 @@ export class Painter {
|
|
|
144
138
|
const parts: string[] = [];
|
|
145
139
|
if (spec.glyph) parts.push(this.theme.fg(spec.glyphColor ?? tokens.label, spec.glyph));
|
|
146
140
|
parts.push(this.theme.fg(labelColor, spec.label));
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
// The wrap indent is `glyph label` ONLY: the timer badge changes width every
|
|
142
|
+
// 1s/10s/100s and must not shift (re-wrap) the continuation lines.
|
|
143
|
+
const head = parts.join(" ");
|
|
144
|
+
// `text` is always ONE line. Details with real newlines (an expanded
|
|
145
|
+
// command) are flattened for it and kept for `wrap`, colored line by line so
|
|
146
|
+
// no color escape spans a newline.
|
|
147
|
+
const details = spec.details ?? "";
|
|
148
|
+
const flat = details.includes("\n") ? details.replace(/\s+/g, " ").trim() : details;
|
|
149
|
+
const paintDetails = (text: string) => this.theme.fg(tokens.details, text);
|
|
150
|
+
const badge = spec.badge ? this.theme.fg(tokens.label, spec.badge) : "";
|
|
151
|
+
const rest = [badge, flat && paintDetails(flat)].filter(Boolean);
|
|
152
|
+
const wrapBody = [badge, details && details.split("\n").map(paintDetails).join("\n")].filter(Boolean);
|
|
149
153
|
return {
|
|
150
154
|
// The gutter follows the label's hue, so thinking rows stay purple and tool
|
|
151
155
|
// rows stay green without either caller naming a gutter color.
|
|
152
156
|
gutter: this.gutter(labelColor),
|
|
153
|
-
text:
|
|
154
|
-
|
|
157
|
+
text: [head, ...rest].join(" "),
|
|
158
|
+
wrap: spec.wrap && spec.details ? { head, body: wrapBody.join(" ") } : undefined,
|
|
155
159
|
};
|
|
156
160
|
}
|
|
157
161
|
|
|
@@ -176,6 +180,18 @@ export class Painter {
|
|
|
176
180
|
return { gutter: this.gutter(label), text: groups.join(this.theme.fg(details, " · ")) };
|
|
177
181
|
}
|
|
178
182
|
|
|
183
|
+
/**
|
|
184
|
+
* The header of an opened run: a muted control line, not data, so it carries
|
|
185
|
+
* no counts. Clicking it folds the run back. ASCII mode swaps `·` for `-`.
|
|
186
|
+
* No gutter bar: blank padding of the gutter width keeps its text aligned with
|
|
187
|
+
* the rows below it (no padding at all when the gutter is off).
|
|
188
|
+
*/
|
|
189
|
+
header(): Row {
|
|
190
|
+
const ascii = this.config.get("glyphStyle") === "ascii";
|
|
191
|
+
const text = `${OPEN_MARKER[this.config.get("glyphStyle")]} Expanded ${ascii ? "-" : "·"} click to fold`;
|
|
192
|
+
return { gutter: this.config.get("gutter") ? " " : "", text: this.theme.fg("muted", text) };
|
|
193
|
+
}
|
|
194
|
+
|
|
179
195
|
private counts(counts: Count[], color: ThemeColor): string {
|
|
180
196
|
const tokens = this.config.tokens();
|
|
181
197
|
const glyphs = this.config.glyphs();
|