@hheei/pi-ext-ui 0.6.0 → 0.7.1

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,77 @@
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
+ /** A tool that has not received its final result yet: its own rows are still changing height. */
8
+ function isLiveTail(member) {
9
+ return member.kind === "tool" && member.completed !== true;
10
+ }
11
+ /** Every original component of a member, in transcript order. */
12
+ export function memberParts(member) {
13
+ return member.parts ?? [member.component];
14
+ }
15
+ export class TailUnit {
16
+ #parts;
17
+ #offsets = [];
18
+ /** Rows already shown for a live member: a tool that grows may not shrink back and shift the block. */
19
+ #reserved = 0;
20
+ #live;
21
+ constructor(member) {
22
+ this.#parts = memberParts(member);
23
+ this.#live = isLiveTail(member);
24
+ }
25
+ /** The same tail with a newer fact: the component list can grow, the reserved height must not. */
26
+ update(member) {
27
+ this.#parts = memberParts(member);
28
+ this.#live = isLiveTail(member);
29
+ }
30
+ render(width) {
31
+ const offsets = [];
32
+ const rows = [];
33
+ for (const part of this.#parts) {
34
+ const all = part.render(width);
35
+ const skip = all[0] === "" ? 1 : 0;
36
+ const own = all.slice(skip);
37
+ if (own.length === 0)
38
+ continue;
39
+ if (rows.length > 0)
40
+ rows.push("");
41
+ offsets.push({ part, top: rows.length, height: own.length, skip });
42
+ rows.push(...own);
43
+ }
44
+ this.#offsets = offsets;
45
+ if (rows.length === 0)
46
+ return rows;
47
+ const out = rows[0] === "" ? rows : ["", ...rows];
48
+ // A live tail grows downward from a fixed top: the host keeps the transcript's bottom in view, so
49
+ // a row count that falls back drags everything above it down. Reserve the tallest frame instead.
50
+ if (this.#live)
51
+ this.#reserved = Math.max(this.#reserved, out.length);
52
+ else
53
+ this.#reserved = 0;
54
+ while (out.length < this.#reserved)
55
+ out.push("");
56
+ return out;
57
+ }
58
+ handleMouse(event) {
59
+ for (const { part, top, height, skip } of this.#offsets) {
60
+ if (event.y < top || event.y >= top + height)
61
+ continue;
62
+ return part.handleMouse?.({
63
+ ...event,
64
+ y: event.y - top + skip,
65
+ height: height + skip,
66
+ });
67
+ }
68
+ return undefined;
69
+ }
70
+ invalidate() {
71
+ for (const part of this.#parts)
72
+ part.invalidate?.();
73
+ }
74
+ }
5
75
  function formatElapsed(milliseconds) {
6
76
  const total = Math.max(0, Math.floor(milliseconds / 1000));
7
77
  const seconds = total % 60;
@@ -25,16 +95,37 @@ function formatTokens(count) {
25
95
  return `${(count / 1_000_000).toFixed(1)}M`;
26
96
  return `${String(Math.round(count / 1_000_000))}M`;
27
97
  }
28
- function cacheHitRate(latest, cacheTotal) {
29
- if (latest === undefined || cacheTotal <= 0)
30
- return "—";
31
- const prompt = latest.input + latest.cacheRead + latest.cacheWrite;
98
+ /**
99
+ * The native footer's cache-hit rule: the last request that actually measured a prompt. A zeroed
100
+ * streaming placeholder is not that request, and a measured prompt with no cache read is 0%, not
101
+ * unknown — missing data and a real zero must not share one placeholder.
102
+ */
103
+ function cacheHitRate(latestPrompt) {
104
+ if (latestPrompt === undefined)
105
+ return undefined;
106
+ const prompt = latestPrompt.input + latestPrompt.cacheRead + latestPrompt.cacheWrite;
32
107
  if (prompt <= 0)
33
- return "—";
34
- return `${((latest.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
108
+ return undefined;
109
+ return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1)}%`;
35
110
  }
36
- function preview(text) {
37
- return text.split("\n").find((line) => line.trim() !== "");
111
+ const PREVIEW_GRAPHEMES = 20;
112
+ const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
113
+ /**
114
+ * The thinking preview: the first line's leading graphemes, and whether anything follows them. A
115
+ * grapheme cap keeps emoji and CJK clusters whole; the frame still truncates to the cells it has.
116
+ */
117
+ function thinkingPreview(text) {
118
+ const content = text.trim();
119
+ const line = toSingleRow(content.split("\n").find((row) => row.trim() !== "") ?? "");
120
+ let head = "";
121
+ let count = 0;
122
+ for (const { segment } of graphemes.segment(line)) {
123
+ if (count === PREVIEW_GRAPHEMES)
124
+ break;
125
+ head += segment;
126
+ count++;
127
+ }
128
+ return { head, more: content !== head };
38
129
  }
39
130
  /**
40
131
  * The collapsed frame of one activity block.
@@ -47,31 +138,48 @@ export class ToolGroupView extends Container {
47
138
  #header = new Text("", 0, 0);
48
139
  #ui;
49
140
  #redraw;
50
- #run;
141
+ /** The stable clock for this block; restored history stays unknown until live content joins. */
142
+ #clock;
143
+ /** The block's own members, drawn inside the group. */
51
144
  #members = [];
52
- #settled = false;
145
+ /** The block's last visible unit, drawn outside the group by its own component. */
146
+ #tail;
147
+ /** Every model request the block owns, in transcript order, including the tail's and native prose. */
148
+ #requests = [];
53
149
  #raw = false;
54
150
  #category;
55
151
  #item;
56
152
  #renderedExpanded = false;
153
+ #shell = "collapsed";
154
+ /** When the block last became quiet; the surface hold counts from here. */
155
+ #settledAt;
156
+ #holdTimer;
57
157
  #hits = [];
58
- constructor(ui, redraw, run) {
158
+ #heading;
159
+ #body;
160
+ // One extra colored row above and below: the block needs breathing room inside its background.
161
+ #box = new Box(0, 1);
162
+ #spacer = new Spacer(1);
163
+ constructor(ui, redraw) {
59
164
  super();
60
165
  this.#ui = ui;
61
166
  this.#redraw = redraw;
62
- this.#run = run;
63
- const heading = new MouseRegion(this.#header, (event) => {
167
+ this.#heading = new MouseRegion(this.#header, (event) => {
64
168
  if (event.type !== "click" || event.button !== "left")
65
169
  return undefined;
66
170
  this.#toggleRaw();
67
171
  return { handled: true, render: true };
68
172
  });
69
- const body = {
173
+ this.#body = {
70
174
  render: (width) => this.#renderBody(width),
71
175
  handleMouse: (event) => this.#hit(event),
72
176
  invalidate() { },
73
177
  };
74
- this.children = [new Spacer(1), heading, body];
178
+ // The group background covers the header, categories, and footer. Zero padding keeps the rows
179
+ // at the width they were laid out for; a tool's own outputPad must not be counted twice.
180
+ this.#box.addChild(this.#heading);
181
+ this.#box.addChild(this.#body);
182
+ this.children = [this.#spacer, this.#box];
75
183
  }
76
184
  #toggleRaw() {
77
185
  this.#raw = this.#ui.getToolsExpanded() ? false : !this.#raw;
@@ -85,113 +193,176 @@ export class ToolGroupView extends Container {
85
193
  }
86
194
  this.#redraw();
87
195
  }
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
196
  #thinkingItems() {
116
- return this.#members.filter((member) => (member.thinkingChars ?? 0) > 0);
197
+ return this.#members.filter((member) => member.thinking !== undefined && member.thinking !== "");
117
198
  }
118
199
  #noticeItems() {
119
200
  return this.#members.filter((member) => member.kind === "notice");
120
201
  }
202
+ /**
203
+ * Completed calls only: a container's final children replace its parent exactly once, and a call
204
+ * still streaming args or running has not produced that fact yet.
205
+ */
121
206
  #calls() {
122
207
  let total = 0;
123
- for (const member of this.#members) {
124
- if (member.kind !== "tool")
208
+ // The tail outside the group is still this block's work, so its calls belong to the header.
209
+ for (const member of this.#block()) {
210
+ if (member.kind !== "tool" || member.completed !== true)
125
211
  continue;
126
212
  // The count is a fact the tool reports; a bounded child list must not lower it.
127
213
  total += member.childCount ?? (member.children === undefined ? 1 : member.children.length);
128
214
  }
129
215
  return total;
130
216
  }
217
+ /** The group's own members, then the unit it leaves outside. */
218
+ #block() {
219
+ return this.#tail === undefined ? [...this.#members] : [...this.#members, this.#tail];
220
+ }
221
+ /** The block's own last fact: the newest member that states a status, tail included. */
222
+ #lastStatus() {
223
+ const block = this.#block();
224
+ for (let index = block.length - 1; index >= 0; index -= 1) {
225
+ const status = block[index]?.status;
226
+ if (status !== undefined)
227
+ return status;
228
+ }
229
+ return undefined;
230
+ }
231
+ /**
232
+ * The surface states the block's last fact, not its worst one, so one old failure cannot tint a
233
+ * block forever. A settled block keeps that color for five seconds and then falls back to the
234
+ * neutral surface, because a transcript full of settled blocks is a transcript to read, not a wall
235
+ * of colored panels.
236
+ */
237
+ #surfaceToken() {
238
+ const last = this.#lastStatus();
239
+ if (last === "running" || last === "streaming") {
240
+ this.#settledAt = undefined;
241
+ this.#stopHold();
242
+ return "toolPendingBg";
243
+ }
244
+ const now = performance.now();
245
+ this.#settledAt ??= now;
246
+ const held = now - this.#settledAt;
247
+ if (last === undefined || held >= SURFACE_HOLD_MS)
248
+ return "toolPendingBg";
249
+ this.#hold(SURFACE_HOLD_MS - held);
250
+ return last === "error" ? "toolErrorBg" : "toolSuccessBg";
251
+ }
252
+ /** One repaint when the hold expires; nothing keeps ticking after that. */
253
+ #hold(delay) {
254
+ if (this.#holdTimer !== undefined)
255
+ return;
256
+ this.#holdTimer = setTimeout(() => {
257
+ this.#holdTimer = undefined;
258
+ this.#redraw();
259
+ }, delay);
260
+ }
261
+ #stopHold() {
262
+ if (this.#holdTimer === undefined)
263
+ return;
264
+ clearTimeout(this.#holdTimer);
265
+ this.#holdTimer = undefined;
266
+ }
267
+ /**
268
+ * Release the block's own lifetime. The projection drops a block when it is evicted, when
269
+ * grouping is disabled, or when the patch unloads; an idempotent dispose keeps that path honest,
270
+ * so a removed block can never ask for a repaint.
271
+ */
272
+ dispose() {
273
+ this.#stopHold();
274
+ }
131
275
  #usage() {
132
276
  let requests = 0;
133
277
  let input = 0;
134
278
  let output = 0;
135
- let cacheTotal = 0;
136
- let latest;
137
- for (const member of this.#members) {
138
- if (member.kind !== "assistant")
139
- continue;
279
+ let cacheRead = 0;
280
+ let cacheWrite = 0;
281
+ let latestPrompt;
282
+ for (const fact of this.#requests) {
140
283
  requests += 1;
141
- if (member.usage === undefined)
284
+ const usage = fact.usage;
285
+ if (usage === undefined)
142
286
  continue;
143
- input += member.usage.input;
144
- output += member.usage.output;
145
- cacheTotal += member.usage.cacheRead + member.usage.cacheWrite;
146
- latest = member.usage;
287
+ input += usage.input;
288
+ output += usage.output;
289
+ cacheRead += usage.cacheRead;
290
+ cacheWrite += usage.cacheWrite;
291
+ // A streaming snapshot can arrive with every field zeroed; that is a placeholder, not a
292
+ // measured prompt, so it must not overwrite the last known cache-hit fact.
293
+ if (usage.input + usage.cacheRead + usage.cacheWrite > 0)
294
+ latestPrompt = usage;
147
295
  }
148
- return { requests, input, output, cacheTotal, latest };
296
+ return { requests, input, output, cacheRead, cacheWrite, latestPrompt };
149
297
  }
150
- /** The block footer: the whole block's request and token totals, fixed under the body. */
298
+ /**
299
+ * The block footer: the block's request count, then the same token facts and the same symbols
300
+ * Pi's own footer uses (`↑input ↓output RcacheRead WcacheWrite CHrate`). Pi totals the whole
301
+ * session; this totals one block, so the two never have to agree on the number.
302
+ */
151
303
  #footerRow() {
152
304
  const theme = this.#ui.theme;
153
305
  const usage = this.#usage();
154
- const input = usage.latest === undefined ? "—" : formatTokens(usage.input);
155
- const output = usage.latest === undefined ? "—" : formatTokens(usage.output);
156
- const parts = [
157
- `Requests ×${usage.requests}`,
158
- `IN ${input}`,
159
- `OUT ${output}`,
160
- `CH ${cacheHitRate(usage.latest, usage.cacheTotal)}`,
161
- ];
306
+ const parts = [`requests ×${usage.requests}`];
307
+ if (usage.input + usage.output + usage.cacheRead + usage.cacheWrite === 0) {
308
+ parts.push("↑—", "↓—");
309
+ return theme.fg("dim", parts.join(" · "));
310
+ }
311
+ if (usage.input > 0)
312
+ parts.push(`↑${formatTokens(usage.input)}`);
313
+ if (usage.output > 0)
314
+ parts.push(`↓${formatTokens(usage.output)}`);
315
+ if (usage.cacheRead > 0)
316
+ parts.push(`R${formatTokens(usage.cacheRead)}`);
317
+ if (usage.cacheWrite > 0)
318
+ parts.push(`W${formatTokens(usage.cacheWrite)}`);
319
+ if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
320
+ const rate = cacheHitRate(usage.latestPrompt);
321
+ if (rate !== undefined)
322
+ parts.push(`CH${rate}`);
323
+ }
162
324
  return theme.fg("dim", parts.join(" · "));
