@hheei/pi-ext-ui 0.5.1 → 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/README.md CHANGED
@@ -8,7 +8,7 @@ The shared view API owns fold levels, compact sections, status glyphs, copy feed
8
8
 
9
9
  Background Bash and subagent tools own their display formats through ext-core `ToolPresentation` providers. This resolver consumes those providers automatically, including nested codemode calls, and adds shared folding and copy controls. Without `pi-ext-ui`, the same tools retain native Pi renderers. No direct dependency on `pi-ext-ui` is required by the tool extensions.
10
10
 
11
- Activity groups are on by default for Pi **>=1.0.1** TUI sessions. Turn them off with the Tool UI setting `toolGroups.enabled` in `ext_settings.json` (the ext-core settings page). The change applies to the current session. A heading such as `▸ codemode ×2 · grep ×3` keeps one live summary row per tool beneath a single outer rail. Each thinking round and notification also keeps a summary row; notifications are not counted. Groups form while tools run and can span consecutive tool/thinking rounds. Prose, user messages, unknown components, images, and unknown tool renderers break groups. Streaming arguments, partial results, completion, and errors update the existing row.
11
+ Activity groups are on by default for Pi **>=1.0.1** TUI sessions. Turn them off with the Tool UI setting `toolGroups.enabled` in `ext_settings.json` (the ext-core settings page). The change applies to the current session. A heading such as `▸ codemode ×2 · grep ×3` keeps one live summary row per tool beneath a single outer rail. Each thinking round and notification also keeps a summary row; notifications are not counted. Groups form while tools run and can span consecutive tool/thinking rounds. Prose, user messages, unknown components, images, and unknown tool renderers break groups. Any MessageView transcript notice stays inside the group, so bg-bash, subagent, memory, optimizer, and codemode-guard notices do not need their own allowlist. Notification rows show their text directly. Streaming arguments, partial results, completion, and errors update the existing row.
12
12
 
13
13
  Fullscreen: click the group heading to restore the original component layouts and interactions beneath the outer rail. Both terminal modes: Ctrl+O forces groups open; closing global expansion restores each group’s local choice. Appending members preserves that choice. Original per-tool fold choices remain intact. Groups are presentation only and are rebuilt on history navigation; session data, model context, and HTML export remain unchanged. Thinking summaries keep a label; expanding respects native thinking visibility. Each `ctx.ui.notify` call retains its full text for expansion. Notifications remain session-local, as in Pi.
14
14
 
package/dist/extension.js CHANGED
@@ -4,13 +4,6 @@ import { createMessageView } from "./message-view.js";
4
4
  import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
5
5
  import { activityGroupsEnabled, createToolGroupSettings } from "./settings.js";
6
6
  import { clearCapturedNestedCalls } from "./tracker.js";
7
- function sessionId(extension) {
8
- const manager = extension.sessionManager;
9
- if (typeof manager !== "object" || manager === null || !("getSessionId" in manager))
10
- return "";
11
- const read = manager.getSessionId;
12
- return typeof read === "function" ? String(read.call(manager)) : "";
13
- }
14
7
  export default function (pi) {
15
8
  let redrawGroups;
16
9
  pi.on("agent_settled", () => redrawGroups?.());
@@ -39,6 +32,18 @@ export default function (pi) {
39
32
  }
40
33
  };
41
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
+ });
42
47
  context.resources.add("tool-group-settings", registerSettings(provider, getRuntimeSettingsRegistry(pi)));
