@hheei/pi-ext-ui 0.5.2 → 0.6.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.
package/dist/extension.js CHANGED
@@ -32,6 +32,18 @@ export default function (pi) {
32
32
  }
33
33
  };
34
34
  const provider = createToolGroupSettings({ onEnabled: apply });
35
+ const onAgentStart = pi.on("agent_start", () => {
36
+ patch?.beginRun();
37
+ redrawGroups?.();
38
+ });
39
+ const onAgentEnd = pi.on("agent_end", () => {
40
+ patch?.endRun();
41
+ redrawGroups?.();
42
+ });
43
+ context.resources.add("run-clock", () => {
44
+ onAgentStart();
45
+ onAgentEnd();
46
+ });
35
47
  context.resources.add("tool-group-settings", registerSettings(provider, getRuntimeSettingsRegistry(pi)));
36
48
  context.resources.add("tool-groups", () => {
37
49
  redrawGroups = undefined;
@@ -3,4 +3,6 @@ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  export declare function installHostToolGroups(context: ExtensionContext): {
4
4
  dispose(): void;
5
5
  requestRender(): void;
6
+ beginRun(): void;
7
+ endRun(): void;
6
8
  };
@@ -1,8 +1,8 @@
1
1
  import { AssistantMessageComponent, InteractiveMode, ToolExecutionComponent, VERSION, } from "@earendil-works/pi-coding-agent";
2
- import { Container, MouseRegion, Spacer, stripTerminalSequences, Text, } from "@earendil-works/pi-tui";
3
- import { fitRow } from "@hheei/pi-ext-core";
2
+ import { Container, Spacer, stripTerminalSequences, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
4
3
  import { MessageView } from "./message-view.js";
5
- import { renderWithoutToolRails, ToolView, toSingleRow } from "./tool-view.js";
4
+ import { ToolGroupView } from "./tool-group-view.js";
5
+ import { ToolView, toSingleRow } from "./tool-view.js";
6
6
  const SEAMS = ["getRegisteredToolDefinition", "showExtensionNotify"];
7
7
  function record(value) {
8
8
  return typeof value === "object" && value !== null;
@@ -28,34 +28,56 @@ function readHost(value) {
28
28
  const requestRender = tui.requestRender;
29
29
  return { chat, ui, requestRender: () => requestRender.call(tui) };
30
30
  }
31
+ function readUsage(value) {
32
+ if (!record(value))
33
+ return undefined;
34
+ const { input, output, cacheRead, cacheWrite } = value;
35
+ if (typeof input !== "number" ||
36
+ typeof output !== "number" ||
37
+ typeof cacheRead !== "number" ||
38
+ typeof cacheWrite !== "number")
39
+ return undefined;
40
+ return { input, output, cacheRead, cacheWrite };
41
+ }
31
42
  function assistantInfo(component) {
32
43
  const message = Reflect.get(component, "lastMessage");
33
44
  if (!record(message) || !Array.isArray(message.content)) {
34
45
  throw new Error("Pi assistant message shape changed");
35
46
  }
47
+ const hidden = Reflect.get(component, "hideThinkingBlock");
36
48
  const calls = new Set();
37
49
  let eligible = !["length", "aborted", "error"].includes(String(message.stopReason));
38
- let thinking = false;
50
+ let thinking = "";
39
51
  for (const block of message.content) {
40
52
  if (!record(block))
41
53
  throw new Error("Pi assistant content shape changed");
42
54
  if (block.type === "toolCall" && typeof block.id === "string")
43
55
  calls.add(block.id);
44
56
  else if (block.type === "thinking" && typeof block.thinking === "string")
45
- thinking ||= block.thinking.trim() !== "";
57
+ thinking += block.thinking;
46
58
  else if (block.type !== "text" || typeof block.text !== "string" || block.text.trim() !== "")
47
59
  eligible = false;
48
60
  }
49
- return { calls, eligible, thinking };
61
+ return {
62
+ calls,
63
+ eligible,
64
+ thinking,
65
+ hidden: hidden === true,
66
+ usage: readUsage(message.usage),
67
+ };
50
68
  }
51
69
  function toolMember(component, calls) {
52
70
  const id = Reflect.get(component, "toolCallId");
53
71
  const name = Reflect.get(component, "toolName");
54
72
  const partial = Reflect.get(component, "isPartial");
73
+ const complete = Reflect.get(component, "argsComplete");
55
74
  const result = Reflect.get(component, "result");
56
75
  const renderer = Reflect.get(component, "resultRendererComponent");
57
76
  const callRenderer = Reflect.get(component, "callRendererComponent");
58
- if (typeof id !== "string" || typeof name !== "string" || typeof partial !== "boolean") {
77
+ if (typeof id !== "string" ||
78
+ typeof name !== "string" ||
79
+ typeof partial !== "boolean" ||
80
+ typeof complete !== "boolean") {
59
81
  throw new Error("Pi tool component shape changed");
60
82
  }
61
83
  if (!calls.has(id) || !(callRenderer instanceof ToolView))
@@ -71,7 +93,24 @@ function toolMember(component, calls) {
71
93
  : callRenderer.summary;
72
94
  if (summary === undefined)
73
95
  return undefined;
74
- return { component, name: toSingleRow(stripTerminalSequences(name)), summary };
96
+ const status = !complete
97
+ ? "streaming"
98
+ : partial
99
+ ? "running"
100
+ : record(result) && result.isError === true
101
+ ? "error"
102
+ : "ok";
103
+ const children = renderer instanceof ToolView ? renderer.children : callRenderer.children;
104
+ const childCount = renderer instanceof ToolView ? renderer.childCount : undefined;
105
+ return {
106
+ kind: "tool",
107
+ component,
108
+ title: toSingleRow(stripTerminalSequences(name)),
109
+ summary,
110
+ status,
111
+ ...(children === undefined || children.length === 0 ? {} : { children }),
112
+ ...(childCount === undefined ? {} : { childCount }),
113
+ };
75
114
  }
76
115
  function messageView(component) {
77
116
  if (component instanceof MessageView)
@@ -84,165 +123,62 @@ function messageView(component) {
84
123
  return undefined;
85
124
  }
86
125
  /** Shared transcript notices stay in the group. The frame is the contract, not each custom type. */
87
- function noticeContinuation(component) {
126
+ function noticeMember(component) {
88
127
  const view = messageView(component);
89
- return view === undefined ? undefined : { component, summary: view.activitySummary };
128
+ if (view === undefined)
129
+ return undefined;
130
+ return {
131
+ kind: "notice",
132
+ component,
133
+ title: view.kind,
134
+ summary: view.activitySummary,
135
+ // A notice with no preview and no body has nothing to open; the group draws a pin only when
136
+ // detail exists, so an unconditional function would give it an arrow onto an empty body.
137
+ ...(view.hasDetail ? { detail: (width) => view.detailRows(width) } : {}),
138
+ };
90
139
  }
91
140
  class NotifyView extends Container {
92
141
  #text = new Text("", 1, 0);
93
- #context;
142
+ #ui;
94
143
  #message;
95
144
  #type;
96
- constructor(message, type, context) {
145
+ constructor(message, type, ui) {
97
146
  super();
98
- this.#context = context;
147
+ this.#ui = ui;
99
148
  this.#message = message;
100
149
  this.#type = type;
101
150
  this.addChild(new Spacer(1));
102
151
  this.addChild(this.#text);
103
152
  }
104
153
  get summary() {
105
- return this.#context.ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(this.#content));
154
+ return this.#ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(this.#content));
106
155
  }
107
- get #content() {
108
- const prefix = this.#type === "error" ? "Error: " : this.#type === "warning" ? "Warning: " : "";
109
- return `${prefix}${this.#message}`;
110
- }
111
- render(width) {
112
- this.#text.setText(this.#context.ui.theme.fg(this.#type === "info" ? "dim" : this.#type, this.#content));
113
- return super.render(width);
114
- }
115
- }
116
- class ToolGroup extends Container {
117
- #header = new Text("", 0, 0);
118
- #originals = new Container();
119
- #context;
120
- #redraw;
121
- #members = [];
122
- #expanded = false;
123
- #renderedExpanded = false;
124
- #open = new Set();
125
- #hits = [];
126
- constructor(context, redraw) {
127
- super();
128
- this.#context = context;
129
- this.#redraw = redraw;
130
- const heading = new MouseRegion(this.#header, (event) => {
131
- if (event.type !== "click" || event.button !== "left")
132
- return undefined;
133
- this.#expanded = this.#context.ui.getToolsExpanded() ? false : !this.#expanded;
134
- if (!this.#expanded)
135
- this.#open.clear();
136
- redraw();
137
- return { handled: true, render: true };
138
- });
139
- const body = {
140
- render: (width) => this.#renderBody(width),
141
- handleMouse: (event) => this.#hit(event),
142
- invalidate() { },
156
+ get member() {
157
+ return {
158
+ kind: "notice",
159
+ component: this,
160
+ title: this.#type,
161
+ summary: this.summary,
162
+ detail: (width) => this.#detail(width),
143
163
  };
144
- this.children = [new Spacer(1), heading, body];
145
164
  }
146
- #renderBody(width) {
165
+ #detail(width) {
147
166
  const inner = Math.max(1, width - 2);
148
- const rail = this.#context.ui.theme.fg("dim", "▎");
149
- const lines = [];
150
- const hits = [];
151
- this.#members.forEach((member, index) => {
152
- const wrapper = this.#originals.children[index];
153
- if (wrapper === undefined)
154
- return;
155
- const native = this.#renderedExpanded || this.#open.has(member.component);
156
- const rows = native
157
- ? renderWithoutToolRails(wrapper, inner)
158
- : member.summary === undefined
159
- ? []
160
- : [member.summary];
161
- if (rows.length === 0)
162
- return;
163
- hits.push({ component: member.component, wrapper, native, height: rows.length });
164
- for (const row of rows) {
165
- lines.push(fitRow(`${rail} ${row}`, Math.max(0, width), "…"));
166
- }
167
+ return this.#content.split("\n").flatMap((line) => {
168
+ const wrapped = wrapTextWithAnsi(line, inner);
169
+ return (wrapped.length > 0 ? wrapped : [""]).map((row) => ` ${row}`);
167
170
  });
168
- this.#hits = hits;
169
- return lines;
170
- }
171
- #hit(event) {
172
- let y = event.y;
173
- for (const hit of this.#hits) {
174
- if (y >= hit.height) {
175
- y -= hit.height;
176
- continue;
177
- }
178
- if (!hit.native) {
179
- if (event.type !== "click" || event.button !== "left")
180
- return undefined;
181
- this.#open.add(hit.component);
182
- this.#redraw();
183
- return { handled: true, render: true };
184
- }
185
- // The rail belongs to the group. Content, including header fold and copy, stays with the member.
186
- if (event.x < 2) {
187
- if (!this.#renderedExpanded && event.type === "click" && event.button === "left") {
188
- this.#open.delete(hit.component);
189
- this.#redraw();
190
- return { handled: true, render: true };
191
- }
192
- return undefined;
193
- }
194
- return hit.wrapper.handleMouse?.({
195
- ...event,
196
- y,
197
- height: hit.height,
198
- x: event.x - 2,
199
- width: Math.max(1, event.width - 2),
200
- });
201
- }
202
- return undefined;
203
171
  }
204
- update(members) {
205
- this.#members = members;
206
- this.#originals.children = members.map(({ component }) => {
207
- let offset = 0;
208
- return {
209
- render(width) {
210
- const rows = component.render(width);
211
- // Pi adds one leading separator per tool; the group supplies its own spacing.
212
- offset = rows[0] === "" ? 1 : 0;
213
- return rows.slice(offset);
214
- },
215
- handleMouse(event) {
216
- return component.handleMouse?.({
217
- ...event,
218
- y: event.y + offset,
219
- height: event.height + offset,
220
- });
221
- },
222
- invalidate() { },
223
- };
224
- });
225
- }
226
- invalidate() {
227
- // Original chatContainer.invalidate invalidates original members exactly once.
228
- this.#header.invalidate();
172
+ get #content() {
173
+ const prefix = this.#type === "error" ? "Error: " : this.#type === "warning" ? "Warning: " : "";
174
+ return `${prefix}${this.#message}`;
229
175
  }
230
176
  render(width) {
231
- this.#renderedExpanded = this.#expanded || this.#context.ui.getToolsExpanded();
232
- const theme = this.#context.ui.theme;
233
- const counts = new Map();
234
- for (const member of this.#members) {
235
- if (member.name !== undefined)
236
- counts.set(member.name, (counts.get(member.name) ?? 0) + 1);
237
- }
238
- const heading = [...counts]
239
- .map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
240
- .join(theme.fg("dim", " · "));
241
- this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${heading}`, Math.max(0, width), "…"));
177
+ this.#text.setText(this.#ui.theme.fg(this.#type === "info" ? "dim" : this.#type, this.#content));
242
178
  return super.render(width);
243
179
  }
244
180
  }
245
- function attach(host, context) {
181
+ function attach(host, run) {
246
182
  const chat = host.chat;
247
183
  const original = {
248
184
  render: chat.render,
@@ -264,13 +200,16 @@ function attach(host, context) {
264
200
  const rebuild = () => {
265
201
  const next = new Map();
266
202
  const children = [];
203
+ const blocks = [];
267
204
  let calls = new Set();
268
205
  let members = [];
269
- const flush = () => {
206
+ /** Closes the pending run. `ended` is false only for the run still open at the transcript tail. */
207
+ const flush = (ended) => {
270
208
  const first = members[0]?.component;
271
- if (first !== undefined && members.filter((member) => member.name !== undefined).length > 1) {
272
- const group = groups.get(first) ?? new ToolGroup(context, host.requestRender);
273
- group.update(members);
209
+ if (first !== undefined && members.filter((member) => member.kind === "tool").length > 1) {
210
+ const group = groups.get(first) ??
211
+ new ToolGroupView(host.ui, host.requestRender, run.current?.endedAt === undefined ? run.current : undefined);
212
+ blocks.push({ group, members, ended });
274
213
  next.set(first, group);
275
214
  children.push(group);
276
215
  }
@@ -283,9 +222,13 @@ function attach(host, context) {
283
222
  const info = assistantInfo(child);
284
223
  calls = info.calls;
285
224
  if (info.eligible) {
225
+ const thinking = info.thinking;
286
226
  members.push({
227
+ kind: "assistant",
287
228
  component: child,
288
- ...(info.thinking ? { summary: context.ui.theme.fg("thinkingText", "thinking…") } : {}),
229
+ ...(thinking === "" ? {} : { thinkingChars: thinking.length }),
230
+ ...(thinking === "" || info.hidden ? {} : { thinking }),
231
+ ...(info.usage === undefined ? {} : { usage: info.usage }),
289
232
  });
290
233
  continue;
291
234
  }
@@ -298,20 +241,24 @@ function attach(host, context) {
298
241
  }
299
242
  }
300
243
  else if (child instanceof NotifyView) {
301
- members.push({ component: child, summary: child.summary });
244
+ members.push(child.member);
302
245
  continue;
303
246
  }
304
247
  else {
305
- const continuation = noticeContinuation(child);
248
+ const continuation = noticeMember(child);
306
249
  if (continuation !== undefined) {
307
250
  members.push(continuation);
308
251
  continue;
309
252
  }
310
253
  }
311
- flush();
254
+ flush(true);
312
255
  children.push(child);
313
256
  }
314
- flush();
257
+ flush(false);
258
+ const frozen = run.current === undefined || run.current.endedAt !== undefined;
259
+ for (const block of blocks) {
260
+ block.group.update(block.members, frozen || block.ended);
261
+ }
315
262
  groups = next;
316
263
  projection.children = children;
317
264
  };
@@ -326,7 +273,7 @@ function attach(host, context) {
326
273
  active = false;
327
274
  groups.clear();
328
275
  projection.clear();
329
- context.ui.notify(`Tool grouping disabled: ${error instanceof Error ? error.message : String(error)}`, "warning");
276
+ host.ui.notify(`Tool grouping disabled: ${error instanceof Error ? error.message : String(error)}`, "warning");
330
277
  return original.render.call(chat, width);
331
278
  }
332
279
  // Real renderer errors propagate; never hide a tool-rendering failure.
@@ -348,7 +295,7 @@ function attach(host, context) {
348
295
  notify(message, type) {
349
296
  if (!active)
350
297
  return false;
351
- chat.addChild(new NotifyView(message, type, context));
298
+ chat.addChild(new NotifyView(message, type, host.ui));
352
299
  host.requestRender();
353
300
  return true;
354
301
  },
@@ -372,7 +319,7 @@ function attach(host, context) {
372
319
  Object.defineProperty(chat, key, descriptor);
373
320
  }
374
321
  if (conflict)
375
- context.ui.notify("Tool grouping patch changed by another extension; restart Pi to fully unload it.", "warning");
322
+ host.ui.notify("Tool grouping patch changed by another extension; restart Pi to fully unload it.", "warning");
376
323
  host.requestRender();
377
324
  },
378
325
  };
@@ -385,7 +332,7 @@ function connect(owner, host) {
385
332
  }
386
333
  catch (error) {
387
334
  owner.failed.add(host.chat);
388
- owner.context.ui.notify(`Tool grouping unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
335
+ owner.ui.notify(`Tool grouping unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
389
336
  }
390
337
  }
391
338
  function patchState() {
@@ -426,7 +373,7 @@ function patchState() {
426
373
  if (record(instance))
427
374
  failed.add(instance);
428
375
  for (const owner of owners.values())
429
- owner.context.ui.notify(`Tool grouping unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
376
+ owner.ui.notify(`Tool grouping unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
430
377
  return undefined;
431
378
  }
432
379
  if (record(instance) && !seen.has(instance)) {
@@ -469,7 +416,8 @@ export function installHostToolGroups(context) {
469
416
  !(major > 1 || (major === 1 && (minor > 0 || (minor === 0 && patch >= 1)))))
470
417
  throw new Error(`Tool grouping requires Pi >=1.0.1; running ${VERSION}`);
471
418
  const state = patchState();
472
- if (state.owners.has(context.ui))
419
+ const ui = context.ui;
420
+ if (state.owners.has(ui))
473
421
  throw new Error("Tool grouping already owns this UI runtime");
474
422
  if (state.owners.size === 0) {
475
423
  for (const seam of SEAMS) {
@@ -483,13 +431,15 @@ export function installHostToolGroups(context) {
483
431
  value: state.wrappers[seam],
484
432
  });
485
433
  }
434
+ const run = {};
486
435
  const owner = {
487
- context,
436
+ ui,
488
437
  hosts: new Map(),
489
438
  failed: new WeakSet(),
490
- attach: (host) => attach(host, context),
439
+ run,
440
+ attach: (host) => attach(host, run),
491
441
  };
492
- state.owners.set(context.ui, owner);
442
+ state.owners.set(ui, owner);
493
443
  // Reload rebuilds history before session_start; reconnect already discovered instances.
494
444
  for (const reference of state.hosts) {
495
445
  const instance = reference.deref();
@@ -499,7 +449,7 @@ export function installHostToolGroups(context) {
499
449
  }
500
450
  try {
501
451
  const host = readHost(instance);
502
- if (host.ui === context.ui)
452
+ if (host.ui === ui)
503
453
  connect(owner, host);
504
454
  }
505
455
  catch {
@@ -512,11 +462,20 @@ export function installHostToolGroups(context) {
512
462
  for (const host of owner.hosts.values())
513
463
  host.requestRender();
514
464
  },
465
+ beginRun() {
466
+ run.current = { startedAt: performance.now() };
467
+ },
468
+ endRun() {
469
+ const current = run.current;
470
+ if (current !== undefined && current.endedAt === undefined)
471
+ current.endedAt = performance.now();
472
+ },
515
473
  dispose() {
516
474
  if (disposed)
517
475
  return;
518
476
  disposed = true;
519
- state.owners.delete(context.ui);
477
+ run.current = undefined;
478
+ state.owners.delete(ui);
520
479
  for (const host of owner.hosts.values())
521
480
  host.dispose();
522
481
  owner.hosts.clear();
@@ -527,7 +486,7 @@ export function installHostToolGroups(context) {
527
486
  state.wrappers[seam])
528
487
  Object.defineProperty(InteractiveMode.prototype, seam, state.originals[seam]);
529
488
  else
530
- context.ui.notify("Tool grouping seam changed by another extension; restart Pi to fully unload it.", "warning");
489
+ ui.notify("Tool grouping seam changed by another extension; restart Pi to fully unload it.", "warning");
531
490
  }
532
491
  },
533
492
  };
package/dist/index.d.ts CHANGED
@@ -2,5 +2,5 @@ export { type DiffHunk, type DiffLine, type DiffSource, type DiffStats, DiffView
2
2
  export { createMessageView, type MessageRows, type MessageTone, MessageView, type MessageViewHeader, type MessageViewSpec, } from "./message-view.js";
3
3
  export { extractChildToolCopyText, NestedCallsView, type NestedCallsViewProps, } from "./nested-calls-view.js";
4
4
  export { type CollapsibleToolRendererOptions, createCollapsibleToolRendererResolver, estimateStreamingTokens, inlinePresentation, isMcpTool, type NestedCallRecord, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, type SpecializedToolRendererName, type ToolRenderContext, } from "./renderer.js";
5
- export { type Collapsible, createToolView, type FoldLevel, formatStatusGlyph, formatToolHeaderText, isCollapsible, type RailColorToken, type RailSection, type ToolStatusKind, ToolView, type ToolViewCopySpec, type ToolViewHeaderSpec, type ToolViewSpec, transitionFoldLevel, } from "./tool-view.js";
5
+ export { type Collapsible, createToolView, type FoldLevel, formatStatusGlyph, formatToolHeaderText, isCollapsible, type RailColorToken, type RailSection, type ToolStatusKind, ToolView, type ToolViewChild, type ToolViewCopySpec, type ToolViewHeaderSpec, type ToolViewSpec, transitionFoldLevel, } from "./tool-view.js";
6
6
  export { type CapturedToolCall, clearCapturedNestedCalls, getCapturedNestedCalls, recordToolExecutionEnd, recordToolExecutionStart, } from "./tracker.js";
@@ -12,6 +12,18 @@ export declare class MessageView implements Component {
12
12
  render(width: number): string[];
13
13
  invalidate(): void;
14
14
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
15
+ /** Provider-declared notice type, used to group notices without reading their text. */
16
+ get kind(): string;
17
+ /**
18
+ * Detail rows inside an activity group: preview and body below the summary row the group already
19
+ * draws, regardless of local fold. Repeating the header there would print the summary twice.
20
+ */
21
+ detailRows(width: number): string[];
15
22
  /** Single-row label used when this notice sits inside an activity group. */
16
23
  get activitySummary(): string;
24
+ /**
25
+ * Whether anything would render below the label. `expandable` asks about the body only; a
26
+ * preview-only notice still has detail to show, so the group keys its pin on this instead.
27
+ */
28
+ get hasDetail(): boolean;
17
29
  }
@@ -106,12 +106,8 @@ export class MessageView {
106
106
  invalidateRows(this.#spec.body);
107
107
  }
108
108
  handleMouse(event) {
109
- if (!this.expandable ||
110
- event.type === "wheel" ||
111
- event.type !== "click" ||
112
- event.button !== "left") {
109
+ if (!this.expandable || event.type !== "click" || event.button !== "left")
113
110
  return undefined;
114
- }
115
111
  const effectiveWidth = event.width > 0 ? event.width : this.#lastWidth;
116
112
  const pad = Math.max(0, this.#spec.outputPad ?? 0);
117
113
  const innerX = event.x - pad;
@@ -125,6 +121,20 @@ export class MessageView {
125
121
  this.toggle();
126
122
  return { handled: true, render: true };
127
123
  }
124
+ /** Provider-declared notice type, used to group notices without reading their text. */
125
+ get kind() {
126
+ return this.#spec.header.kind;
127
+ }
128
+ /**
129
+ * Detail rows inside an activity group: preview and body below the summary row the group already
130
+ * draws, regardless of local fold. Repeating the header there would print the summary twice.
131
+ */
132
+ detailRows(width) {
133
+ const pad = Math.max(0, this.#spec.outputPad ?? 0);
134
+ const safeWidth = Math.max(1, width - pad * 2);
135
+ const leftPad = pad > 0 ? " ".repeat(pad) : "";
136
+ return [...layRows(this.#spec.preview, safeWidth), ...layRows(this.#spec.body, safeWidth)].map((line) => (leftPad === "" ? line : `${leftPad}${line}`));
137
+ }
128
138
  /** Single-row label used when this notice sits inside an activity group. */
129
139
  get activitySummary() {
130
140
  const { theme, header } = this.#spec;
@@ -133,6 +143,13 @@ export class MessageView {
133
143
  const summary = summaryRaw === "" ? "" : theme.fg(header.tone ?? "dim", summaryRaw);
134
144
  return summary === "" ? kind : `${kind}${theme.fg("dim", " · ")}${summary}`;
135
145
  }
146
+ /**
147
+ * Whether anything would render below the label. `expandable` asks about the body only; a
148
+ * preview-only notice still has detail to show, so the group keys its pin on this instead.
149
+ */
150
+ get hasDetail() {
151
+ return hasRows(this.#spec.preview) || hasRows(this.#spec.body);
152
+ }
136
153
  #header(width, expanded) {
137
154
  const pin = this.expandable ? `${this.#spec.theme.fg("dim", expanded ? "▾" : "▸")} ` : "";
138
155
  return truncate(`${pin}${this.activitySummary}`, width);