163
325
  }
164
326
  #categoryRow(category, label, detail) {
165
327
  const theme = this.#ui.theme;
166
- const pin = `${theme.fg("dim", this.#category === category ? "▾" : "▸")} `;
328
+ const open = this.#category === category;
329
+ const pin = `${theme.fg("dim", open ? "▾" : "▸")} `;
167
330
  const tail = detail === "" ? "" : `${theme.fg("dim", " · ")}${theme.fg("dim", detail)}`;
168
- return `${pin}${theme.fg("muted", label)}${tail}`;
331
+ // The open category is the block's focus point; the arrow still carries the state alone.
332
+ return `${pin}${theme.fg(open ? "accent" : "text", label)}${tail}`;
169
333
  }
170
334
  #renderBody(width) {
171
335
  if (this.#renderedExpanded)
172
336
  return this.#renderRaw(width);
173
337
  return this.#renderFrame(width);
174
338
  }
339
+ /**
340
+ * The whole block expanded: no group shell and no per-member fill, so every block shows only the
341
+ * styling its own component draws.
342
+ */
175
343
  #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" } }];
344
+ const lines = [];
345
+ const hits = [];
180
346
  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)
347
+ const wrapper = this.#memberWrapper(member);
348
+ const own = wrapper.render(Math.max(1, width));
349
+ if (own.length === 0)
184
350
  continue;