43
48
  context.resources.add("tool-groups", () => {
44
49
  redrawGroups = undefined;
@@ -54,7 +59,7 @@ export default function (pi) {
54
59
  context.resources.add("message-view", () => handle.dispose());
55
60
  try {
56
61
  const state = await provider.storage.load({
57
- sessionId: sessionId(context.extension),
62
+ sessionId: context.extension.sessionManager.getSessionId(),
58
63
  signal: context.signal,
59
64
  });
60
65
  if (!context.signal.aborted)
@@ -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,6 +1,8 @@
1
1
  import { AssistantMessageComponent, InteractiveMode, ToolExecutionComponent, VERSION, } from "@earendil-works/pi-coding-agent";
2
- import { Container, MouseRegion, Spacer, stripTerminalSequences, Text, truncateToWidth, } from "@earendil-works/pi-tui";
3
- import { renderWithoutToolRails, ToolView, toSingleRow } from "./tool-view.js";
2
+ import { Container, Spacer, stripTerminalSequences, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
3
+ import { MessageView } from "./message-view.js";
4
+ import { ToolGroupView } from "./tool-group-view.js";
5
+ import { ToolView, toSingleRow } from "./tool-view.js";
4
6
  const SEAMS = ["getRegisteredToolDefinition", "showExtensionNotify"];
5
7
  function record(value) {
6
8
  return typeof value === "object" && value !== null;
@@ -26,34 +28,56 @@ function readHost(value) {
26
28
  const requestRender = tui.requestRender;
27
29
  return { chat, ui, requestRender: () => requestRender.call(tui) };
28
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
+ }
29
42
  function assistantInfo(component) {
30
43
  const message = Reflect.get(component, "lastMessage");
31
44
  if (!record(message) || !Array.isArray(message.content)) {
32
45
  throw new Error("Pi assistant message shape changed");
33
46
  }
47
+ const hidden = Reflect.get(component, "hideThinkingBlock");
34
48
  const calls = new Set();
35
49
  let eligible = !["length", "aborted", "error"].includes(String(message.stopReason));
36
- let thinking = false;
50
+ let thinking = "";
37
51
  for (const block of message.content) {
38
52
  if (!record(block))
39
53
  throw new Error("Pi assistant content shape changed");
40
54
  if (block.type === "toolCall" && typeof block.id === "string")
41
55
  calls.add(block.id);
42
56
  else if (block.type === "thinking" && typeof block.thinking === "string")
43
- thinking ||= block.thinking.trim() !== "";
57
+ thinking += block.thinking;
44
58
  else if (block.type !== "text" || typeof block.text !== "string" || block.text.trim() !== "")
45
59
  eligible = false;
46
60
  }
47
- return { calls, eligible, thinking };
61
+ return {
62
+ calls,
63
+ eligible,
64
+ thinking,
65
+ hidden: hidden === true,
66
+ usage: readUsage(message.usage),
67
+ };
48
68
  }
49
69
  function toolMember(component, calls) {
50
70
  const id = Reflect.get(component, "toolCallId");
51
71
  const name = Reflect.get(component, "toolName");
52
72
  const partial = Reflect.get(component, "isPartial");
73
+ const complete = Reflect.get(component, "argsComplete");
53
74
  const result = Reflect.get(component, "result");
54
75
  const renderer = Reflect.get(component, "resultRendererComponent");
55
76
  const callRenderer = Reflect.get(component, "callRendererComponent");
56
- 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") {
57
81
  throw new Error("Pi tool component shape changed");
58
82
  }
59
83
  if (!calls.has(id) || !(callRenderer instanceof ToolView))
@@ -69,115 +93,92 @@ function toolMember(component, calls) {
69
93
  : callRenderer.summary;
70
94
  if (summary === undefined)
71
95
  return undefined;
72
- 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
+ };
114
+ }
115
+ function messageView(component) {
116
+ if (component instanceof MessageView)
117
+ return component;
118
+ if (!(component instanceof Container))
119
+ return undefined;
120
+ for (const child of component.children)
121
+ if (child instanceof MessageView)
122
+ return child;
123
+ return undefined;
124
+ }
125
+ /** Shared transcript notices stay in the group. The frame is the contract, not each custom type. */
126
+ function noticeMember(component) {
127
+ const view = messageView(component);
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
+ };
73
139
  }
74
140
  class NotifyView extends Container {
75
141
  #text = new Text("", 1, 0);
76
- #context;
142
+ #ui;
77
143
  #message;
78
144
  #type;
79
- constructor(message, type, context) {
145
+ constructor(message, type, ui) {
80
146
  super();
81
- this.#context = context;
147
+ this.#ui = ui;
82
148
  this.#message = message;
83
149
  this.#type = type;
84
150
  this.addChild(new Spacer(1));
85
151
  this.addChild(this.#text);
86
152
  }
87
153
  get summary() {
88
- return this.#context.ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(`notify · ${this.#content}`));
154
+ return this.#ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(this.#content));
89
155
  }
90
- get #content() {
91
- const prefix = this.#type === "error" ? "Error: " : this.#type === "warning" ? "Warning: " : "";
92
- return `${prefix}${this.#message}`;
93
- }
94
- render(width) {
95
- this.#text.setText(this.#context.ui.theme.fg(this.#type === "info" ? "dim" : this.#type, this.#content));
96
- return super.render(width);
97
- }
98
- }
99
- class ToolGroup extends Container {
100
- #header = new Text("", 0, 0);
101
- #originals = new Container();
102
- #context;
103
- #members = [];
104
- #expanded = false;
105
- #renderedExpanded = false;
106
- constructor(context, redraw) {
107
- super();
108
- this.#context = context;
109
- const heading = new MouseRegion(this.#header, (event) => {
110
- if (event.type !== "click" || event.button !== "left")
111
- return undefined;
112
- this.#expanded = this.#context.ui.getToolsExpanded() ? false : !this.#expanded;
113
- redraw();
114
- return { handled: true, render: true };
115
- });
116
- const body = {
117
- render: (width) => {
118
- const rows = this.#renderedExpanded
119
- ? renderWithoutToolRails(this.#originals, Math.max(1, width - 2))
120
- : this.#members.flatMap((member) => member.summary === undefined ? [] : [member.summary]);
121
- const rail = this.#context.ui.theme.fg("dim", "▎");
122
- return rows.map((row) => truncateToWidth(`${rail} ${row}`, Math.max(0, width), "…"));
123
- },
124
- handleMouse: (event) => {
125
- if (!this.#renderedExpanded || event.x < 2)
126
- return undefined;
127
- return this.#originals.handleMouse({
128
- ...event,
129
- x: event.x - 2,
130
- width: Math.max(1, event.width - 2),
131
- });
132
- },
133
- 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),
134
163
  };
135
- this.children = [new Spacer(1), heading, body];
136
164
  }
137
- update(members) {
138
- this.#members = members;
139
- this.#originals.children = members.map(({ component, name }) => {
140
- if (name === undefined)
141
- return component;
142
- let offset = 0;
143
- return {
144
- render(width) {
145
- const rows = component.render(width);
146
- // Pi adds one leading separator per tool; the group supplies its own spacing.
147
- offset = rows[0] === "" ? 1 : 0;
148
- return rows.slice(offset);
149
- },
150
- handleMouse(event) {
151
- return component.handleMouse?.({
152
- ...event,
153
- y: event.y + offset,
154
- height: event.height + offset,
155
- });
156
- },
157
- invalidate() { },
158
- };
165
+ #detail(width) {
166
+ const inner = Math.max(1, width - 2);
167
+ return this.#content.split("\n").flatMap((line) => {
168
+ const wrapped = wrapTextWithAnsi(line, inner);
169
+ return (wrapped.length > 0 ? wrapped : [""]).map((row) => ` ${row}`);
159
170
  });
160
171
  }
161
- invalidate() {
162
- // Original chatContainer.invalidate invalidates original members exactly once.
163
- this.#header.invalidate();
172
+ get #content() {
173
+ const prefix = this.#type === "error" ? "Error: " : this.#type === "warning" ? "Warning: " : "";
174
+ return `${prefix}${this.#message}`;
164
175
  }
165
176
  render(width) {
166
- this.#renderedExpanded = this.#expanded || this.#context.ui.getToolsExpanded();
167
- const theme = this.#context.ui.theme;
168
- const counts = new Map();
169
- for (const member of this.#members) {
170
- if (member.name !== undefined)
171
- counts.set(member.name, (counts.get(member.name) ?? 0) + 1);
172
- }
173
- const heading = [...counts]
174
- .map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
175
- .join(theme.fg("dim", " · "));
176
- this.#header.setText(truncateToWidth(`${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));
177
178
  return super.render(width);
178
179
  }
179
180
  }
180
- function attach(host, context) {
181
+ function attach(host, run) {
181
182
  const chat = host.chat;
182
183
  const original = {
183
184
  render: chat.render,
@@ -199,13 +200,16 @@ function attach(host, context) {
199
200
  const rebuild = () => {
200
201
  const next = new Map();
201
202
  const children = [];
203
+ const blocks = [];
202
204
  let calls = new Set();
203
205
  let members = [];
204
- const flush = () => {
206
+ /** Closes the pending run. `ended` is false only for the run still open at the transcript tail. */
207
+ const flush = (ended) => {
205
208
  const first = members[0]?.component;
206
- if (first !== undefined && members.filter((member) => member.name !== undefined).length > 1) {
207
- const group = groups.get(first) ?? new ToolGroup(context, host.requestRender);
208
- 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 });
209
213
  next.set(first, group);
210
214
  children.push(group);
211
215
  }
@@ -218,9 +222,13 @@ function attach(host, context) {
218
222
  const info = assistantInfo(child);
219
223
  calls = info.calls;
220
224
  if (info.eligible) {
225
+ const thinking = info.thinking;
221
226
  members.push({
227
+ kind: "assistant",
222
228
  component: child,
223
- ...(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 }),
224
232
  });
225
233
  continue;
226
234
  }
@@ -233,13 +241,24 @@ function attach(host, context) {
233
241
  }
234
242
  }
235
243
  else if (child instanceof NotifyView) {
236
- members.push({ component: child, summary: child.summary });
244
+ members.push(child.member);
237
245
  continue;
238
246
  }
239
- flush();
247
+ else {
248
+ const continuation = noticeMember(child);
249
+ if (continuation !== undefined) {
250
+ members.push(continuation);
251
+ continue;
252
+ }
253
+ }
254
+ flush(true);
240
255
  children.push(child);
241
256
  }
242
- 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
+ }
243
262
  groups = next;
244
263
  projection.children = children;
245
264
  };
@@ -254,7 +273,7 @@ function attach(host, context) {
254
273
  active = false;
255
274
  groups.clear();
256
275
  projection.clear();
257
- 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");
258
277
  return original.render.call(chat, width);
259
278
  }
260
279
  // Real renderer errors propagate; never hide a tool-rendering failure.
@@ -276,7 +295,7 @@ function attach(host, context) {
276
295
  notify(message, type) {
277
296
  if (!active)
278
297
  return false;
279
- chat.addChild(new NotifyView(message, type, context));
298
+ chat.addChild(new NotifyView(message, type, host.ui));
280
299
  host.requestRender();
281
300
  return true;
282
301
  },
@@ -300,7 +319,7 @@ function attach(host, context) {
300
319
  Object.defineProperty(chat, key, descriptor);
301
320
  }
302
321
  if (conflict)
303
- 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");
304
323
  host.requestRender();
305
324
  },
306
325
  };
@@ -313,7 +332,7 @@ function connect(owner, host) {
313
332
  }
314
333
  catch (error) {
315
334
  owner.failed.add(host.chat);
316
- 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");
317
336
  }
318
337
  }
