pi-minimalist 0.2.0 → 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/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. An earlier version built `"read src/a.ts"` and then
8
- * split it on the first space to recolor the action word, which silently
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`. There used to be a
13
- * module-level mutable config with `config = defaultConfig()` defaults on four
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 { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
24
- import type { Config } from "./config.ts";
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
- /** Full-width background, applied after truncation. */
50
- highlight?: (text: string) => string;
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. An earlier design
57
- * exported this as a constant, which could disagree with the real glyph width and
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
- /** Highlight the row background (streaming thinking only). */
82
- highlight?: boolean;
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
- if (spec.badge) parts.push(this.theme.fg(tokens.label, spec.badge));
148
- if (spec.details) parts.push(this.theme.fg(tokens.details, spec.details));
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: parts.join(" "),
154
- highlight: spec.highlight ? (text) => this.theme.bg(tokens.activeBg, text) : undefined,
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();
@@ -1,9 +1,6 @@
1
1
  /**
2
2
  * Folding adjacent low-noise rows into one summary (the `groupToolRuns` setting).
3
3
  *
4
- * Formerly `quiet-mode.ts` / `QuietMode`, named after a `/quiet` command that no
5
- * longer exists.
6
- *
7
4
  * WHY THIS STATE EXISTS AT ALL
8
5
  * ---------------------------
9
6
  * Each tool row is an independent transcript entry that knows nothing about its
@@ -19,7 +16,8 @@ import type { Count, Painter, Row, RunSummary } from "./row.ts";
19
16
 
20
17
  export type Outcome = "success" | "failure" | "pending";
21
18
 
22
- type EntryKind = "tool" | "thinking" | "prose";
19
+ /** "other" = visible in the transcript but not ours to fold (user message, excluded tool card). */
20
+ type EntryKind = "tool" | "thinking" | "prose" | "other";
23
21
 
24
22
  type Entry = {
25
23
  id: string;
@@ -33,11 +31,13 @@ type Entry = {
33
31
  foldable: boolean;
34
32
  };
35
33
 
36
- type Cycle = { startedAt: number; settledAt?: number; finalAnswerStarted: boolean };
34
+ type Cycle = { startedAt: number; settledAt?: number };
37
35
 
38
36
  /** "show" keeps the row as-is, "hide" draws nothing, a summary folds a whole run. */
39
37
  export type RowView = "show" | "hide" | RunSummary;
40
38
  type ActivityState = "show" | "hide" | "summary";
39
+ /** A run the user opened. Every member id maps to the same object. */
40
+ type OpenRun = { members: Entry[]; activity: boolean };
41
41
  type ViewCache = {
42
42
  key: string;
43
43
  activity: Map<string, ActivityState>;
@@ -51,12 +51,14 @@ export class RunGrouping {
51
51
  private byId = new Map<string, Entry>();
52
52
  private ownerIds = new WeakMap<object, number>();
53
53
  private ownerEntries = new Map<number, Set<string>>();
54
+ /** Message identity per owner. Rebuilt components of one message share it, so they share entries. */
55
+ private messageKeys = new WeakMap<object, string>();
54
56
  private nextOwnerId = 1;
55
57
  private cycle = 0;
56
58
  private cycles = new Map<number, Cycle>();
57
59
  private agentActive = false;
58
- /** Rows the user opened out of a summary by clicking it. Never fold again. */
59
- private opened = new Set<string>();
60
+ /** Rows the user opened out of a summary by clicking it. They stay out of folds until `close()`. */
61
+ private opened = new Map<string, OpenRun>();
60
62
  private version = 0;
61
63
  private cache?: ViewCache;
62
64
 
@@ -73,7 +75,7 @@ export class RunGrouping {
73
75
  // Replay leaves `cycle` pointing at the next empty slot. A subsequent live
74
76
  // interaction can use it directly; consecutive live interactions advance.
75
77
  if (this.entries.some((entry) => entry.cycle === this.cycle)) this.cycle++;
76
- this.cycles.set(this.cycle, { startedAt: now, finalAnswerStarted: false });
78
+ this.cycles.set(this.cycle, { startedAt: now });
77
79
  this.agentActive = true;
78
80
  this.invalidateViews();
79
81
  }
@@ -116,50 +118,49 @@ export class RunGrouping {
116
118
  this.invalidateViews();
117
119
  }
118
120
 
119
- /** Observe a thinking block by its component identity and local run index. */
120
- observeThinking(owner: object, runIndex: number, done: boolean, expanded: boolean): void {
121
+ /**
122
+ * Record something visible that we never fold or draw (a user message, an
123
+ * excluded tool card). It only exists to cut runs, so no summary jumps over it.
124
+ */
125
+ observeBarrier(id: string): void {
126
+ this.observe(id, "other", "success", false, false, "other");
127
+ }
128
+
129
+ /** Observe a thinking block by its message identity and local run index. */
130
+ observeThinking(owner: object, runIndex: number, done: boolean, expanded: boolean, timestamp?: number): void {
131
+ this.identify(owner, timestamp);
121
132
  const id = this.thinkingId(owner, runIndex);
122
133
  this.rememberOwnerEntry(owner, id);
123
134
  this.observe(id, "think", done ? "success" : "pending", expanded, true, "thinking");
124
135
  }
125
136
 
126
- /**
127
- * Record prose and return its stable id. `stopReason=stop` is available on the
128
- * first empty final-answer update, before OpenAI streams its first text delta;
129
- * the finalized block later confirms the same fact through `phase`.
130
- */
137
+ /** Record prose and return its stable id. `stopReason=stop` marks a final answer. */
131
138
  observeProse(
132
139
  owner: object,
133
140
  contentIndex: number,
134
141
  signal: {
135
- phase?: "commentary" | "final_answer";
136
142
  stopReason?: string;
137
143
  streaming?: boolean;
138
144
  timestamp?: number;
139
145
  } = {},
140
146
  ): string {
141
- const id = `prose:${this.ownerId(owner)}:${contentIndex}`;
147
+ this.identify(owner, signal.timestamp);
148
+ const id = `prose:${this.messageKey(owner)}:${contentIndex}`;
142
149
  const isNew = !this.byId.has(id);
143
150
  const timestamp = signal.timestamp ?? Date.now();
144
151
  if (!this.cycles.has(this.cycle)) {
145
- this.cycles.set(this.cycle, { startedAt: timestamp, finalAnswerStarted: false });
152
+ this.cycles.set(this.cycle, { startedAt: timestamp });
146
153
  }
147
154
  this.rememberOwnerEntry(owner, id);
148
155
  this.observe(id, "prose", "pending", false, false, "prose");
149
156
 
150
- const isFinal = signal.phase === "final_answer" || signal.stopReason === "stop";
151
- const cycle = this.cycles.get(this.byId.get(id)!.cycle)!;
152
- if (isFinal && !cycle.finalAnswerStarted) {
153
- cycle.finalAnswerStarted = true;
154
- this.invalidateViews();
155
- }
156
-
157
+ const isFinal = signal.stopReason === "stop";
157
158
  // session replay has no agent_start/agent_settled events. A newly observed,
158
159
  // finalized answer is therefore the only reliable boundary between old user
159
160
  // interactions. Advance AFTER assigning the answer so it remains the latest
160
161
  // prose of its own cycle; stable ids prevent re-renders from advancing twice.
161
162
  if (!this.agentActive && isNew && isFinal && !signal.streaming) {
162
- cycle.settledAt = timestamp;
163
+ this.cycles.get(this.byId.get(id)!.cycle)!.settledAt = timestamp;
163
164
  this.cycle++;
164
165
  }
165
166
  return id;
@@ -193,7 +194,7 @@ export class RunGrouping {
193
194
  }
194
195
 
195
196
  thinkingId(owner: object, runIndex: number): string {
196
- return `think:${this.ownerId(owner)}:${runIndex}`;
197
+ return `think:${this.messageKey(owner)}:${runIndex}`;
197
198
  }
198
199
 
199
200
  /** Hide a message's leading spacer only when every one of its rows is hidden. */
@@ -223,7 +224,7 @@ export class RunGrouping {
223
224
  if (activity === "summary") return this.activitySummary(entry!, painter);
224
225
 
225
226
  const view = this.view(id);
226
- if (view === "show") return base();
227
+ if (view === "show") return entry ? this.withHeader(entry, base(), painter) : base();
227
228
  if (view === "hide") return null;
228
229
  return painter.summary(view);
229
230
  }
@@ -240,44 +241,69 @@ export class RunGrouping {
240
241
  if (!entry) return false;
241
242
  const { activity, grouped } = this.views();
242
243
  const members = [entry];
243
- if (activity.get(id) === "summary") {
244
- members.push(...this.entries.filter((e) => e.cycle === entry.cycle && activity.get(e.id) === "hide"));
244
+ const isActivity = activity.get(id) === "summary";
245
+ if (isActivity) {
246
+ members.unshift(...this.entries.filter((e) => e.cycle === entry.cycle && activity.get(e.id) === "hide"));
245
247
  } else if (typeof grouped.get(id) === "object") {
246
248
  // A run's hidden members are the contiguous "hide" rows before its tail.
247
249
  for (let i = this.entries.indexOf(entry) - 1; i >= 0 && grouped.get(this.entries[i].id) === "hide"; i--) {
248
- members.push(this.entries[i]);
250
+ members.unshift(this.entries[i]);
249
251
  }
250
252
  } else {
251
253
  return false;
252
254
  }
253
- for (const member of members) this.opened.add(member.id);
255
+ const run: OpenRun = { members, activity: isActivity };
256
+ for (const member of members) this.opened.set(member.id, run);
254
257
  this.invalidateViews();
255
258
  return true;
256
259
  }
257
260
 
261
+ /**
262
+ * Fold an opened run back, after a click on its header. `CompactLine` reports
263
+ * `onHeader` only for a row that drew the header, so `id` is that row.
264
+ */
265
+ close(id: string): boolean {
266
+ const run = this.opened.get(id);
267
+ if (!run) return false;
268
+ for (const member of run.members) this.opened.delete(member.id);
269
+ this.invalidateViews();
270
+ return true;
271
+ }
272
+
273
+ /** The click handler of a row drawn by `CompactLine`: header folds back, anything else opens. */
274
+ click(id: string, onHeader: boolean): boolean {
275
+ return onHeader ? this.close(id) : this.open(id);
276
+ }
277
+
278
+ /** The header of an opened run, on its first member only, while the fold that made it is on. */
279
+ headerFor(id: string, painter: Painter): Row | undefined {
280
+ const entry = this.byId.get(id);
281
+ const run = this.opened.get(id);
282
+ if (!entry || !run || run.members[0].id !== id || !(run.activity ? this.config.get("foldIntermediateActivity") : this.enabled)) {
283
+ return undefined;
284
+ }
285
+ return painter.header();
286
+ }
287
+
288
+ private withHeader(entry: Entry, row: Row, painter: Painter): Row {
289
+ const header = this.headerFor(entry.id, painter);
290
+ return header ? { ...row, header } : row;
291
+ }
292
+
258
293
  view(id: string): RowView {
259
294
  return this.views().grouped.get(id) ?? "show";
260
295
  }
261
296
 
262
- /**
263
- * Can this entry disappear into a run summary?
264
- *
265
- * A RUNNING entry folds by default: folds are recomputed on every render, so
266
- * the row reappears the moment it matters, and the summary still counts it in
267
- * its `running` group. `keepActiveToolsExpanded` opts out for people who want
268
- * to watch a long command in place.
269
- */
270
297
  /** Collapse every collapsible row before the latest prose into ONE activity summary. */
271
298
  private activityState(entry: Entry | undefined): ActivityState {
272
299
  return entry ? (this.views().activity.get(entry.id) ?? "show") : "show";
273
300
  }
274
301
 
302
+ /** The `activitySummary` mode decides what a folded activity run shows. */
275
303
  private activitySummary(entry: Entry, painter: Painter, now = Date.now()): Row {
276
- const cycle = this.cycles.get(entry.cycle);
277
304
  const actions = this.views().actions.get(entry.cycle);
278
- if (this.config.get("activitySummary") === "tools" && actions) {
279
- return painter.summary(actions);
280
- }
305
+ if (this.config.get("activitySummary") === "tools" && actions) return painter.summary(actions);
306
+ const cycle = this.cycles.get(entry.cycle);
281
307
  const elapsed = Math.max(0, (cycle?.settledAt ?? now) - (cycle?.startedAt ?? now));
282
308
  return painter.labeled({ label: "Worked", details: `for ${formatDuration(elapsed)}` });
283
309
  }
@@ -288,7 +314,6 @@ export class RunGrouping {
288
314
  this.config.get("groupToolRuns"),
289
315
  this.config.get("thinkingAsToolCall"),
290
316
  this.config.get("foldIntermediateActivity"),
291
- this.config.get("foldActivityOnFinalAnswer"),
292
317
  this.config.get("keepActiveToolsExpanded"),
293
318
  ].join(":");
294
319
  if (this.cache?.key === key) return this.cache;
@@ -304,7 +329,12 @@ export class RunGrouping {
304
329
  continue;
305
330
  }
306
331
  let last = first + 1;
307
- while (last < this.entries.length && this.foldable(this.entries[last])) last++;
332
+ // A new agent cycle (user prompt) also cuts the run.
333
+ while (
334
+ last < this.entries.length &&
335
+ this.foldable(this.entries[last]) &&
336
+ this.entries[last].cycle === this.entries[first].cycle
337
+ ) last++;
308
338
  if (last - first > 1) {
309
339
  for (let index = first; index < last - 1; index++) grouped.set(this.entries[index].id, "hide");
310
340
  grouped.set(this.entries[last - 1].id, summarize(this.entries.slice(first, last)));
@@ -322,20 +352,18 @@ export class RunGrouping {
322
352
  }
323
353
  for (const [cycleId, entries] of byCycle) {
324
354
  const prose = entries.filter((entry) => entry.kind === "prose");
325
- const cycle = this.cycles.get(cycleId);
326
- if (
327
- prose.length < 2 ||
328
- (this.config.get("foldActivityOnFinalAnswer") && !cycle?.finalAnswerStarted)
329
- ) continue;
355
+ if (prose.length < 2) continue;
330
356
 
331
357
  const beforeLatest = entries.slice(0, entries.indexOf(prose.at(-1)!));
358
+ // A cycle of consecutive prose only (no tool, no thinking) has nothing to fold.
359
+ if (!beforeLatest.some(isAction)) continue;
332
360
  const collapsible = beforeLatest.filter((entry) => this.activityFoldable(entry));
333
361
  for (let index = 0; index < collapsible.length - 1; index++) {
334
362
  activity.set(collapsible[index].id, "hide");
335
363
  }
336
364
  const tail = collapsible.at(-1);
337
365
  if (tail) activity.set(tail.id, "summary");
338
- const actionEntries = beforeLatest.filter((entry) => entry.kind !== "prose");
366
+ const actionEntries = beforeLatest.filter(isAction);
339
367
  if (actionEntries.length > 0) actions.set(cycleId, summarize(actionEntries));
340
368
  }
341
369
  }
@@ -349,6 +377,7 @@ export class RunGrouping {
349
377
  }
350
378
 
351
379
  private activityFoldable(entry: Entry): boolean {
380
+ if (entry.kind === "other") return false;
352
381
  if (entry.expanded || this.opened.has(entry.id)) return false;
353
382
  if (entry.outcome === "pending" && entry.kind !== "prose" && this.config.get("keepActiveToolsExpanded")) {
354
383
  return false;
@@ -373,6 +402,20 @@ export class RunGrouping {
373
402
  entries.add(entryId);
374
403
  }
375
404
 
405
+ /**
406
+ * Ids follow the MESSAGE, not the component: Pi builds new components for the
407
+ * same messages on every rebuild (Ctrl+T, tree navigation), and ids tied to
408
+ * the dropped components would leave ghost entries counted in summaries.
409
+ * `timestamp` is set once when a message starts streaming and never changes.
410
+ */
411
+ private identify(owner: object, timestamp?: number): void {
412
+ if (timestamp !== undefined) this.messageKeys.set(owner, `t${timestamp}`);
413
+ }
414
+
415
+ private messageKey(owner: object): string {
416
+ return this.messageKeys.get(owner) ?? `o${this.ownerId(owner)}`;
417
+ }
418
+
376
419
  private ownerId(owner: object): number {
377
420
  let id = this.ownerIds.get(owner);
378
421
  if (id === undefined) {
@@ -383,6 +426,9 @@ export class RunGrouping {
383
426
  }
384
427
  }
385
428
 
429
+ /** A tool or thinking row: what a "Worked for" fold and its tool counts are made of. */
430
+ const isAction = (entry: Entry) => entry.kind === "tool" || entry.kind === "thinking";
431
+
386
432
  /** Group a run's entries by outcome, each in first-seen order. */
387
433
  export function summarize(run: Entry[]): RunSummary {
388
434
  const named = (outcome: Outcome) =>
package/src/thinking.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * FEATURE 2: thinking blocks.
3
3
  *
4
- * Collapsed — one line replacing Pi's bare "Thinking..." label, installed on
5
- * `Symbol.for("pi.thinkingPreview")`:
4
+ * Collapsed — one line replacing Pi's bare "Thinking..." label, installed as
5
+ * `Bridge.thinkingPreview`:
6
6
  *
7
7
  * • think The user wants me to… ← while streaming
8
8
  * ✓ think The user wants me to… ← once the message completes
9
9
  *
10
10
  * Expanded (Ctrl+T) — Pi's native italic Markdown, but with every token-specific
11
- * color replaced by the thinking hue, on `Symbol.for("pi.thinkingMarkdownTheme")`.
11
+ * color replaced by the thinking hue, as `Bridge.thinkingMarkdownTheme`.
12
12
  */
13
13
 
14
14
  import { CompactLine, realTimers, type Timers } from "./components.ts";
@@ -20,7 +20,6 @@ import type { Config } from "./config.ts";
20
20
  export type ThinkingPreview = (
21
21
  text: string,
22
22
  theme: ThemeLike,
23
- pad: number,
24
23
  streaming?: boolean,
25
24
  owner?: object,
26
25
  runIndex?: number,
@@ -36,9 +35,7 @@ export function createThinkingPreview(deps: ThinkingDeps): ThinkingPreview {
36
35
  const { config, grouping } = deps;
37
36
  const timers = deps.timers ?? realTimers;
38
37
 
39
- // `pad` is part of the core bridge signature but unused: the gutter already
40
- // positions the row, so honoring pad too would double-indent it.
41
- return (text, theme, _pad, streaming, owner, runIndex) => {
38
+ return (text, theme, streaming, owner, runIndex) => {
42
39
  const line = new CompactLine(timers);
43
40
 
44
41
  // Takes no width: thinking has a single detail field, so CompactLine's
@@ -59,7 +56,7 @@ export function createThinkingPreview(deps: ThinkingDeps): ThinkingPreview {
59
56
 
60
57
  if (owner && runIndex !== undefined) {
61
58
  const id = grouping.thinkingId(owner, runIndex);
62
- line.setRow(() => grouping.rowFor(id, new Painter(theme, config), paint), () => grouping.open(id));
59
+ line.setRow(() => grouping.rowFor(id, new Painter(theme, config), paint), (onHeader) => grouping.click(id, onHeader));
63
60
  } else {
64
61
  line.setRow(paint);
65
62
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * FEATURE 1: the compact tool renderer.
3
3
  *
4
- * Installed on the process-global bridge `Symbol.for("pi.defaultToolRenderer")`
5
- * and consulted by the prototype wrappers in `core-patch.ts`.
4
+ * Installed as `Bridge.toolRenderer` (see `bridge.ts`) and consulted by the
5
+ * prototype wrappers in `core-patch.ts`.
6
6
  *
7
7
  * Core overrides RENDERING only. Native tool definitions keep their schema,
8
8
  * `execute()`, and builtin source ownership — that ownership is what lets
@@ -172,17 +172,28 @@ export function createToolRenderer(deps: RendererDeps): ToolRenderer {
172
172
 
173
173
  // Painted at render time, because the detail budget depends on the real
174
174
  // viewport width and TUI only reveals it during render().
175
+ const expanded = context.expanded === true;
175
176
  const paint = (width: number) => {
176
177
  const painter = new Painter(theme, config);
177
178
  const badge = timerBadge(status.elapsed, config);
179
+ // Expanded (Ctrl+O): the full details, wrapped by CompactLine instead of
180
+ // truncated, so a long command is readable.
178
181
  const { label, details } = describeTool(name, args, {
179
- expanded: context.expanded === true,
180
- budget: detailBudget(width, status.glyph, name, badge),
181
- config,
182
+ budget: expanded ? undefined : detailBudget(width, status.glyph, name, badge),
182
183
  });
183
184
  // No background while executing — the animated timer already signals
184
185
  // activity, and a highlight flashing on each repaint was distracting.
185
- return painter.labeled({ glyph: status.glyph, glyphColor: status.color, label, badge, details });
186
+ // One colour for glyph, label and gutter (the gutter follows the label), so
187
+ // a failed row is red all over and a done one is label-coloured all over.
188
+ return painter.labeled({
189
+ glyph: status.glyph,
190
+ glyphColor: status.color,
191
+ label,
192
+ labelColor: status.color,
193
+ badge,
194
+ details,
195
+ wrap: expanded,
196
+ });
186
197
  };
187
198
 
188
199
  // EVERY call is observed, so a non-foldable one still cuts a run. Core
@@ -193,10 +204,10 @@ export function createToolRenderer(deps: RendererDeps): ToolRenderer {
193
204
  component.setRow(paint);
194
205
  return component;
195
206
  }
196
- grouping.observe(id, summaryName(name, args), status.outcome, context.expanded === true);
207
+ grouping.observe(id, summaryName(name, args), status.outcome, expanded);
197
208
  component.setRow(
198
209
  (width) => grouping.rowFor(id, new Painter(theme, config), () => paint(width)),
199
- () => grouping.open(id),
210
+ (onHeader) => grouping.click(id, onHeader),
200
211
  );
201
212
  return component;
202
213
  },
@@ -247,8 +258,8 @@ function detailBudget(width: number, glyph: string, label: string, badge: string
247
258
  *
248
259
  * Two caches on purpose: core's renderer path passes `lastComponent`, but its
249
260
  * FALLBACK path always passes undefined, so `context.state` is the only stable
250
- * per-call cache there. Allocating a fresh row per repaint used to start another
251
- * 1s ticker while clearing none — an interval leak that froze the UI.
261
+ * per-call cache there. Allocating a fresh row per repaint would start another
262
+ * 1s ticker while clearing none — an interval leak that freezes the UI.
252
263
  */
253
264
  function reuseRow(context: CallContext & { lastComponent?: unknown }, timers: Timers): CompactLine {
254
265
  const cached =