185
- hits.push({ height: rows.length, action: { kind: "member", wrapper } });
186
- for (const row of rows)
351
+ // One blank row between adjacent semantic blocks. A thinking run has no surface of its own,
352
+ // so it keeps the plain separator even when tools paint their own padded surfaces.
353
+ if (lines.length > 0) {
354
+ lines.push("");
355
+ hits.push({ height: 1, action: { kind: "none" } });
356
+ }
357
+ // The expanded group is transparent: every block keeps only the styling it draws itself.
358
+ for (const row of own)
187
359
  lines.push(fitRow(row, Math.max(0, width), "…"));
360
+ hits.push({ height: own.length, action: { kind: "member", wrapper } });
188
361
  }
189
- lines.push(rail);
190
- hits.push({ height: 1, action: { kind: "none" } });
191
362
  this.#hits = hits;
192
363
  return lines;
193
364
  }
194
- /** Header, rails, the three categories, and the read-only tail; every row is one terminal row. */
365
+ /** Header, the three categories, and the statistics footer; every row is one terminal row. */
195
366
  #renderFrame(width) {
196
367
  const theme = this.#ui.theme;
197
368
  const lines = [];
@@ -208,10 +379,12 @@ export class ToolGroupView extends Container {
208
379
  lines.push(fitRow(row, Math.max(0, width), "…"));
209
380
  hits.push({ height: rows.length, action });
210
381
  };
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);
382
+ const tools = [];
383
+ for (const [index, member] of this.#members.entries()) {
384
+ if (member.kind === "tool")
385
+ tools.push(index);
386
+ }
387
+ push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
215
388
  const notices = this.#noticeItems();