319
338
  function patchState() {
@@ -354,7 +373,7 @@ function patchState() {
354
373
  if (record(instance))
355
374
  failed.add(instance);
356
375
  for (const owner of owners.values())
357
- 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");
358
377
  return undefined;
359
378
  }
360
379
  if (record(instance) && !seen.has(instance)) {
@@ -397,7 +416,8 @@ export function installHostToolGroups(context) {
397
416
  !(major > 1 || (major === 1 && (minor > 0 || (minor === 0 && patch >= 1)))))
398
417
  throw new Error(`Tool grouping requires Pi >=1.0.1; running ${VERSION}`);
399
418
  const state = patchState();
400
- if (state.owners.has(context.ui))
419
+ const ui = context.ui;
420
+ if (state.owners.has(ui))
401
421
  throw new Error("Tool grouping already owns this UI runtime");
402
422
  if (state.owners.size === 0) {
403
423
  for (const seam of SEAMS) {
@@ -411,13 +431,15 @@ export function installHostToolGroups(context) {
411
431
  value: state.wrappers[seam],
412
432
  });
413
433
  }
434
+ const run = {};
414
435
  const owner = {
415
- context,
436
+ ui,
416
437
  hosts: new Map(),
417
438
  failed: new WeakSet(),
418
- attach: (host) => attach(host, context),
439
+ run,
440
+ attach: (host) => attach(host, run),
419
441
  };
420
- state.owners.set(context.ui, owner);
442
+ state.owners.set(ui, owner);
421
443
  // Reload rebuilds history before session_start; reconnect already discovered instances.
422
444
  for (const reference of state.hosts) {
423
445
  const instance = reference.deref();
@@ -427,7 +449,7 @@ export function installHostToolGroups(context) {
427
449
  }
428
450
  try {
429
451
  const host = readHost(instance);
430
- if (host.ui === context.ui)
452
+ if (host.ui === ui)
431
453
  connect(owner, host);
432
454
  }
433
455
  catch {
@@ -440,11 +462,20 @@ export function installHostToolGroups(context) {
440
462
  for (const host of owner.hosts.values())
441
463
  host.requestRender();
442
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
+ },
443
473
  dispose() {
444
474
  if (disposed)
445
475
  return;
446
476
  disposed = true;
447
- state.owners.delete(context.ui);
477
+ run.current = undefined;
478
+ state.owners.delete(ui);
448
479
  for (const host of owner.hosts.values())
449
480
  host.dispose();
450
481
  owner.hosts.clear();
@@ -455,7 +486,7 @@ export function installHostToolGroups(context) {
455
486
  state.wrappers[seam])
456
487
  Object.defineProperty(InteractiveMode.prototype, seam, state.originals[seam]);
457
488
  else
458
- 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");
459
490
  }
460
491
  },
461
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,4 +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[];
22
+ /** Single-row label used when this notice sits inside an activity group. */
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;
15
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,13 +121,37 @@ export class MessageView {
125
121
  this.toggle();
126
122
  return { handled: true, render: true };
127
123
  }
128
- #header(width, expanded) {
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
+ }
138
+ /** Single-row label used when this notice sits inside an activity group. */
139
+ get activitySummary() {
129
140
  const { theme, header } = this.#spec;
130
141
  const kind = theme.fg("dim", toSingleRow(header.kind));
131
142
  const summaryRaw = header.summary === undefined ? "" : toSingleRow(header.summary);
132
143
  const summary = summaryRaw === "" ? "" : theme.fg(header.tone ?? "dim", summaryRaw);
133
- const label = summary === "" ? kind : `${kind}${theme.fg("dim", " · ")}${summary}`;
134
- const pin = this.expandable ? `${theme.fg("dim", expanded ? "▾" : "▸")} ` : "";
135
- return truncate(`${pin}${label}`, width);
144
+ return summary === "" ? kind : `${kind}${theme.fg("dim", " · ")}${summary}`;
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
+ }
153
+ #header(width, expanded) {
154
+ const pin = this.expandable ? `${this.#spec.theme.fg("dim", expanded ? "▾" : "▸")} ` : "";
155
+ return truncate(`${pin}${this.activitySummary}`, width);
136
156
  }
137
157
  }