@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.
package/README.md CHANGED
@@ -8,8 +8,8 @@ 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. 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.
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. A thinking item shows a content preview while Pi hides thinking and opens the full text on demand; while thinking is visible the category prints it flat. 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 own text once, indented and width-wrapped; only a MessageView with a real body gets an expand pin. Streaming arguments, partial results, completion, and errors update the existing row.
12
12
 
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.
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 keeps its label; the hidden state shows a preview instead of dropping the text, and the visible state has no per-item arrow. Each `ctx.ui.notify` keeps its full text inline. Notifications remain session-local, as in Pi.
14
14
 
15
15
  Grouping uses two centralized, version-checked host prototype hooks: tool lookup and extension notifications. Thinking uses the existing assistant message, with no extra hook. Unsupported hosts or incompatible layouts report a warning and retain normal rendering. Cleanup restores the original methods; if another extension replaces the same methods, restart Pi to unload the conflicting patch. For workspace development use `pnpm run pi-dev`: running the bundled CLI beside physical host devDependencies can resolve a second host class, unlike the published installation.
@@ -0,0 +1,11 @@
1
+ import type { Theme, ThemeBg } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * Paint one surface's rows. A surface owns its own background and nothing else: after any reset
4
+ * (`0`, `49`, or a compound sequence containing one) it re-asserts *its* background, so a row keeps
5
+ * the fill across the styled spans a theme or child renderer ends with `\x1b[0m`.
6
+ *
7
+ * It never guesses another surface's background. A nested surface that ended its own fill is gone;
8
+ * the rest of the row belongs to the surface painting it. Nested surfaces stay separate because each
9
+ * one paints the rows it draws, not because this function tracks ANSI state.
10
+ */
11
+ export declare function backgroundFill(theme: Theme, token: ThemeBg): (text: string) => string;
@@ -0,0 +1,31 @@
1
+ const MARK = "\u0000";
2
+ /** Any SGR sequence: parameters are `;`-separated, an empty parameter list means reset. */
3
+ const SGR = /\x1b\[([0-9;]*)m/g;
4
+ /**
5
+ * Paint one surface's rows. A surface owns its own background and nothing else: after any reset
6
+ * (`0`, `49`, or a compound sequence containing one) it re-asserts *its* background, so a row keeps
7
+ * the fill across the styled spans a theme or child renderer ends with `\x1b[0m`.
8
+ *
9
+ * It never guesses another surface's background. A nested surface that ended its own fill is gone;
10
+ * the rest of the row belongs to the surface painting it. Nested surfaces stay separate because each
11
+ * one paints the rows it draws, not because this function tracks ANSI state.
12
+ */
13
+ export function backgroundFill(theme, token) {
14
+ const [start, end] = theme.bg(token, MARK).split(MARK);
15
+ if (start === undefined || end === undefined)
16
+ return (text) => text;
17
+ return (text) => {
18
+ let out = start;
19
+ let from = 0;
20
+ for (const match of text.matchAll(SGR)) {
21
+ const params = match[1] ?? "";
22
+ const resets = params === "" || params.split(";").some((code) => code === "0" || code === "49");
23
+ const next = (match.index ?? 0) + match[0].length;
24
+ out += text.slice(from, next);
25
+ if (resets)
26
+ out += start;
27
+ from = next;
28
+ }
29
+ return `${out}${text.slice(from)}${end}`;
30
+ };
31
+ }
package/dist/extension.js CHANGED
@@ -2,12 +2,15 @@ import { getRuntimeSettingsRegistry, messageViewPoint, registerExtensionHook, re
2
2
  import { installHostToolGroups } from "./host-transcript-patch.js";
3
3
  import { createMessageView } from "./message-view.js";
4
4
  import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
5
- import { activityGroupsEnabled, createToolGroupSettings } from "./settings.js";
6
- import { clearCapturedNestedCalls } from "./tracker.js";
5
+ import { ToolUiRuntime } from "./runtime.js";
6
+ import { activityGroupsEnabled, createToolGroupSettings, toolBackgroundsEnabled, } from "./settings.js";
7
+ import { TOOL_SPINNER_INTERVAL_MS } from "./tool-view.js";
7
8
  export default function (pi) {
9
+ // One runtime per extension instance: two Pi runtimes in one process never share these facts.
10
+ const runtime = new ToolUiRuntime();
8
11
  let redrawGroups;
9
12
  pi.on("agent_settled", () => redrawGroups?.());
10
- registerCollapsibleToolRenderer(pi);
13
+ registerCollapsibleToolRenderer(pi, runtime);
11
14
  registerExtensionLifecycle(pi, {
12
15
  key: "@hheei/pi-ext-ui",
13
16
  async start(context) {
@@ -31,18 +34,59 @@ export default function (pi) {
31
34
  context.extension.ui.notify(error instanceof Error ? error.message : String(error), "warning");
32
35
  }
33
36
  };
34
- const provider = createToolGroupSettings({ onEnabled: apply });
37
+ const applyBackgrounds = (enabled) => {
38
+ runtime.backgrounds = enabled;
39
+ redrawGroups?.();
40
+ };
41
+ const provider = createToolGroupSettings({
42
+ onEnabled: apply,
43
+ onBackgrounds: applyBackgrounds,
44
+ });
35
45
  const onAgentStart = pi.on("agent_start", () => {
36
46
  patch?.beginRun();
37
47
  redrawGroups?.();
38
48
  });
49
+ // A live tool animates its status glyph. The ticker exists only while one is running, and the
50
+ // frame itself comes from one shared clock, so a phase never restarts on a status change.
51
+ let liveTools = 0;
52
+ let ticker;
53
+ const stopTicker = () => {
54
+ if (ticker === undefined)
55
+ return;
56
+ clearInterval(ticker);
57
+ ticker = undefined;
58
+ };
39
59
  const onAgentEnd = pi.on("agent_end", () => {
40
60
  patch?.endRun();
61
+ // No tool outlives the agent loop, so the animation cannot keep a ticker alive either.
62
+ liveTools = 0;
63
+ stopTicker();
41
64
  redrawGroups?.();
42
65
  });
66
+ const onToolStart = pi.on("tool_execution_start", () => {
67
+ liveTools += 1;
68
+ if (ticker !== undefined)
69
+ return;
70
+ ticker = setInterval(() => {
71
+ if (liveTools === 0 || redrawGroups === undefined) {
72
+ stopTicker();
73
+ return;
74
+ }
75
+ redrawGroups();
76
+ }, TOOL_SPINNER_INTERVAL_MS);
77
+ });
78
+ const onToolEnd = pi.on("tool_execution_end", () => {
79
+ liveTools = Math.max(0, liveTools - 1);
80
+ if (liveTools === 0)
81
+ stopTicker();
82
+ });
43
83
  context.resources.add("run-clock", () => {
44
84
  onAgentStart();
45
85
  onAgentEnd();
86
+ onToolStart();
87
+ onToolEnd();
88
+ stopTicker();
89
+ liveTools = 0;
46
90
  });
47
91
  context.resources.add("tool-group-settings", registerSettings(provider, getRuntimeSettingsRegistry(pi)));
48
92
  context.resources.add("tool-groups", () => {
@@ -51,9 +95,14 @@ export default function (pi) {
51
95
  patch = undefined;
52
96
  });
53
97
  context.resources.add("tracker-cleanup", () => {
54
- clearCapturedNestedCalls();
98
+ runtime.tracker.clear();
99
+ });
100
+ // Resume reads the host's own nested-call record for the current branch, never another one.
101
+ runtime.nestedCalls.setSource(context.extension.sessionManager);
102
+ context.resources.add("nested-call-source", () => {
103
+ runtime.nestedCalls.setSource(undefined);
55
104
  });
56
- const toolRenderers = registerExtensionHook(pi, toolRendererPoint, createOptionalToolRendererFactory(pi));
105
+ const toolRenderers = registerExtensionHook(pi, toolRendererPoint, createOptionalToolRendererFactory(pi, runtime));
57
106
  context.resources.add("tool-renderer", () => toolRenderers.dispose());
58
107
  const handle = registerExtensionHook(context.pi, messageViewPoint, createMessageView);
59
108
  context.resources.add("message-view", () => handle.dispose());
@@ -62,14 +111,17 @@ export default function (pi) {
62
111
  sessionId: context.extension.sessionManager.getSessionId(),
63
112
  signal: context.signal,
64
113
  });
65
- if (!context.signal.aborted)
114
+ if (!context.signal.aborted) {
66
115
  apply(activityGroupsEnabled(state));
116
+ applyBackgrounds(toolBackgroundsEnabled(state));
117
+ }
67
118
  }
68
119
  catch (error) {
69
120
  if (!context.signal.aborted) {
70
121
  const detail = error instanceof Error ? error.message : String(error);
71
122
  context.extension.ui.notify(`Tool activity groups settings unreadable: ${detail}`, "warning");
72
123
  apply(true);
124
+ applyBackgrounds(false);
73
125
  }
74
126
  }
75
127
  },
@@ -1,7 +1,7 @@
1
1
  import { AssistantMessageComponent, InteractiveMode, ToolExecutionComponent, VERSION, } from "@earendil-works/pi-coding-agent";
2
- import { Container, Spacer, stripTerminalSequences, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
+ import { Container, stripTerminalSequences, Text } from "@earendil-works/pi-tui";
3
3
  import { MessageView } from "./message-view.js";
4
- import { ToolGroupView } from "./tool-group-view.js";
4
+ import { memberParts, TailUnit, ToolGroupView, } from "./tool-group-view.js";
5
5
  import { ToolView, toSingleRow } from "./tool-view.js";
6
6
  const SEAMS = ["getRegisteredToolDefinition", "showExtensionNotify"];
7
7
  function record(value) {
@@ -47,21 +47,25 @@ function assistantInfo(component) {
47
47
  const hidden = Reflect.get(component, "hideThinkingBlock");
48
48
  const calls = new Set();
49
49
  let eligible = !["length", "aborted", "error"].includes(String(message.stopReason));
50
- let thinking = "";
50
+ const thinking = [];
51
51
  for (const block of message.content) {
52
52
  if (!record(block))
53
53
  throw new Error("Pi assistant content shape changed");
54
54
  if (block.type === "toolCall" && typeof block.id === "string")
55
55
  calls.add(block.id);
56
- else if (block.type === "thinking" && typeof block.thinking === "string")
57
- thinking += block.thinking;
56
+ else if (block.type === "thinking" && typeof block.thinking === "string") {
57
+ // Pi joins a thinking run with blank lines; the same separator keeps paragraphs readable here.
58
+ const text = block.thinking.trim();
59
+ if (text !== "")
60
+ thinking.push(text);
61
+ }
58
62
  else if (block.type !== "text" || typeof block.text !== "string" || block.text.trim() !== "")
59
63
  eligible = false;
60
64
  }
61
65
  return {
62
66
  calls,
63
67
  eligible,
64
- thinking,
68
+ thinking: thinking.join("\n\n"),
65
69
  hidden: hidden === true,
66
70
  usage: readUsage(message.usage),
67
71
  };
@@ -88,10 +92,9 @@ function toolMember(component, calls) {
88
92
  !result.content.every((block) => record(block) && block.type === "text") ||
89
93
  !(renderer instanceof ToolView)))
90
94
  return undefined;
91
- const summary = renderer instanceof ToolView
92
- ? (renderer.summary ?? callRenderer.summary)
93
- : callRenderer.summary;
94
- if (summary === undefined)
95
+ // The visible view decides the facts: a hidden call renderer reports no summary of its own.
96
+ const view = renderer instanceof ToolView ? renderer : callRenderer;
97
+ if (view.summary === undefined)
95
98
  return undefined;
96
99
  const status = !complete
97
100
  ? "streaming"
@@ -100,16 +103,17 @@ function toolMember(component, calls) {
100
103
  : record(result) && result.isError === true
101
104
  ? "error"
102
105
  : "ok";
103
- const children = renderer instanceof ToolView ? renderer.children : callRenderer.children;
104
- const childCount = renderer instanceof ToolView ? renderer.childCount : undefined;
106
+ const children = view.children;
105
107
  return {
106
108
  kind: "tool",
107
109
  component,
108
110
  title: toSingleRow(stripTerminalSequences(name)),
109
- summary,
110
111
  status,
112
+ // The host keeps isPartial true until it stores a final result; argsComplete alone is not
113
+ // completion, and restored history never sets it. The result is the observed end fact.
114
+ completed: !partial && result !== undefined,
111
115
  ...(children === undefined || children.length === 0 ? {} : { children }),
112
- ...(childCount === undefined ? {} : { childCount }),
116
+ ...(view.childCount === undefined ? {} : { childCount: view.childCount }),
113
117
  };
114
118
  }
115
119
  function messageView(component) {
@@ -132,50 +136,50 @@ function noticeMember(component) {
132
136
  component,
133
137
  title: view.kind,
134
138
  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) } : {}),
139
+ // Preview is visible content; only a real body opens. A preview-only notice therefore never
140
+ // grows a second expansion level, and the notice lays out both blocks itself.
141
+ content: (width) => view.previewRows(width),
142
+ ...(view.expandable ? { detail: (width) => view.bodyRows(width) } : {}),
138
143
  };
139
144
  }
140
- class NotifyView extends Container {
145
+ /**
146
+ * A captured `ui.notify`. It owns its content: one indented, width-wrapped block with the notice's
147
+ * semantic color. The group draws these rows once, so a plain notice has no expansion of its own.
148
+ */
149
+ class NotifyView {
141
150
  #text = new Text("", 1, 0);
142
151
  #ui;
143
152
  #message;
144
153
  #type;
145
154
  constructor(message, type, ui) {
146
- super();
147
155
  this.#ui = ui;
148
156
  this.#message = message;
149
157
  this.#type = type;
150
- this.addChild(new Spacer(1));
151
- this.addChild(this.#text);
152
- }
153
- get summary() {
154
- return this.#ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(this.#content));
155
158
  }
156
159
  get member() {
157
160
  return {
158
161
  kind: "notice",
159
162
  component: this,
160
163
  title: this.#type,
161
- summary: this.summary,
162
- detail: (width) => this.#detail(width),
164
+ content: (width) => this.rows(width),
163
165
  };
164
166
  }
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}`);
170
- });
167
+ /** The notice body as it appears both in the transcript and inside the activity group. */
168
+ rows(width) {
169
+ // info is the terminal's own readable text; a warning or error keeps its status token and
170
+ // its written prefix, so the level never depends on color alone.
171
+ this.#text.setText(this.#ui.theme.fg(this.#type === "info" ? "text" : this.#type, this.#content));
172
+ return this.#text.render(width);
171
173
  }
172
174
  get #content() {
173
175
  const prefix = this.#type === "error" ? "Error: " : this.#type === "warning" ? "Warning: " : "";
174
176
  return `${prefix}${this.#message}`;
175
177
  }
176
178
  render(width) {
177
- this.#text.setText(this.#ui.theme.fg(this.#type === "info" ? "dim" : this.#type, this.#content));
178
- return super.render(width);
179
+ return ["", ...this.rows(width)];
180
+ }
181
+ invalidate() {
182
+ this.#text.invalidate();
179
183
  }
180
184
  }
181
185
  function attach(host, run) {
@@ -197,39 +201,134 @@ function attach(host, run) {
197
201
  const projection = new Container();
198
202
  let groups = new Map();
199
203
  let active = true;
204
+ const disposeGroups = () => {
205
+ for (const group of groups.values())
206
+ group.dispose();
207
+ groups.clear();
208
+ };
209
+ /**
210
+ * Record first observation time for new components during a live loop. Restored history stays
211
+ * unknown until live content joins its block.
212
+ */
213
+ const claim = (child) => {
214
+ if (run.observed.has(child))
215
+ return;
216
+ const current = run.current;
217
+ run.observed.set(child, current !== undefined && current.run.endedAt === undefined
218
+ ? { at: performance.now(), run: current.run }
219
+ : undefined);
220
+ };
221
+ const observe = () => {
222
+ for (const child of chat.children)
223
+ claim(child);
224
+ };
225
+ /** Create or recover one clock for each live continuous block. */
226
+ const clockOf = (members, endedAt) => {
227
+ const parts = members.flatMap(memberParts);
228
+ let clock;
229
+ for (const part of parts) {
230
+ clock = run.clocks.get(part);
231
+ if (clock !== undefined)
232
+ break;
233
+ }
234
+ if (clock === undefined) {
235
+ const origin = parts
236
+ .map((part) => run.observed.get(part))
237
+ .find((value) => value !== undefined);
238
+ if (origin === undefined)
239
+ return undefined;
240
+ clock = { startedAt: origin.at };
241
+ if (origin.run.endedAt !== undefined)
242
+ clock.endedAt = origin.run.endedAt;
243
+ else
244
+ run.activeClocks.add(clock);
245
+ }
246
+ if (endedAt !== undefined && clock.endedAt === undefined) {
247
+ clock.endedAt = endedAt;
248
+ run.activeClocks.delete(clock);
249
+ }
250
+ for (const part of parts)
251
+ if (run.clocks.get(part) === undefined)
252
+ run.clocks.set(part, clock);
253
+ return clock;
254
+ };
200
255
  const rebuild = () => {
201
256
  const next = new Map();
202
257
  const children = [];
203
258
  const blocks = [];
204
259
  let calls = new Set();
205
260
  let members = [];
206
- /** Closes the pending run. `ended` is false only for the run still open at the transcript tail. */
207
- const flush = (ended) => {
261
+ /** Every request this pending block owns, in transcript order, whether or not it is displayed. */
262
+ let requests = [];
263
+ /**
264
+ * Closes the pending block. `ended` is false only for the block still open at the transcript tail:
265
+ * that one keeps its last visible unit outside the group, and its facts still count here.
266
+ */
267
+ const flush = (ended, boundary) => {
208
268
  const first = members[0]?.component;
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 });
269
+ let weight = 0;
270
+ for (const member of members) {
271
+ if (member.kind === "tool")
272
+ weight += 1;
273
+ else if (member.kind === "notice" ||
274
+ (member.thinking !== undefined && member.thinking !== ""))
275
+ weight += 0.5;
276
+ }
277
+ if (first !== undefined && weight >= 2) {
278
+ const group = groups.get(first) ?? new ToolGroupView(host.ui, host.requestRender);
279
+ const tail = ended ? undefined : members.at(-1);
280
+ const inside = tail === undefined ? members : members.slice(0, -1);
281
+ const boundaryTime = ended && boundary !== undefined
282
+ ? (run.observed.get(boundary)?.at ?? performance.now())
283
+ : undefined;
284
+ blocks.push({
285
+ group,
286
+ members: inside,
287
+ tail,
288
+ requests,
289
+ clock: clockOf(members, boundaryTime),
290
+ });
213
291
  next.set(first, group);
214
292
  children.push(group);
293
+ if (tail !== undefined)
294
+ children.push(new TailUnit(tail));
215
295
  }
216
296
  else
217
- children.push(...members.map((member) => member.component));
297
+ children.push(...members.flatMap(memberParts));
218
298
  members = [];
299
+ requests = [];
219
300
  };
220
301
  for (const child of chat.children) {
302
+ claim(child);
221
303
  if (child instanceof AssistantMessageComponent) {
222
304
  const info = assistantInfo(child);
223
305
  calls = info.calls;
306
+ // The request belongs to the block whose work it answers, even when its text is native
307
+ // content the aggregate never shows.
308
+ requests.push(info.usage === undefined ? {} : { usage: info.usage });
224
309
  if (info.eligible) {
225
310
  const thinking = info.thinking;
226
- members.push({
227
- kind: "assistant",
228
- component: child,
229
- ...(thinking === "" ? {} : { thinkingChars: thinking.length }),
230
- ...(thinking === "" || info.hidden ? {} : { thinking }),
231
- ...(info.usage === undefined ? {} : { usage: info.usage }),
232
- });
311
+ const previous = members.at(-1);
312
+ if (thinking !== "" && previous?.kind === "thinking") {
313
+ // One continuous run: consecutive thinking blocks are one item, paragraphs kept.
314
+ members[members.length - 1] = {
315
+ ...previous,
316
+ thinking: `${previous.thinking ?? ""}\n\n${thinking}`,
317
+ parts: [...memberParts(previous), child],
318
+ ...(previous.thinkingHidden === true || info.hidden ? { thinkingHidden: true } : {}),
319
+ };
320
+ }
321
+ else {
322
+ members.push({
323
+ kind: "thinking",
324
+ component: child,
325
+ // The host's hide setting is a display preference, not a content filter: the text
326
+ // stays available to the group's preview and expansion, and is never written back.
327
+ ...(thinking === ""
328
+ ? {}
329
+ : { thinking, ...(info.hidden ? { thinkingHidden: true } : {}) }),
330
+ });
331
+ }
233
332
  continue;
234
333
  }
235
334
  }
@@ -251,13 +350,17 @@ function attach(host, run) {
251
350
  continue;
252
351
  }
253
352
  }
254
- flush(true);
353
+ flush(true, child);
255
354
  children.push(child);
256
355
  }
257
356
  flush(false);
258
- const frozen = run.current === undefined || run.current.endedAt !== undefined;
259
357
  for (const block of blocks) {
260
- block.group.update(block.members, frozen || block.ended);
358
+ block.group.update(block.members, block.tail, block.requests, block.clock);
359
+ }
360
+ // A block the new projection does not reuse is gone: its hold timer must go with it.
361
+ for (const [key, group] of groups) {
362
+ if (next.get(key) !== group)
363
+ group.dispose();
261
364
  }
262
365
  groups = next;
263
366
  projection.children = children;
@@ -271,7 +374,7 @@ function attach(host, run) {
271
374
  }
272
375
  catch (error) {
273
376
  active = false;
274
- groups.clear();
377
+ disposeGroups();
275
378
  projection.clear();
276
379
  host.ui.notify(`Tool grouping disabled: ${error instanceof Error ? error.message : String(error)}`, "warning");
277
380
  return original.render.call(chat, width);
@@ -299,12 +402,13 @@ function attach(host, run) {
299
402
  host.requestRender();
300
403
  return true;
301
404
  },
405
+ observe,
302
406
  dispose() {
303
407
  if (disposed)
304
408
  return;
305
409
  disposed = true;
306
410
  active = false;
307
- groups.clear();
411
+ disposeGroups();
308
412
  projection.clear();
309
413
  let conflict = false;
310
414
  for (const key of ["render", "handleMouse", "invalidate"]) {
@@ -431,12 +535,15 @@ export function installHostToolGroups(context) {
431
535
  value: state.wrappers[seam],
432
536
  });
433
537
  }
434
- const run = {};
538
+ const run = {
539
+ observed: new WeakMap(),
540
+ clocks: new WeakMap(),
541
+ activeClocks: new Set(),
542
+ };
435
543
  const owner = {
436
544
  ui,
437
545
  hosts: new Map(),
438
546
  failed: new WeakSet(),
439
- run,
440
547
  attach: (host) => attach(host, run),
441
548
  };
442
549
  state.owners.set(ui, owner);
@@ -463,18 +570,29 @@ export function installHostToolGroups(context) {
463
570
  host.requestRender();
464
571
  },
465
572
  beginRun() {
466
- run.current = { startedAt: performance.now() };
573
+ // History may not have rendered yet. Claim it before opening the new live run.
574
+ for (const host of owner.hosts.values())
575
+ host.observe();
576
+ run.current = { run: {} };
467
577
  },
468
578
  endRun() {
579
+ // Mark unseen transcript children before ending the run so later renders treat them as history.
580
+ for (const host of owner.hosts.values())
581
+ host.observe();
582
+ const endedAt = performance.now();
469
583
  const current = run.current;
470
- if (current !== undefined && current.endedAt === undefined)
471
- current.endedAt = performance.now();
584
+ if (current !== undefined && current.run.endedAt === undefined)
585
+ current.run.endedAt = endedAt;
586
+ for (const clock of run.activeClocks)
587
+ clock.endedAt = endedAt;
588
+ run.activeClocks.clear();
472
589
  },
473
590
  dispose() {
474
591
  if (disposed)
475
592
  return;
476
593
  disposed = true;
477
594
  run.current = undefined;
595
+ run.activeClocks.clear();
478
596
  state.owners.delete(ui);
479
597
  for (const host of owner.hosts.values())
480
598
  host.dispose();
package/dist/index.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { type DiffHunk, type DiffLine, type DiffSource, type DiffStats, DiffView, type DiffViewOptions, diffStats, } from "./diff-view.js";
2
2
  export { createMessageView, type MessageRows, type MessageTone, MessageView, type MessageViewHeader, type MessageViewSpec, } from "./message-view.js";
3
- export { extractChildToolCopyText, NestedCallsView, type NestedCallsViewProps, } from "./nested-calls-view.js";
4
- export { type CollapsibleToolRendererOptions, createCollapsibleToolRendererResolver, estimateStreamingTokens, inlinePresentation, isMcpTool, type NestedCallRecord, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, type SpecializedToolRendererName, type ToolRenderContext, } from "./renderer.js";
3
+ export { extractChildToolCopyText, inlinePresentation } from "./native-tools.js";
4
+ export { NestedCallsView, type NestedCallsViewProps, } from "./nested-calls-view.js";
5
+ export { type CollapsibleToolRendererOptions, createCollapsibleToolRendererResolver, estimateStreamingTokens, isMcpTool, type NestedCallRecord, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, type SpecializedToolRendererName, type ToolRenderContext, } from "./renderer.js";
6
+ export { ToolUiRuntime } from "./runtime.js";
5
7
  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
- export { type CapturedToolCall, clearCapturedNestedCalls, getCapturedNestedCalls, recordToolExecutionEnd, recordToolExecutionStart, } from "./tracker.js";
8
+ export { type CapturedToolCall, type NestedCallArgsFact, NestedCallArgsIndex, ToolExecutionTracker, } from "./tracker.js";
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { DiffView, diffStats, } from "./diff-view.js";
2
2
  export { createMessageView, MessageView, } from "./message-view.js";
3
- export { extractChildToolCopyText, NestedCallsView, } from "./nested-calls-view.js";
4
- export { createCollapsibleToolRendererResolver, estimateStreamingTokens, inlinePresentation, isMcpTool, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, } from "./renderer.js";
3
+ export { extractChildToolCopyText, inlinePresentation } from "./native-tools.js";
4
+ export { NestedCallsView, } from "./nested-calls-view.js";
5
+ export { createCollapsibleToolRendererResolver, estimateStreamingTokens, isMcpTool, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, } from "./renderer.js";
6
+ export { ToolUiRuntime } from "./runtime.js";
5
7
  export { createToolView, formatStatusGlyph, formatToolHeaderText, isCollapsible, ToolView, transitionFoldLevel, } from "./tool-view.js";
6
- export { clearCapturedNestedCalls, getCapturedNestedCalls, recordToolExecutionEnd, recordToolExecutionStart, } from "./tracker.js";
8
+ export { NestedCallArgsIndex, ToolExecutionTracker, } from "./tracker.js";
@@ -14,16 +14,10 @@ export declare class MessageView implements Component {
14
14
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
15
15
  /** Provider-declared notice type, used to group notices without reading their text. */
16
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[];
17
+ /** Preview rows inside an activity group: visible content below the summary, folded or not. */
18
+ previewRows(width: number): string[];
19
+ /** Body rows inside an activity group: what opening this notice reveals. */
20
+ bodyRows(width: number): string[];
22
21
  /** Single-row label used when this notice sits inside an activity group. */
23
22
  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;
29
23
  }