216
389
  if (notices.length > 0) {
217
390
  const kinds = new Map();
@@ -229,19 +402,25 @@ export class ToolGroupView extends Container {
229
402
  }
230
403
  const thinking = this.#thinkingItems();
231
404
  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`), {
405
+ const tokens = Math.ceil(thinking.reduce((sum, member) => sum + (member.thinking?.length ?? 0), 0) / 4);
406
+ push(this.#categoryRow("thinking", `thinking x${thinking.length}`, `${String(tokens)} tokens`), {
234
407
  kind: "category",
235
408
  category: "thinking",
236
409
  });
237
410
  if (this.#category === "thinking")
238
411
  this.#renderThinking(thinking, width, push);
239
412
  }
413
+ // Names and header calls share one eligibility: only completed calls are counted. A tool that
414
+ // is still streaming or running stays listed and expandable, but adds no number.
240
415
  const counts = new Map();
416
+ let listed = 0;
241
417
  for (const index of tools) {
242
418
  const member = this.#members[index];
243
419
  if (member === undefined)
244
420
  continue;
421
+ listed += 1;
422
+ if (member.completed !== true)
423
+ continue;
245
424
  if (member.children === undefined) {
246
425
  if (member.title !== undefined)
247
426
  counts.set(member.title, (counts.get(member.title) ?? 0) + 1);
@@ -250,99 +429,104 @@ export class ToolGroupView extends Container {
250
429
  for (const child of member.children)
251
430
  counts.set(child.title, (counts.get(child.title) ?? 0) + 1);
252
431
  }
253
- if (counts.size > 0) {
432
+ if (listed > 0) {
254
433
  const names = [...counts]
255
434
  .map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
256
435
  .join(theme.fg("dim", " · "));
257
- push(this.#categoryRow("tools", names, ""), {
436
+ push(this.#categoryRow("tools", names === "" ? "tools" : names, ""), {
258
437
  kind: "category",
259
438
  category: "tools",
260
439
  });
261
440
  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));
441
+ this.#renderTools(tools, width, push, pushBlock);
271
442
  }
443
+ // The rails bracket the header, categories, and any opened detail; the footer sits below them.
444
+ push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
445
+ // The footer is always the block's request and token totals: one row, never a tool status.
446
+ push(this.#footerRow());
272
447
  this.#hits = hits;
273
448
  return lines;
274
449
  }
275
- /** Every tool at its own level 0, one blank row between calls. */
276
- #renderTools(theme, tools, width, push, pushBlock) {
450
+ /**
451
+ * Every tool at its own fold level, one blank row between calls. A container is one tool here too:
452
+ * its component draws its own board, and `children`/childCount only feed the counts.
453
+ */
454
+ #renderTools(tools, width, push, pushBlock) {
277
455
  let first = true;
278
- const separate = () => {
279
- if (!first)
280
- push("");
281
- first = false;
282
- };
283
456
  for (const memberIndex of tools) {
284
457
  const member = this.#members[memberIndex];
285
458
  if (member === undefined)
286
459
  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
- });
460
+ const wrapper = this.#offsetWrapper(member.component);
461
+ const rows = wrapper.render(Math.max(1, width));
462
+ if (rows.length === 0)
295
463
  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
- }
464
+ if (!first)
465
+ push("");
466
+ first = false;
467
+ // One hit for the whole tool: its own rows carry the header, copy slot, and fold state.
468
+ pushBlock(rows, { kind: "member", wrapper });
311
469
  }
312
470
  }
313
- /** One thinking block per assistant turn: a label row, one preview row, and the text on demand. */
471
+ /**
472
+ * Thinking items. While the host hides thinking, an item is one row: its 20-grapheme content
473
+ * preview, which opens the full text. While thinking is visible, the full text is already there.
474
+ */
314
475
  #renderThinking(thinking, width, push) {
315
- const theme = this.#ui.theme;
316
- let first = true;
317
476
  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
477
  const text = member.thinking;
325
478
  if (text === undefined)
326
479
  continue;
327
- if (open) {
328
- for (const row of this.#detailRows(text, width))
480
+ if (member.thinkingHidden !== true) {
481
+ for (const row of this.#thinkingRows(text, width))
329
482
  push(row);
330
483
  continue;
331
484
  }
332
- const line = preview(text);
333
- if (line !== undefined)
334
- push(`${theme.fg("dim", "▎")} ${theme.fg("thinkingText", line)}`);
485
+ const open = this.#item === member.component;
486
+ if (!open) {
487
+ push(this.#thinkingPreviewRow(text, width), { kind: "item", member: member.component });
488
+ continue;
489
+ }
490
+ // The first row is still the item's own hit area, so a click folds it back.
491
+ for (const [index, row] of this.#thinkingRows(text, width).entries())
492
+ push(row, index === 0 ? { kind: "item", member: member.component } : { kind: "none" });
335
493
  }
336
494
  }
337
- /** Notices keep one row each and open their provider-rendered detail in place. */
495
+ /** One preview row: the grapheme head plus an ellipsis, then cut to the cells the rail leaves. */
496
+ #thinkingPreviewRow(text, width) {
497
+ const theme = this.#ui.theme;
498
+ const { head, more } = thinkingPreview(text);
499
+ const room = Math.max(1, width - 2);
500
+ const fitted = truncateToWidth(head, more ? Math.max(1, room - 1) : room, "…");
501
+ const preview = more && fitted === head ? `${head}…` : fitted;
502
+ return `${theme.fg("dim", "▎")} ${theme.fg("thinkingText", theme.italic(preview))}`;
503
+ }
504
+ /** Full thinking text: paragraphs keep their separation, every row wraps to the indent's width. */
505
+ #thinkingRows(text, width) {
506
+ const theme = this.#ui.theme;
507
+ const inner = Math.max(1, width - 2);
508
+ return toContentRows(text).flatMap((line) => {
509
+ const wrapped = wrapTextWithAnsi(line, inner);
510
+ return (wrapped.length > 0 ? wrapped : [""]).map((row) => row === "" ? "" : ` ${theme.fg("thinkingText", theme.italic(row))}`);
511
+ });
512
+ }
513
+ /**
514
+ * Notices print the content their own view lays out: the preview is directly visible, and only a
515
+ * real body adds a pin. A plain notify therefore appears once, indented, with no item action.
516
+ */
338
517
  #renderNotices(notices, width, push) {
339
518
  const theme = this.#ui.theme;
340
519
  for (const member of notices) {
341
520
  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)
521
+ const open = detail !== undefined && this.#item === member.component;
522
+ if (member.summary !== undefined) {
523
+ const pin = detail === undefined ? "" : `${theme.fg("dim", open ? "▾" : "▸")} `;
524
+ push(`${pin}${member.summary}`, detail === undefined ? { kind: "none" } : { kind: "item", member: member.component });
525
+ }
526
+ if (member.content !== undefined)
527
+ for (const row of member.content(width))
528
+ push(row);
529
+ if (!open || detail === undefined)
346
530
  continue;
347
531
  for (const row of detail(width))
348
532
  push(row);
@@ -368,13 +552,7 @@ export class ToolGroupView extends Container {
368
552
  return { handled: true, render: true };
369
553
  }
370
554
  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
- };
555
+ this.#item = this.#item === action.member ? undefined : action.member;
378
556
  this.#redraw();
379
557
  return { handled: true, render: true };
380
558
  }
@@ -406,20 +584,58 @@ export class ToolGroupView extends Container {
406
584
  };
407
585
  }
408
586
  /**
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.
587
+ * A member's rows for the raw view: every original part in order, without Pi's leading separator
588
+ * and with exactly one blank row between parts (a thinking run keeps its paragraph separation).
411
589
  */
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
- });
590
+ #memberWrapper(member) {
591
+ const parts = memberParts(member);
592
+ let offsets = [];
593
+ return {
594
+ render: (width) => {
595
+ const rows = [];
596
+ const next = [];
597
+ for (const part of parts) {
598
+ const all = part.render(width);
599
+ const skip = all[0] === "" ? 1 : 0;
600
+ const own = all.slice(skip);
601
+ if (own.length === 0)
602
+ continue;
603
+ if (rows.length > 0)
604
+ rows.push("");
605
+ next.push({ part, top: rows.length, height: own.length, skip });
606
+ rows.push(...own);
607
+ }
608
+ offsets = next;
609
+ return rows;
610
+ },
611
+ handleMouse(event) {
612
+ for (const { part, top, height, skip } of offsets) {
613
+ if (event.y < top || event.y >= top + height)
614
+ continue;
615
+ return part.handleMouse?.({
616
+ ...event,
617
+ y: event.y - top + skip,
618
+ height: height + skip,
619
+ });
620
+ }
621
+ return undefined;
622
+ },
623
+ invalidate() { },
624
+ };
418
625
  }
419
- update(members, settled) {
626
+ /**
627
+ * `clock` belongs to this continuous block. Restored history has none until live content joins it.
628
+ *
629
+ * `tail` is the block's last visible unit, which the projection draws outside the group; its calls,
630
+ * requests, and background state still belong to this block.
631
+ */
632
+ update(members, tail, requests, clock) {
420
633
  this.#members = members;
421
- this.#settled = settled;
422
- if (this.#item !== undefined && !members.some((m) => m.component === this.#item?.member))
634
+ this.#tail = tail;
635
+ this.#requests = requests;
636
+ if (this.#clock === undefined)
637
+ this.#clock = clock;
638
+ if (this.#item !== undefined && !members.some((m) => m.component === this.#item))
423
639
  this.#item = undefined;
424
640
  if (this.#category === "thinking" && this.#thinkingItems().length === 0)
425
641
  this.#category = undefined;
@@ -443,16 +659,30 @@ export class ToolGroupView extends Container {
443
659
  }
444
660
  this.#renderedExpanded = this.#raw || globalExpanded;
445
661
  const theme = this.#ui.theme;
662
+ this.#syncShell();
663
+ this.#box.setBgFn(backgroundFill(theme, this.#surfaceToken()));
446
664
  const calls = this.#calls();
447
- const elapsed = this.#run === undefined
665
+ const elapsed = this.#clock === undefined
448
666
  ? "—"
449
- : formatElapsed((this.#run.endedAt ?? performance.now()) - this.#run.startedAt);
667
+ : formatElapsed((this.#clock.endedAt ?? performance.now()) - this.#clock.startedAt);
450
668
  const details = [
451
- theme.fg("muted", "collapse views"),
669
+ theme.fg("text", "collapse views"),
452
670
  theme.fg("dim", elapsed),
453
671
  theme.fg("dim", `${calls} ${calls === 1 ? "call" : "calls"}`),
454
672
  ].join(theme.fg("dim", " · "));
455
673
  this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width), "…"));
456
674
  return super.render(width);
457
675
  }
676
+ /**
677
+ * The group shell follows the fold state: a background block around the aggregate, or transparent
678
+ * rows when the whole block is expanded. Only the shell changes; members keep their components.
679
+ */
680
+ #syncShell() {
681
+ const shell = this.#renderedExpanded ? "raw" : "collapsed";
682
+ if (this.#shell === shell)
683
+ return;
684
+ this.#shell = shell;
685
+ this.children =
686
+ shell === "raw" ? [this.#spacer, this.#heading, this.#body] : [this.#spacer, this.#box];
687
+ }
458
688
  }