pi-minimalist 0.2.0 → 0.2.3

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/src/config.ts CHANGED
@@ -7,11 +7,7 @@
7
7
  * are readable on their own.
8
8
  */
9
9
 
10
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
11
-
12
- // ThemeBg is not exported publicly, so recover it from Theme.bg's own signature
13
- // rather than importing a deep dist path (which is not a package export).
14
- type ThemeBg = Parameters<Theme["bg"]>[0];
10
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
15
11
 
16
12
  /** Top-level key inside Pi's settings.json. */
17
13
  export const SETTINGS_KEY = "minimalist";
@@ -41,6 +37,9 @@ export type Glyphs = {
41
37
 
42
38
  export type GlyphStyle = "unicode" | "ascii";
43
39
 
40
+ /** Marker on the header of an opened run. Not user-configurable. */
41
+ export const OPEN_MARKER: Record<GlyphStyle, string> = { unicode: "▾", ascii: "v" };
42
+
44
43
  export const GLYPH_PRESETS: Record<GlyphStyle, Glyphs> = {
45
44
  unicode: { done: "✓", failed: "✗", running: "•", queued: "›", gutter: "▌", timer: "⏱", count: "×" },
46
45
  // Every ASCII glyph is exactly one column wide, so rows stay aligned in fonts
@@ -56,9 +55,8 @@ export const GLYPH_PRESETS: Record<GlyphStyle, Glyphs> = {
56
55
  * Which Pi theme token paints each part of a row.
57
56
  *
58
57
  * Overridable, but the defaults are deliberate: the action word is a LABEL, not
59
- * a warning, so it uses `success` green even for mutating tools. An earlier
60
- * version used warning orange, which collided with orange meaning "highlighted
61
- * prose".
58
+ * a warning, so it uses `success` green even for mutating tools (orange would
59
+ * collide with "highlighted prose").
62
60
  */
63
61
  export type Tokens = {
64
62
  label: ThemeColor;
@@ -66,7 +64,6 @@ export type Tokens = {
66
64
  thinking: ThemeColor;
67
65
  error: ThemeColor;
68
66
  outputGutter: ThemeColor;
69
- activeBg: ThemeBg;
70
67
  };
71
68
 
72
69
  export const DEFAULT_TOKENS: Tokens = {
@@ -75,7 +72,6 @@ export const DEFAULT_TOKENS: Tokens = {
75
72
  thinking: "thinkingText",
76
73
  error: "error",
77
74
  outputGutter: "borderMuted",
78
- activeBg: "toolPendingBg",
79
75
  };
80
76
 
81
77
  // ---------------------------------------------------------------------------
@@ -113,8 +109,6 @@ export type BasicSettings = {
113
109
  groupToolRuns: boolean;
114
110
  /** Replace all but the latest assistant prose block with one summary row. */
115
111
  foldIntermediateActivity: boolean;
116
- /** Keep commentary visible until OpenAI starts its final-answer message. */
117
- foldActivityOnFinalAnswer: boolean;
118
112
  /** What replaces the activity hidden before the latest prose block. */
119
113
  activitySummary: ActivitySummary;
120
114
  /** Draw the left gutter bar. */
@@ -137,12 +131,6 @@ export type AdvancedSettings = {
137
131
  excludeTools: string[];
138
132
  glyphs: Partial<Glyphs>;
139
133
  tokens: Partial<Tokens>;
140
- /**
141
- * Sanity cap on detail text length. NOT a display limit — rows truncate at the
142
- * real viewport width. This only stops a multi-megabyte heredoc from being
143
- * whitespace-collapsed, colored and measured on every repaint.
144
- */
145
- maxDetailChars: number;
146
134
  };
147
135
 
148
136
  export type Settings = BasicSettings & AdvancedSettings;
@@ -152,7 +140,6 @@ export const DEFAULT_BASIC: BasicSettings = {
152
140
  compactToolRows: true,
153
141
  groupToolRuns: true,
154
142
  foldIntermediateActivity: false,
155
- foldActivityOnFinalAnswer: false,
156
143
  activitySummary: "elapsed",
157
144
  gutter: true,
158
145
  timer: true,
@@ -175,13 +162,11 @@ export const PRESETS: Record<Exclude<Preset, "custom">, Record<LookKey, boolean>
175
162
 
176
163
  export const DEFAULT_ADVANCED: AdvancedSettings = {
177
164
  // `subagent` ships a renderer showing run id, state and a ctrl+o hint that one
178
- // line cannot carry. Excluded by NAME rather than by the old "does this tool
179
- // have its own renderer?" rule, which silently exempted every third-party tool
180
- // and made the behavior impossible to discover from the UI.
165
+ // line cannot carry. Excluded by NAME rather than by a "does this tool
166
+ // have its own renderer?" rule, which would silently exempt every third-party tool.
181
167
  excludeTools: ["subagent"],
182
168
  glyphs: {},
183
169
  tokens: {},
184
- maxDetailChars: 4000,
185
170
  };
186
171
 
187
172
  export const DEFAULTS: Settings = { ...DEFAULT_BASIC, ...DEFAULT_ADVANCED };
@@ -274,17 +259,10 @@ export class Config {
274
259
  }
275
260
 
276
261
  /**
277
- * Should this tool keep Pi's own rendering?
278
- *
279
- * The exclusion list is the ONLY exemption, so the master switch is checked
280
- * alongside it wherever claiming happens.
262
+ * True when this tool should be drawn as a compact row. The exclusion list is
263
+ * the ONLY exemption, so the master switch is checked alongside it.
281
264
  */
282
- isExcluded(toolName: string): boolean {
283
- return this.view.excludeTools.includes(toolName);
284
- }
285
-
286
- /** True when this tool should be drawn as a compact row. */
287
265
  compacts(toolName: string): boolean {
288
- return this.view.compactToolRows && !this.isExcluded(toolName);
266
+ return this.view.compactToolRows && !this.view.excludeTools.includes(toolName);
289
267
  }
290
268
  }
package/src/core-patch.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * RUNTIME core patches — the replacement for the old patch-pi.sh bundle edits.
2
+ * RUNTIME core patches: wrappers on Pi's real component prototypes.
3
3
  *
4
- * WHY THIS WORKS (the discovery that removed the patch script)
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
- * `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:
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
- * - 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.
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 { 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";
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
- function bridge<T>(key: keyof typeof BRIDGE_SYMBOLS): T | undefined {
37
- return (globalThis as Globals)[Symbol.for(BRIDGE_SYMBOLS[key])] as T | undefined;
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
- * 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.
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
- * 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.
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 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.
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): ToolRendererBridge | undefined {
130
- const renderer = bridge<ToolRendererBridge>("toolRenderer");
131
- if (!renderer?.renderCall) return undefined;
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?.renderCall) return this.toolDefinition?.renderCall;
144
+ if (!renderer) return this.toolDefinition?.renderCall;
171
145
  return (args: unknown, theme: unknown, context: unknown) =>
172
- renderer.renderCall!(this.toolName, args, theme, context);
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?.renderResult) return this.toolDefinition?.renderResult;
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!(this.toolName, result, options, theme, context, nativeRenderer);
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<(id: string) => boolean>("activitySummaryRow")?.(this.toolCallId);
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
- /** Renders nothing, for the "no result content at all" case. */
215
- const EMPTY: Component = { render: () => [], invalidate: () => {} };
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
- // PATCH 2 — thinking blocks
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. That is the
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
- * 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:
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<() => boolean>("keepActiveThinkingExpanded")?.() === true;
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<(owner: object) => "normal" | "hidden" | "summary">("activityMessageView")?.(this);
316
+ const view = bridge()?.activityMessageView(this);
338
317
  if (view === "hidden") return [];
339
- return view === "summary" ? withOneLeadingBlank(lines) : lines;
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<Config>("config")?.get("thinkingAsToolCall") === true;
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 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();
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 = thinkingRegions(children);
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
- 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
- }
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 entry = regions[run];
375
+ const region = regions[run];
405
376
  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
- }
377
+ if (region) wrapThinkingRun(ctx, region, blocks, runIndex);
378
+ }
427
379
 
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
- }
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
- // 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.
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 (messageSpacer && first && typeof first.setLines === "function") {
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(): unknown {
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
  }