@hheei/pi-ext-ui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,53 @@
1
- import { Container, MouseRegion, Spacer, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
1
+ import { Box, Container, MouseRegion, Spacer, Text, truncateToWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
2
  import { fitRow } from "@hheei/pi-ext-core";
3
- import { formatDurationMs } from "./payload-parser.js";
4
- import { formatToolHeaderText, isCollapsible, renderWithoutToolRails, } from "./tool-view.js";
3
+ import { backgroundFill } from "./background.js";
4
+ import { isCollapsible, toContentRows, toSingleRow, } from "./tool-view.js";
5
+ /** A settled block keeps its surface this long, then drops it so the transcript stays quiet. */
6
+ const SURFACE_HOLD_MS = 5_000;
7
+ /** Every original component of a member, in transcript order. */
8
+ export function memberParts(member) {
9
+ return member.parts ?? [member.component];
10
+ }
11
+ export class TailUnit {
12
+ #parts;
13
+ #offsets = [];
14
+ constructor(member) {
15
+ this.#parts = memberParts(member);
16
+ }
17
+ render(width) {
18
+ const offsets = [];
19
+ const rows = [];
20
+ for (const part of this.#parts) {
21
+ const all = part.render(width);
22
+ const skip = all[0] === "" ? 1 : 0;
23
+ const own = all.slice(skip);
24
+ if (own.length === 0)
25
+ continue;
26
+ if (rows.length > 0)
27
+ rows.push("");
28
+ offsets.push({ part, top: rows.length, height: own.length, skip });
29
+ rows.push(...own);
30
+ }
31
+ this.#offsets = offsets;
32
+ return rows.length === 0 || rows[0] === "" ? rows : ["", ...rows];
33
+ }
34
+ handleMouse(event) {
35
+ for (const { part, top, height, skip } of this.#offsets) {
36
+ if (event.y < top || event.y >= top + height)
37
+ continue;
38
+ return part.handleMouse?.({
39
+ ...event,
40
+ y: event.y - top + skip,
41
+ height: height + skip,
42
+ });
43
+ }
44
+ return undefined;
45
+ }
46
+ invalidate() {
47
+ for (const part of this.#parts)
48
+ part.invalidate?.();
49
+ }
50
+ }
5
51
  function formatElapsed(milliseconds) {
6
52
  const total = Math.max(0, Math.floor(milliseconds / 1000));
7
53
  const seconds = total % 60;
@@ -25,16 +71,35 @@ function formatTokens(count) {
25
71
  return `${(count / 1_000_000).toFixed(1)}M`;
26
72
  return `${String(Math.round(count / 1_000_000))}M`;
27
73
  }
28
- function cacheHitRate(latest, cacheTotal) {
29
- if (latest === undefined || cacheTotal <= 0)
30
- return "—";
31
- const prompt = latest.input + latest.cacheRead + latest.cacheWrite;
32
- if (prompt <= 0)
74
+ /**
75
+ * The native footer's cache-hit rule: the last request that actually measured a prompt. A zeroed
76
+ * streaming placeholder is not that request, and a measured prompt with no cache read is 0%, not
77
+ * unknown — missing data and a real zero must not share one placeholder.
78
+ */
79
+ function cacheHitRate(latestPrompt) {
80
+ if (latestPrompt === undefined)
33
81
  return "—";
34
- return `${((latest.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
82
+ const prompt = latestPrompt.input + latestPrompt.cacheRead + latestPrompt.cacheWrite;
83
+ return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
35
84
  }
36
- function preview(text) {
37
- return text.split("\n").find((line) => line.trim() !== "");
85
+ const PREVIEW_GRAPHEMES = 20;
86
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
87
+ /**
88
+ * The thinking preview: the first line's leading graphemes, and whether anything follows them. A
89
+ * grapheme cap keeps emoji and CJK clusters whole; the frame still truncates to the cells it has.
90
+ */
91
+ function thinkingPreview(text) {
92
+ const content = text.trim();
93
+ const line = toSingleRow(content.split("\n").find((row) => row.trim() !== "") ?? "");
94
+ let head = "";
95
+ let count = 0;
96
+ for (const { segment } of graphemes.segment(line)) {
97
+ if (count === PREVIEW_GRAPHEMES)
98
+ break;
99
+ head += segment;
100
+ count++;
101
+ }
102
+ return { head, more: content !== head };
38
103
  }
39
104
  /**
40
105
  * The collapsed frame of one activity block.
@@ -47,31 +112,48 @@ export class ToolGroupView extends Container {
47
112
  #header = new Text("", 0, 0);
48
113
  #ui;
49
114
  #redraw;
50
- #run;
115
+ /** The stable clock for this block; restored history stays unknown until live content joins. */
116
+ #clock;
117
+ /** The block's own members, drawn inside the group. */
51
118
  #members = [];
52
- #settled = false;
119
+ /** The block's last visible unit, drawn outside the group by its own component. */
120
+ #tail;
121
+ /** Every model request the block owns, in transcript order, including the tail's and native prose. */
122
+ #requests = [];
53
123
  #raw = false;
54
124
  #category;
55
125
  #item;
56
126
  #renderedExpanded = false;
127
+ #shell = "collapsed";
128
+ /** When the block last became quiet; the surface hold counts from here. */
129
+ #settledAt;
130
+ #holdTimer;
57
131
  #hits = [];
58
- constructor(ui, redraw, run) {
132
+ #heading;
133
+ #body;
134
+ // One extra colored row above and below: the block needs breathing room inside its background.
135
+ #box = new Box(0, 1);
136
+ #spacer = new Spacer(1);
137
+ constructor(ui, redraw) {
59
138
  super();
60
139
  this.#ui = ui;
61
140
  this.#redraw = redraw;
62
- this.#run = run;
63
- const heading = new MouseRegion(this.#header, (event) => {
141
+ this.#heading = new MouseRegion(this.#header, (event) => {
64
142
  if (event.type !== "click" || event.button !== "left")
65
143
  return undefined;
66
144
  this.#toggleRaw();
67
145
  return { handled: true, render: true };
68
146
  });
69
- const body = {
147
+ this.#body = {
70
148
  render: (width) => this.#renderBody(width),
71
149
  handleMouse: (event) => this.#hit(event),
72
150
  invalidate() { },
73
151
  };
74
- this.children = [new Spacer(1), heading, body];
152
+ // The group background covers the header, categories, and footer. Zero padding keeps the rows
153
+ // at the width they were laid out for; a tool's own outputPad must not be counted twice.
154
+ this.#box.addChild(this.#heading);
155
+ this.#box.addChild(this.#body);
156
+ this.children = [this.#spacer, this.#box];
75
157
  }
76
158
  #toggleRaw() {
77
159
  this.#raw = this.#ui.getToolsExpanded() ? false : !this.#raw;
@@ -85,113 +167,159 @@ export class ToolGroupView extends Container {
85
167
  }
86
168
  this.#redraw();
87
169
  }
88
- /**
89
- * The footer names work in progress: the latest top-level call that is still active, or the last
90
- * one started when none is. Arrival order of partial updates must not decide it.
91
- */
92
- #tailIndex() {
93
- let last = -1;
94
- let active = -1;
95
- for (const [index, member] of this.#members.entries()) {
96
- if (member.kind !== "tool")
97
- continue;
98
- last = index;
99
- if (member.status === "running" || member.status === "streaming")
100
- active = index;
101
- }
102
- return active === -1 ? last : active;
103
- }
104
- #historyTools(tailIndex) {
105
- // The footer slot shows the tail only while the block runs; once it carries the request
106
- // totals the tail belongs to the list, or its call would disappear from the block.
107
- const tail = this.#settled ? -1 : tailIndex;
108
- const indices = [];
109
- for (const [index, member] of this.#members.entries()) {
110
- if (member.kind === "tool" && index !== tail)
111
- indices.push(index);
112
- }
113
- return indices;
114
- }
115
170
  #thinkingItems() {
116
- return this.#members.filter((member) => (member.thinkingChars ?? 0) > 0);
171
+ return this.#members.filter((member) => member.thinking !== undefined && member.thinking !== "");
117
172
  }
118
173
  #noticeItems() {
119
174
  return this.#members.filter((member) => member.kind === "notice");
120
175
  }
176
+ /**
177
+ * Completed calls only: a container's final children replace its parent exactly once, and a call
178
+ * still streaming args or running has not produced that fact yet.
179
+ */
121
180
  #calls() {
122
181
  let total = 0;
123
- for (const member of this.#members) {
124
- if (member.kind !== "tool")
182
+ // The tail outside the group is still this block's work, so its calls belong to the header.
183
+ for (const member of this.#block()) {
184
+ if (member.kind !== "tool" || member.completed !== true)
125
185
  continue;
126
186
  // The count is a fact the tool reports; a bounded child list must not lower it.
127
187
  total += member.childCount ?? (member.children === undefined ? 1 : member.children.length);
128
188
  }
129
189
  return total;
130
190
  }
191
+ /** The group's own members, then the unit it leaves outside. */
192
+ #block() {
193
+ return this.#tail === undefined ? [...this.#members] : [...this.#members, this.#tail];
194
+ }
195
+ /** The block's own last fact: the newest member that states a status, tail included. */
196
+ #lastStatus() {
197
+ const block = this.#block();
198
+ for (let index = block.length - 1; index >= 0; index -= 1) {
199
+ const status = block[index]?.status;
200
+ if (status !== undefined)
201
+ return status;
202
+ }
203
+ return undefined;
204
+ }
205
+ /**
206
+ * The surface states the block's last fact, not its worst one, so one old failure cannot tint a
207
+ * block forever. A settled block keeps that color for five seconds and then falls back to the
208
+ * neutral surface, because a transcript full of settled blocks is a transcript to read, not a wall
209
+ * of colored panels.
210
+ */
211
+ #surfaceToken() {
212
+ const last = this.#lastStatus();
213
+ if (last === "running" || last === "streaming") {
214
+ this.#settledAt = undefined;
215
+ this.#stopHold();
216
+ return "toolPendingBg";
217
+ }
218
+ const now = performance.now();
219
+ this.#settledAt ??= now;
220
+ const held = now - this.#settledAt;
221
+ if (last === undefined || held >= SURFACE_HOLD_MS)
222
+ return "toolPendingBg";
223
+ this.#hold(SURFACE_HOLD_MS - held);
224
+ return last === "error" ? "toolErrorBg" : "toolSuccessBg";
225
+ }
226
+ /** One repaint when the hold expires; nothing keeps ticking after that. */
227
+ #hold(delay) {
228
+ if (this.#holdTimer !== undefined)
229
+ return;
230
+ this.#holdTimer = setTimeout(() => {
231
+ this.#holdTimer = undefined;
232
+ this.#redraw();
233
+ }, delay);
234
+ }
235
+ #stopHold() {
236
+ if (this.#holdTimer === undefined)
237
+ return;
238
+ clearTimeout(this.#holdTimer);
239
+ this.#holdTimer = undefined;
240
+ }
241
+ /**
242
+ * Release the block's own lifetime. The projection drops a block when it is evicted, when
243
+ * grouping is disabled, or when the patch unloads; an idempotent dispose keeps that path honest,
244
+ * so a removed block can never ask for a repaint.
245
+ */
246
+ dispose() {
247
+ this.#stopHold();
248
+ }
131
249
  #usage() {
132
250
  let requests = 0;
133
251
  let input = 0;
134
252
  let output = 0;
135
- let cacheTotal = 0;
136
- let latest;
137
- for (const member of this.#members) {
138
- if (member.kind !== "assistant")
139
- continue;
253
+ let measured = false;
254
+ let latestPrompt;
255
+ for (const fact of this.#requests) {
140
256
  requests += 1;
141
- if (member.usage === undefined)
257
+ const usage = fact.usage;
258
+ if (usage === undefined)
142
259
  continue;
143
- input += member.usage.input;
144
- output += member.usage.output;
145
- cacheTotal += member.usage.cacheRead + member.usage.cacheWrite;
146
- latest = member.usage;
260
+ measured = true;
261
+ input += usage.input;
262
+ output += usage.output;
263
+ // A streaming snapshot can arrive with every field zeroed; that is a placeholder, not a
264
+ // measured prompt, so it must not overwrite the last known cache-hit fact.
265
+ if (usage.input + usage.cacheRead + usage.cacheWrite > 0)
266
+ latestPrompt = usage;
147
267
  }
148
- return { requests, input, output, cacheTotal, latest };
268
+ return { requests, measured, input, output, latestPrompt };
149
269
  }
150
270
  /** The block footer: the whole block's request and token totals, fixed under the body. */
151
271
  #footerRow() {
152
272
  const theme = this.#ui.theme;
153
273
  const usage = this.#usage();
154
- const input = usage.latest === undefined ? "—" : formatTokens(usage.input);
155
- const output = usage.latest === undefined ? "—" : formatTokens(usage.output);
274
+ const input = usage.measured ? formatTokens(usage.input) : "—";
275
+ const output = usage.measured ? formatTokens(usage.output) : "—";
156
276
  const parts = [
157
- `Requests ×${usage.requests}`,
277
+ `requests ×${usage.requests}`,
158
278
  `IN ${input}`,
159
279
  `OUT ${output}`,
160
- `CH ${cacheHitRate(usage.latest, usage.cacheTotal)}`,
280
+ `CH ${cacheHitRate(usage.latestPrompt)}`,
161
281
  ];
162
282
  return theme.fg("dim", parts.join(" · "));
163
283
  }
164
284
  #categoryRow(category, label, detail) {
165
285
  const theme = this.#ui.theme;
166
- const pin = `${theme.fg("dim", this.#category === category ? "▾" : "▸")} `;
286
+ const open = this.#category === category;
287
+ const pin = `${theme.fg("dim", open ? "▾" : "▸")} `;
167
288
  const tail = detail === "" ? "" : `${theme.fg("dim", " · ")}${theme.fg("dim", detail)}`;
168
- return `${pin}${theme.fg("muted", label)}${tail}`;
289
+ // The open category is the block's focus point; the arrow still carries the state alone.
290
+ return `${pin}${theme.fg(open ? "accent" : "text", label)}${tail}`;
169
291
  }
170
292
  #renderBody(width) {
171
293
  if (this.#renderedExpanded)
172
294
  return this.#renderRaw(width);
173
295
  return this.#renderFrame(width);
174
296
  }
297
+ /**
298
+ * The whole block expanded: no group shell and no per-member fill, so every block shows only the
299
+ * styling its own component draws.
300
+ */
175
301
  #renderRaw(width) {
176
- const theme = this.#ui.theme;
177
- const rail = theme.fg("muted", "─".repeat(Math.max(0, width)));
178
- const lines = [rail];
179
- const hits = [{ height: 1, action: { kind: "none" } }];
302
+ const lines = [];
303
+ const hits = [];
180
304
  for (const member of this.#members) {
181
- const wrapper = this.#offsetWrapper(member.component);
182
- const rows = renderWithoutToolRails(wrapper, Math.max(1, width));
183
- if (rows.length === 0)
305
+ const wrapper = this.#memberWrapper(member);
306
+ const own = wrapper.render(Math.max(1, width));
307
+ if (own.length === 0)
184
308
  continue;
185
- hits.push({ height: rows.length, action: { kind: "member", wrapper } });
186
- for (const row of rows)
309
+ // One blank row between adjacent semantic blocks; inner blank rows stay where they are.
310
+ if (lines.length > 0) {
311
+ lines.push("");
312
+ hits.push({ height: 1, action: { kind: "none" } });
313
+ }
314
+ // The expanded group is transparent: every block keeps only the styling it draws itself.
315
+ for (const row of own)
187
316
  lines.push(fitRow(row, Math.max(0, width), "…"));
317
+ hits.push({ height: own.length, action: { kind: "member", wrapper } });
188
318
  }
189
- lines.push(rail);
190
- hits.push({ height: 1, action: { kind: "none" } });
191
319
  this.#hits = hits;
192
320
  return lines;
193
321
  }
194
- /** Header, rails, the three categories, and the read-only tail; every row is one terminal row. */
322
+ /** Header, the three categories, and the statistics footer; every row is one terminal row. */
195
323
  #renderFrame(width) {
196
324
  const theme = this.#ui.theme;
197
325
  const lines = [];
@@ -208,10 +336,12 @@ export class ToolGroupView extends Container {
208
336
  lines.push(fitRow(row, Math.max(0, width), "…"));
209
337
  hits.push({ height: rows.length, action });
210
338
  };
211
- const rail = theme.fg("muted", "─".repeat(Math.max(0, width)));
212
- const tailIndex = this.#tailIndex();
213
- const tools = this.#historyTools(tailIndex);
214
- push(rail);
339
+ const tools = [];
340
+ for (const [index, member] of this.#members.entries()) {
341
+ if (member.kind === "tool")
342
+ tools.push(index);
343
+ }
344
+ push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
215
345
  const notices = this.#noticeItems();
216
346
  if (notices.length > 0) {
217
347
  const kinds = new Map();
@@ -229,19 +359,25 @@ export class ToolGroupView extends Container {
229
359
  }
230
360
  const thinking = this.#thinkingItems();
231
361
  if (thinking.length > 0) {
232
- const tokens = Math.ceil(thinking.reduce((sum, member) => sum + (member.thinkingChars ?? 0), 0) / 4);
233
- push(this.#categoryRow("thinking", "thinking", `${String(tokens)} tokens`), {
362
+ const tokens = Math.ceil(thinking.reduce((sum, member) => sum + (member.thinking?.length ?? 0), 0) / 4);
363
+ push(this.#categoryRow("thinking", `thinking x${thinking.length}`, `${String(tokens)} tokens`), {
234
364
  kind: "category",
235
365
  category: "thinking",
236
366
  });
237
367
  if (this.#category === "thinking")
238
368
  this.#renderThinking(thinking, width, push);
239
369
  }
370
+ // Names and header calls share one eligibility: only completed calls are counted. A tool that
371
+ // is still streaming or running stays listed and expandable, but adds no number.
240
372
  const counts = new Map();
373
+ let listed = 0;
241
374
  for (const index of tools) {
242
375
  const member = this.#members[index];
243
376
  if (member === undefined)
244
377
  continue;
378
+ listed += 1;
379
+ if (member.completed !== true)
380
+ continue;
245
381
  if (member.children === undefined) {
246
382
  if (member.title !== undefined)
247
383
  counts.set(member.title, (counts.get(member.title) ?? 0) + 1);
@@ -250,99 +386,104 @@ export class ToolGroupView extends Container {
250
386
  for (const child of member.children)
251
387
  counts.set(child.title, (counts.get(child.title) ?? 0) + 1);
252
388
  }
253
- if (counts.size > 0) {
389
+ if (listed > 0) {
254
390
  const names = [...counts]
255
391
  .map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
256
392
  .join(theme.fg("dim", " · "));
257
- push(this.#categoryRow("tools", names, ""), {
393
+ push(this.#categoryRow("tools", names === "" ? "tools" : names, ""), {
258
394
  kind: "category",
259
395
  category: "tools",
260
396
  });
261
397
  if (this.#category === "tools")
262
- this.#renderTools(theme, tools, width, push, pushBlock);
263
- }
264
- push(rail);
265
- // One footer slot: the running tail while the block is live, the block totals once it settles.
266
- const tail = this.#members[tailIndex];
267
- if (this.#settled)
268
- push(this.#footerRow());
269
- else if (tail !== undefined && tail.title !== undefined) {
270
- push(formatToolHeaderText({ status: tail.status, title: tail.title }, theme));
398
+ this.#renderTools(tools, width, push, pushBlock);
271
399
  }
400
+ // The rails bracket the header, categories, and any opened detail; the footer sits below them.
401
+ push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
402
+ // The footer is always the block's request and token totals: one row, never a tool status.
403
+ push(this.#footerRow());
272
404
  this.#hits = hits;
273
405
  return lines;
274
406
  }
275
- /** Every tool at its own level 0, one blank row between calls. */
276
- #renderTools(theme, tools, width, push, pushBlock) {
407
+ /**
408
+ * Every tool at its own fold level, one blank row between calls. A container is one tool here too:
409
+ * its component draws its own board, and `children`/childCount only feed the counts.
410
+ */
411
+ #renderTools(tools, width, push, pushBlock) {
277
412
  let first = true;
278
- const separate = () => {
279
- if (!first)
280
- push("");
281
- first = false;
282
- };
283
413
  for (const memberIndex of tools) {
284
414
  const member = this.#members[memberIndex];
285
415
  if (member === undefined)
286
416
  continue;
287
- if (member.children === undefined) {
288
- const wrapper = this.#offsetWrapper(member.component);
289
- separate();
290
- // One hit for the whole tool: its own rows carry the header, copy slot, and fold state.
291
- pushBlock(renderWithoutToolRails(wrapper, Math.max(1, width)), {
292
- kind: "member",
293
- wrapper,
294
- });
417
+ const wrapper = this.#offsetWrapper(member.component);
418
+ const rows = wrapper.render(Math.max(1, width));
419
+ if (rows.length === 0)
295
420
  continue;
296
- }
297
- for (const [index, child] of member.children.entries()) {
298
- separate();
299
- const open = this.#item?.member === member.component && this.#item.child === index;
300
- // The container's own row already reads `summary · duration`; a child listed in the
301
- // group must read the same, or the same call looks different in the two views.
302
- const summary = [child.summary ?? "", formatDurationMs(child.durationMs)]
303
- .filter((part) => part !== "")
304
- .join(" · ");
305
- push(`${theme.fg("dim", open ? "▾" : "▸")} ${formatToolHeaderText({ status: child.status, title: child.title, summary }, theme)}`, { kind: "item", member: member.component, child: index });
306
- if (!open || child.detail === undefined)
307
- continue;
308
- for (const row of this.#detailRows(child.detail, width))
309
- push(row);
310
- }
421
+ if (!first)
422
+ push("");
423
+ first = false;
424
+ // One hit for the whole tool: its own rows carry the header, copy slot, and fold state.
425
+ pushBlock(rows, { kind: "member", wrapper });
311
426
  }
312
427
  }
313
- /** One thinking block per assistant turn: a label row, one preview row, and the text on demand. */
428
+ /**
429
+ * Thinking items. While the host hides thinking, an item is one row: its 20-grapheme content
430
+ * preview, which opens the full text. While thinking is visible, the full text is already there.
431
+ */
314
432
  #renderThinking(thinking, width, push) {
315
- const theme = this.#ui.theme;
316
- let first = true;
317
433
  for (const member of thinking) {
318
- if (!first)
319
- push("");
320
- first = false;
321
- const open = this.#item?.member === member.component;
322
- const tokens = Math.ceil((member.thinkingChars ?? 0) / 4);
323
- push(`${theme.fg("dim", open ? "▾" : "▸")} ${theme.fg("muted", "thinking")}${theme.fg("dim", ` · ${String(tokens)} tokens`)}`, { kind: "item", member: member.component });
324
434
  const text = member.thinking;
325
435
  if (text === undefined)
326
436
  continue;
327
- if (open) {
328
- for (const row of this.#detailRows(text, width))
437
+ if (member.thinkingHidden !== true) {
438
+ for (const row of this.#thinkingRows(text, width))
329
439
  push(row);
330
440
  continue;
331
441
  }
332
- const line = preview(text);
333
- if (line !== undefined)
334
- push(`${theme.fg("dim", "▎")} ${theme.fg("thinkingText", line)}`);
442
+ const open = this.#item === member.component;
443
+ if (!open) {
444
+ push(this.#thinkingPreviewRow(text, width), { kind: "item", member: member.component });
445
+ continue;
446
+ }
447
+ // The first row is still the item's own hit area, so a click folds it back.
448
+ for (const [index, row] of this.#thinkingRows(text, width).entries())
449
+ push(row, index === 0 ? { kind: "item", member: member.component } : { kind: "none" });
335
450
  }
336
451
  }
337
- /** Notices keep one row each and open their provider-rendered detail in place. */
452
+ /** One preview row: the grapheme head plus an ellipsis, then cut to the cells the rail leaves. */
453
+ #thinkingPreviewRow(text, width) {
454
+ const theme = this.#ui.theme;
455
+ const { head, more } = thinkingPreview(text);
456
+ const room = Math.max(1, width - 2);
457
+ const fitted = truncateToWidth(head, more ? Math.max(1, room - 1) : room, "…");
458
+ const preview = more && fitted === head ? `${head}…` : fitted;
459
+ return `${theme.fg("dim", "▎")} ${theme.fg("thinkingText", theme.italic(preview))}`;
460
+ }
461
+ /** Full thinking text: paragraphs keep their separation, every row wraps to the indent's width. */
462
+ #thinkingRows(text, width) {
463
+ const theme = this.#ui.theme;
464
+ const inner = Math.max(1, width - 2);
465
+ return toContentRows(text).flatMap((line) => {
466
+ const wrapped = wrapTextWithAnsi(line, inner);
467
+ return (wrapped.length > 0 ? wrapped : [""]).map((row) => row === "" ? "" : ` ${theme.fg("thinkingText", theme.italic(row))}`);
468
+ });
469
+ }
470
+ /**
471
+ * Notices print the content their own view lays out: the preview is directly visible, and only a
472
+ * real body adds a pin. A plain notify therefore appears once, indented, with no item action.
473
+ */
338
474
  #renderNotices(notices, width, push) {
339
475
  const theme = this.#ui.theme;
340
476
  for (const member of notices) {
341
477
  const detail = member.detail;
342
- const open = detail !== undefined && this.#item?.member === member.component;
343
- const pin = detail === undefined ? "" : `${theme.fg("dim", open ? "▾" : "▸")} `;
344
- push(`${pin}${member.summary ?? member.title ?? ""}`, detail === undefined ? { kind: "none" } : { kind: "item", member: member.component });
345
- if (!open)
478
+ const open = detail !== undefined && this.#item === member.component;
479
+ if (member.summary !== undefined) {
480
+ const pin = detail === undefined ? "" : `${theme.fg("dim", open ? "▾" : "▸")} `;
481
+ push(`${pin}${member.summary}`, detail === undefined ? { kind: "none" } : { kind: "item", member: member.component });
482
+ }
483
+ if (member.content !== undefined)
484
+ for (const row of member.content(width))
485
+ push(row);
486
+ if (!open || detail === undefined)
346
487
  continue;
347
488
  for (const row of detail(width))
348
489
  push(row);
@@ -368,13 +509,7 @@ export class ToolGroupView extends Container {
368
509
  return { handled: true, render: true };
369
510
  }
370
511
  if (action.kind === "item") {
371
- const same = this.#item?.member === action.member && this.#item.child === action.child;
372
- this.#item = same
373
- ? undefined
374
- : {
375
- member: action.member,
376
- ...(action.child === undefined ? {} : { child: action.child }),
377
- };
512
+ this.#item = this.#item === action.member ? undefined : action.member;
378
513
  this.#redraw();
379
514
  return { handled: true, render: true };
380
515
  }
@@ -406,20 +541,58 @@ export class ToolGroupView extends Container {
406
541
  };
407
542
  }
408
543
  /**
409
- * Expanded detail keeps its text. The user opened it to read it, so a line wider than the frame is
410
- * wrapped to the width the indent leaves instead of being cut with an ellipsis.
544
+ * A member's rows for the raw view: every original part in order, without Pi's leading separator
545
+ * and with exactly one blank row between parts (a thinking run keeps its paragraph separation).
411
546
  */
412
- #detailRows(text, width) {
413
- const inner = Math.max(1, width - 2);
414
- return text.split("\n").flatMap((line) => {
415
- const wrapped = wrapTextWithAnsi(line, inner);
416
- return (wrapped.length > 0 ? wrapped : [""]).map((row) => ` ${row}`);
417
- });
547
+ #memberWrapper(member) {
548
+ const parts = memberParts(member);
549
+ let offsets = [];
550
+ return {
551
+ render: (width) => {
552
+ const rows = [];
553
+ const next = [];
554
+ for (const part of parts) {
555
+ const all = part.render(width);
556
+ const skip = all[0] === "" ? 1 : 0;
557
+ const own = all.slice(skip);
558
+ if (own.length === 0)
559
+ continue;
560
+ if (rows.length > 0)
561
+ rows.push("");
562
+ next.push({ part, top: rows.length, height: own.length, skip });
563
+ rows.push(...own);
564
+ }
565
+ offsets = next;
566
+ return rows;
567
+ },
568
+ handleMouse(event) {
569
+ for (const { part, top, height, skip } of offsets) {
570
+ if (event.y < top || event.y >= top + height)
571
+ continue;
572
+ return part.handleMouse?.({
573
+ ...event,
574
+ y: event.y - top + skip,
575
+ height: height + skip,
576
+ });
577
+ }
578
+ return undefined;
579
+ },
580
+ invalidate() { },
581
+ };
418
582
  }
419
- update(members, settled) {
583
+ /**
584
+ * `clock` belongs to this continuous block. Restored history has none until live content joins it.
585
+ *
586
+ * `tail` is the block's last visible unit, which the projection draws outside the group; its calls,
587
+ * requests, and background state still belong to this block.
588
+ */
589
+ update(members, tail, requests, clock) {
420
590
  this.#members = members;
421
- this.#settled = settled;
422
- if (this.#item !== undefined && !members.some((m) => m.component === this.#item?.member))
591
+ this.#tail = tail;
592
+ this.#requests = requests;
593
+ if (this.#clock === undefined)
594
+ this.#clock = clock;
595
+ if (this.#item !== undefined && !members.some((m) => m.component === this.#item))
423
596
  this.#item = undefined;
424
597
  if (this.#category === "thinking" && this.#thinkingItems().length === 0)
425
598
  this.#category = undefined;
@@ -443,16 +616,30 @@ export class ToolGroupView extends Container {
443
616
  }
444
617
  this.#renderedExpanded = this.#raw || globalExpanded;
445
618
  const theme = this.#ui.theme;
619
+ this.#syncShell();
620
+ this.#box.setBgFn(backgroundFill(theme, this.#surfaceToken()));
446
621
  const calls = this.#calls();
447
- const elapsed = this.#run === undefined
622
+ const elapsed = this.#clock === undefined
448
623
  ? "—"
449
- : formatElapsed((this.#run.endedAt ?? performance.now()) - this.#run.startedAt);
624
+ : formatElapsed((this.#clock.endedAt ?? performance.now()) - this.#clock.startedAt);
450
625
  const details = [
451
- theme.fg("muted", "collapse views"),
626
+ theme.fg("text", "collapse views"),
452
627
  theme.fg("dim", elapsed),
453
628
  theme.fg("dim", `${calls} ${calls === 1 ? "call" : "calls"}`),
454
629
  ].join(theme.fg("dim", " · "));
455
630
  this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width), "…"));
456
631
  return super.render(width);
457
632
  }
633
+ /**
634
+ * The group shell follows the fold state: a background block around the aggregate, or transparent
635
+ * rows when the whole block is expanded. Only the shell changes; members keep their components.
636
+ */
637
+ #syncShell() {
638
+ const shell = this.#renderedExpanded ? "raw" : "collapsed";
639
+ if (this.#shell === shell)
640
+ return;
641
+ this.#shell = shell;
642
+ this.children =
643
+ shell === "raw" ? [this.#spacer, this.#heading, this.#body] : [this.#spacer, this.#box];
644
+ }
458
645
  }