@hheei/pi-ext-ui 0.4.1 → 0.5.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
@@ -7,3 +7,9 @@ Pi UI primitives and a small set of specialized tool renderers.
7
7
  The shared view API owns fold levels, compact sections, status glyphs, copy feedback, and width-safe rendering. It does not read files, execute tools, infer outcomes, or import a concrete extension.
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
+
11
+ Opt into activity groups with `pi --ui-tool-groups` after installing this extension (Pi **>=1.0.1**). 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.
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.
14
+
15
+ This optional feature 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 --ui-tool-groups`: running the bundled CLI beside physical host devDependencies can resolve a second host class, unlike the published installation.
package/dist/extension.js CHANGED
@@ -1,12 +1,33 @@
1
1
  import { messageViewPoint, registerExtensionHook, registerExtensionLifecycle, toolRendererPoint, } from "@hheei/pi-ext-core";
2
+ import { installHostToolGroups } from "./host-transcript-patch.js";
2
3
  import { createMessageView } from "./message-view.js";
3
4
  import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
4
5
  import { clearCapturedNestedCalls } from "./tracker.js";
5
6
  export default function (pi) {
7
+ pi.registerFlag("ui-tool-groups", {
8
+ type: "boolean",
9
+ default: false,
10
+ description: "Group tool summaries, thinking and notifications (Pi >=1.0.1 runtime patch)",
11
+ });
12
+ let redrawGroups;
13
+ pi.on("agent_settled", () => redrawGroups?.());
6
14
  registerCollapsibleToolRenderer(pi);
7
15
  registerExtensionLifecycle(pi, {
8
16
  key: "@hheei/pi-ext-ui",
9
17
  start(context) {
18
+ if (context.extension.mode === "tui" && pi.getFlag("ui-tool-groups") === true) {
19
+ try {
20
+ const patch = installHostToolGroups(context.extension);
21
+ redrawGroups = patch.requestRender;
22
+ context.resources.add("tool-groups", () => {
23
+ redrawGroups = undefined;
24
+ patch.dispose();
25
+ });
26
+ }
27
+ catch (error) {
28
+ context.extension.ui.notify(error instanceof Error ? error.message : String(error), "warning");
29
+ }
30
+ }
10
31
  context.resources.add("tracker-cleanup", () => {
11
32
  clearCapturedNestedCalls();
12
33
  });
@@ -0,0 +1,6 @@
1
+ import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ /** Private to pi-ext-ui: two process hooks, scoped by the actual host UI facade. */
3
+ export declare function installHostToolGroups(context: ExtensionContext): {
4
+ dispose(): void;
5
+ requestRender(): void;
6
+ };
@@ -0,0 +1,462 @@
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";
4
+ const SEAMS = ["getRegisteredToolDefinition", "showExtensionNotify"];
5
+ function record(value) {
6
+ return typeof value === "object" && value !== null;
7
+ }
8
+ // Audited Pi >=1.0.1 compiles these TS-private members to ordinary JS properties.
9
+ // All host-private access stays here; VERSION comes from the running host virtual module.
10
+ function readHost(value) {
11
+ if (!record(value))
12
+ throw new Error("Invalid InteractiveMode instance");
13
+ const chat = value.chatContainer;
14
+ const session = value.session;
15
+ const tui = value.ui;
16
+ if (!record(session) || !record(tui) || typeof tui.requestRender !== "function") {
17
+ throw new Error("Pi host session/TUI shape changed");
18
+ }
19
+ const runner = session.extensionRunner;
20
+ if (!(chat instanceof Container) ||
21
+ !record(runner) ||
22
+ typeof runner.getUIContext !== "function") {
23
+ throw new Error("Pi host transcript/runner shape changed");
24
+ }
25
+ const ui = runner.getUIContext();
26
+ const requestRender = tui.requestRender;
27
+ return { chat, ui, requestRender: () => requestRender.call(tui) };
28
+ }
29
+ function assistantInfo(component) {
30
+ const message = Reflect.get(component, "lastMessage");
31
+ if (!record(message) || !Array.isArray(message.content)) {
32
+ throw new Error("Pi assistant message shape changed");
33
+ }
34
+ const calls = new Set();
35
+ let eligible = !["length", "aborted", "error"].includes(String(message.stopReason));
36
+ let thinking = false;
37
+ for (const block of message.content) {
38
+ if (!record(block))
39
+ throw new Error("Pi assistant content shape changed");
40
+ if (block.type === "toolCall" && typeof block.id === "string")
41
+ calls.add(block.id);
42
+ else if (block.type === "thinking" && typeof block.thinking === "string")
43
+ thinking ||= block.thinking.trim() !== "";
44
+ else if (block.type !== "text" || typeof block.text !== "string" || block.text.trim() !== "")
45
+ eligible = false;
46
+ }
47
+ return { calls, eligible, thinking };
48
+ }
49
+ function toolMember(component, calls) {
50
+ const id = Reflect.get(component, "toolCallId");
51
+ const name = Reflect.get(component, "toolName");
52
+ const partial = Reflect.get(component, "isPartial");
53
+ const result = Reflect.get(component, "result");
54
+ const renderer = Reflect.get(component, "resultRendererComponent");
55
+ const callRenderer = Reflect.get(component, "callRendererComponent");
56
+ if (typeof id !== "string" || typeof name !== "string" || typeof partial !== "boolean") {
57
+ throw new Error("Pi tool component shape changed");
58
+ }
59
+ if (!calls.has(id) || !(callRenderer instanceof ToolView))
60
+ return undefined;
61
+ if (result !== undefined &&
62
+ (!record(result) ||
63
+ !Array.isArray(result.content) ||
64
+ !result.content.every((block) => record(block) && block.type === "text") ||
65
+ !(renderer instanceof ToolView)))
66
+ return undefined;
67
+ const summary = renderer instanceof ToolView
68
+ ? (renderer.summary ?? callRenderer.summary)
69
+ : callRenderer.summary;
70
+ if (summary === undefined)
71
+ return undefined;
72
+ return { component, name: toSingleRow(stripTerminalSequences(name)), summary };
73
+ }
74
+ class NotifyView extends Container {
75
+ #text = new Text("", 1, 0);
76
+ #context;
77
+ #message;
78
+ #type;
79
+ constructor(message, type, context) {
80
+ super();
81
+ this.#context = context;
82
+ this.#message = message;
83
+ this.#type = type;
84
+ this.addChild(new Spacer(1));
85
+ this.addChild(this.#text);
86
+ }
87
+ get summary() {
88
+ return this.#context.ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(`notify · ${this.#content}`));
89
+ }
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() { },
134
+ };
135
+ this.children = [new Spacer(1), heading, body];
136
+ }
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
+ };
159
+ });
160
+ }
161
+ invalidate() {
162
+ // Original chatContainer.invalidate invalidates original members exactly once.
163
+ this.#header.invalidate();
164
+ }
165
+ 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
+ return super.render(width);
178
+ }
179
+ }
180
+ function attach(host, context) {
181
+ const chat = host.chat;
182
+ const original = {
183
+ render: chat.render,
184
+ handleMouse: chat.handleMouse,
185
+ invalidate: chat.invalidate,
186
+ };
187
+ const descriptors = Object.getOwnPropertyDescriptors(chat);
188
+ for (const key of ["render", "handleMouse", "invalidate"]) {
189
+ const descriptor = descriptors[key];
190
+ if (descriptor !== undefined && descriptor.writable !== true) {
191
+ throw new Error(`Pi transcript ${key} cannot be replaced`);
192
+ }
193
+ }
194
+ if (!Object.isExtensible(chat))
195
+ throw new Error("Pi transcript cannot accept instance methods");
196
+ const projection = new Container();
197
+ let groups = new Map();
198
+ let active = true;
199
+ const rebuild = () => {
200
+ const next = new Map();
201
+ const children = [];
202
+ let calls = new Set();
203
+ let members = [];
204
+ const flush = () => {
205
+ 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
+ next.set(first, group);
210
+ children.push(group);
211
+ }
212
+ else
213
+ children.push(...members.map((member) => member.component));
214
+ members = [];
215
+ };
216
+ for (const child of chat.children) {
217
+ if (child instanceof AssistantMessageComponent) {
218
+ const info = assistantInfo(child);
219
+ calls = info.calls;
220
+ if (info.eligible) {
221
+ members.push({
222
+ component: child,
223
+ ...(info.thinking ? { summary: context.ui.theme.fg("thinkingText", "thinking…") } : {}),
224
+ });
225
+ continue;
226
+ }
227
+ }
228
+ else if (child instanceof ToolExecutionComponent) {
229
+ const member = toolMember(child, calls);
230
+ if (member !== undefined) {
231
+ members.push(member);
232
+ continue;
233
+ }
234
+ }
235
+ else if (child instanceof NotifyView) {
236
+ members.push({ component: child, summary: child.summary });
237
+ continue;
238
+ }
239
+ flush();
240
+ children.push(child);
241
+ }
242
+ flush();
243
+ groups = next;
244
+ projection.children = children;
245
+ };
246
+ const wrappers = {
247
+ render(width) {
248
+ if (!active)
249
+ return original.render.call(chat, width);
250
+ try {
251
+ rebuild();
252
+ }
253
+ catch (error) {
254
+ active = false;
255
+ groups.clear();
256
+ projection.clear();
257
+ context.ui.notify(`Tool grouping disabled: ${error instanceof Error ? error.message : String(error)}`, "warning");
258
+ return original.render.call(chat, width);
259
+ }
260
+ // Real renderer errors propagate; never hide a tool-rendering failure.
261
+ return projection.render(width);
262
+ },
263
+ handleMouse(event) {
264
+ return active ? projection.handleMouse(event) : original.handleMouse.call(chat, event);
265
+ },
266
+ invalidate() {
267
+ original.invalidate.call(chat);
268
+ for (const group of groups.values())
269
+ group.invalidate();
270
+ },
271
+ };
272
+ Object.assign(chat, wrappers);
273
+ host.requestRender();
274
+ let disposed = false;
275
+ return {
276
+ notify(message, type) {
277
+ if (!active)
278
+ return false;
279
+ chat.addChild(new NotifyView(message, type, context));
280
+ host.requestRender();
281
+ return true;
282
+ },
283
+ dispose() {
284
+ if (disposed)
285
+ return;
286
+ disposed = true;
287
+ active = false;
288
+ groups.clear();
289
+ projection.clear();
290
+ let conflict = false;
291
+ for (const key of ["render", "handleMouse", "invalidate"]) {
292
+ if (chat[key] !== wrappers[key]) {
293
+ conflict = true;
294
+ continue;
295
+ }
296
+ const descriptor = descriptors[key];
297
+ if (descriptor === undefined)
298
+ Reflect.deleteProperty(chat, key);
299
+ else
300
+ Object.defineProperty(chat, key, descriptor);
301
+ }
302
+ if (conflict)
303
+ context.ui.notify("Tool grouping patch changed by another extension; restart Pi to fully unload it.", "warning");
304
+ host.requestRender();
305
+ },
306
+ };
307
+ }
308
+ function connect(owner, host) {
309
+ if (owner.hosts.has(host.chat) || owner.failed.has(host.chat))
310
+ return;
311
+ try {
312
+ owner.hosts.set(host.chat, { ...owner.attach(host), requestRender: host.requestRender });
313
+ }
314
+ catch (error) {
315
+ owner.failed.add(host.chat);
316
+ owner.context.ui.notify(`Tool grouping unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
317
+ }
318
+ }
319
+ function patchState() {
320
+ const prototype = InteractiveMode.prototype;
321
+ const key = Symbol.for("@hheei/pi-ext-ui/host-tool-groups/2");
322
+ const existing = Reflect.get(prototype, key);
323
+ if (existing !== undefined) {
324
+ if (!record(existing) ||
325
+ !(existing.owners instanceof Map) ||
326
+ !(existing.hosts instanceof Set) ||
327
+ !record(existing.originals) ||
328
+ !record(existing.wrappers) ||
329
+ SEAMS.some((seam) => !record(existing.originals[seam]) || typeof existing.wrappers[seam] !== "function"))
330
+ throw new Error("Invalid tool grouping patch owner");
331
+ return existing;
332
+ }
333
+ const lookup = Object.getOwnPropertyDescriptor(prototype, SEAMS[0]);
334
+ const notify = Object.getOwnPropertyDescriptor(prototype, SEAMS[1]);
335
+ if (typeof lookup?.value !== "function" ||
336
+ !lookup.writable ||
337
+ lookup.value.length !== 1 ||
338
+ typeof notify?.value !== "function" ||
339
+ !notify.writable ||
340
+ notify.value.length !== 2)
341
+ throw new Error("Pi tool/notification seam changed or is already patched");
342
+ const owners = new Map();
343
+ const hosts = new Set();
344
+ const seen = new WeakSet();
345
+ const failed = new WeakSet();
346
+ const locate = (instance) => {
347
+ if (owners.size === 0 || (record(instance) && failed.has(instance)))
348
+ return undefined;
349
+ let host;
350
+ try {
351
+ host = readHost(instance);
352
+ }
353
+ catch (error) {
354
+ if (record(instance))
355
+ failed.add(instance);
356
+ for (const owner of owners.values())
357
+ owner.context.ui.notify(`Tool grouping unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
358
+ return undefined;
359
+ }
360
+ if (record(instance) && !seen.has(instance)) {
361
+ seen.add(instance);
362
+ hosts.add(new WeakRef(instance));
363
+ }
364
+ const owner = owners.get(host.ui);
365
+ if (owner === undefined)
366
+ return undefined;
367
+ connect(owner, host);
368
+ return { owner, host };
369
+ };
370
+ const state = {
371
+ owners,
372
+ hosts,
373
+ originals: { getRegisteredToolDefinition: lookup, showExtensionNotify: notify },
374
+ wrappers: {
375
+ getRegisteredToolDefinition(name) {
376
+ const result = Reflect.apply(lookup.value, this, [name]);
377
+ locate(this);
378
+ return result;
379
+ },
380
+ showExtensionNotify(message, type) {
381
+ const match = locate(this);
382
+ if (match?.owner.hosts.get(match.host.chat)?.notify(message, type ?? "info"))
383
+ return;
384
+ return Reflect.apply(notify.value, this, [message, type]);
385
+ },
386
+ },
387
+ };
388
+ Object.defineProperty(prototype, key, { value: state });
389
+ return state;
390
+ }
391
+ /** Private to pi-ext-ui: two process hooks, scoped by the actual host UI facade. */
392
+ export function installHostToolGroups(context) {
393
+ const parts = VERSION.split(".").map(Number);
394
+ const [major = 0, minor = 0, patch = 0] = parts;
395
+ if (parts.length !== 3 ||
396
+ !parts.every(Number.isInteger) ||
397
+ !(major > 1 || (major === 1 && (minor > 0 || (minor === 0 && patch >= 1)))))
398
+ throw new Error(`Tool grouping requires Pi >=1.0.1; running ${VERSION}`);
399
+ const state = patchState();
400
+ if (state.owners.has(context.ui))
401
+ throw new Error("Tool grouping already owns this UI runtime");
402
+ if (state.owners.size === 0) {
403
+ for (const seam of SEAMS) {
404
+ if (Object.getOwnPropertyDescriptor(InteractiveMode.prototype, seam)?.value !==
405
+ state.originals[seam].value)
406
+ throw new Error("Pi tool/notification seam was changed by another extension");
407
+ }
408
+ for (const seam of SEAMS)
409
+ Object.defineProperty(InteractiveMode.prototype, seam, {
410
+ ...state.originals[seam],
411
+ value: state.wrappers[seam],
412
+ });
413
+ }
414
+ const owner = {
415
+ context,
416
+ hosts: new Map(),
417
+ failed: new WeakSet(),
418
+ attach: (host) => attach(host, context),
419
+ };
420
+ state.owners.set(context.ui, owner);
421
+ // Reload rebuilds history before session_start; reconnect already discovered instances.
422
+ for (const reference of state.hosts) {
423
+ const instance = reference.deref();
424
+ if (instance === undefined) {
425
+ state.hosts.delete(reference);
426
+ continue;
427
+ }
428
+ try {
429
+ const host = readHost(instance);
430
+ if (host.ui === context.ui)
431
+ connect(owner, host);
432
+ }
433
+ catch {
434
+ state.hosts.delete(reference);
435
+ }
436
+ }
437
+ let disposed = false;
438
+ return {
439
+ requestRender() {
440
+ for (const host of owner.hosts.values())
441
+ host.requestRender();
442
+ },
443
+ dispose() {
444
+ if (disposed)
445
+ return;
446
+ disposed = true;
447
+ state.owners.delete(context.ui);
448
+ for (const host of owner.hosts.values())
449
+ host.dispose();
450
+ owner.hosts.clear();
451
+ if (state.owners.size !== 0)
452
+ return;
453
+ for (const seam of SEAMS) {
454
+ if (Object.getOwnPropertyDescriptor(InteractiveMode.prototype, seam)?.value ===
455
+ state.wrappers[seam])
456
+ Object.defineProperty(InteractiveMode.prototype, seam, state.originals[seam]);
457
+ else
458
+ context.ui.notify("Tool grouping seam changed by another extension; restart Pi to fully unload it.", "warning");
459
+ }
460
+ },
461
+ };
462
+ }
@@ -167,6 +167,7 @@ export class NestedCallsView {
167
167
  : context.toolCallId;
168
168
  return {
169
169
  ...context,
170
+ lastComponent: undefined,
170
171
  args: "args" in call ? call.args : undefined,
171
172
  toolCallId,
172
173
  durationMs: call.durationMs,
@@ -1,4 +1,4 @@
1
- import { type AgentToolResult } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
2
  import { type DiffSource } from "./diff-view.js";
3
3
  import type { ToolStatusKind } from "./tool-view.js";
4
4
  export declare const EDIT_TOOL_NAME = "edit";
@@ -1,10 +1,18 @@
1
- import { formatReadLineRange } from "@earendil-works/pi-coding-agent";
2
1
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
3
2
  import { diffStats } from "./diff-view.js";
4
3
  export const EDIT_TOOL_NAME = "edit";
5
4
  export const WRITE_TOOL_NAME = "write";
6
5
  export const CODEMODE_OPTIONS_PREFIX = "// @options:";
7
6
  export const CODEMODE_SCRIPT_HEADER = /^Script (?:completed|failed)\r?\nWall time ([\d.]+) seconds\r?\nOutput:\r?\n/;
7
+ // Pi <1.3 has no exported read-range formatter. Keep this UI summary independent of that export.
8
+ function readLineNumber(value) {
9
+ const parsed = typeof value === "number"
10
+ ? value
11
+ : typeof value === "string" && value.trim() !== ""
12
+ ? Number(value)
13
+ : Number.NaN;
14
+ return Number.isFinite(parsed) ? parsed : undefined;
15
+ }
8
16
  export function isRecord(value) {
9
17
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
18
  }
@@ -280,7 +288,16 @@ export function fileToolPresentation(toolName, args, _details, isError) {
280
288
  if (path === "")
281
289
  return undefined;
282
290
  const parsed = parseCallArgs(args);
283
- const range = formatReadLineRange(parsed?.offset, parsed?.limit);
291
+ const offset = parsed?.offset;
292
+ const limit = parsed?.limit;
293
+ const start = offset == null ? 1 : readLineNumber(offset);
294
+ const count = limit == null ? undefined : readLineNumber(limit);
295
+ const end = start !== undefined && count !== undefined ? start + count - 1 : undefined;
296
+ const range = (offset == null && limit == null) ||
297
+ start === undefined ||
298
+ (limit != null && count === undefined)
299
+ ? ""
300
+ : `:${start}${end ? `-${end}` : ""}`;
284
301
  return { summary: `${path}${range}`, status: isError ? "error" : "ok" };
285
302
  }
286
303
  if (toolName === WRITE_TOOL_NAME) {
@@ -1,5 +1,9 @@
1
1
  import { type ExtensionAPI, type ToolRendererResolver, type ToolRenderers } from "@earendil-works/pi-coding-agent";
2
- export type ToolRenderContext = Parameters<NonNullable<ToolRenderers["renderCall"]>>[2];
2
+ /** Optional host metadata is absent on early supported Pi versions. */
3
+ export type ToolRenderContext = Parameters<NonNullable<ToolRenderers["renderCall"]>>[2] & {
4
+ readonly durationMs?: number | undefined;
5
+ readonly outputPad?: number | undefined;
6
+ };
3
7
  /**
4
8
  * Upstream tools whose call and result views pi-ext-ui still frames itself. Concrete tool packages
5
9
  * own their own presentation through ext-core's optional renderer point, so their names never
package/dist/renderer.js CHANGED
@@ -53,7 +53,8 @@ function resolveToolCallSummary(params) {
53
53
  // Tier 1: Host Native RenderCall Delegation
54
54
  if (originalRenderCall !== undefined) {
55
55
  try {
56
- const upstreamLines = originalRenderCall(args, theme, context).render(120);
56
+ const upstreamContext = { ...context, lastComponent: undefined };
57
+ const upstreamLines = originalRenderCall(args, theme, upstreamContext).render(120);
57
58
  const firstNonEmpty = upstreamLines
58
59
  .map((line) => stripTerminalSequences(line).trim())
59
60
  .find((line) => line !== "");
@@ -106,12 +107,10 @@ function rememberCallView(context, view) {
106
107
  }
107
108
  /**
108
109
  * A result view takes over from the call view. A container tool yields on any result, because its
109
- * own row would duplicate the nested rows and output it now carries; every other tool keeps its
110
- * call row until the final result. A provider states that for its own tool, so no name is involved.
110
+ * own row would duplicate the nested rows and output it now carries. A mounted result view takes
111
+ * over from its call view, including during partial updates. A provider states container semantics.
111
112
  */
112
- function hideCallView(context, replaceCallOnPartial, isPartial) {
113
- if (!replaceCallOnPartial && isPartial)
114
- return;
113
+ function hideCallView(context) {
115
114
  const view = isRecord(context.state) ? context.state[CALL_VIEW_STATE_KEY] : undefined;
116
115
  if (view instanceof ToolView)
117
116
  view.hide();
@@ -143,9 +142,6 @@ export function isMcpTool(toolName) {
143
142
  toolName === "list_mcp_resource_templates");
144
143
  }
145
144
  function shouldInterceptTool(toolName, options) {
146
- if (options?.catchAll === true) {
147
- return options.match !== undefined ? options.match(toolName) : true;
148
- }
149
145
  if (options?.match?.(toolName)) {
150
146
  return true;
151
147
  }
@@ -153,7 +149,10 @@ function shouldInterceptTool(toolName, options) {
153
149
  if (toolList !== undefined) {
154
150
  return toolList.includes(toolName);
155
151
  }
156
- return SPECIALIZED_TOOL_SET.has(toolName) || isMcpTool(toolName);
152
+ if (options?.catchAll === false) {
153
+ return SPECIALIZED_TOOL_SET.has(toolName) || isMcpTool(toolName);
154
+ }
155
+ return true;
157
156
  }
158
157
  function renderGenericResult(params) {
159
158
  const { toolName, theme, context, result, renderOptions, defaultLevel, originalRenderCall, originalRenderResult, resolvePresentation, resolveNativeRenderer, options, } = params;
@@ -305,9 +304,21 @@ function renderGenericResult(params) {
305
304
  ...renderOptions,
306
305
  expanded: effectiveExpanded,
307
306
  };
307
+ let lastUpstreamComponent;
308
308
  const lazyUpstreamComponent = {
309
309
  render(w) {
310
- return originalRenderResult(result, effectiveOptions, theme, context).render(w);
310
+ try {
311
+ const upstreamContext = {
312
+ ...context,
313
+ lastComponent: lastUpstreamComponent,
314
+ };
315
+ const comp = originalRenderResult(result, effectiveOptions, theme, upstreamContext);
316
+ lastUpstreamComponent = comp;
317
+ return comp.render(w);
318
+ }
319
+ catch {
320
+ return rawOutput !== "" ? rawOutput.split("\n") : [];
321
+ }
311
322
  },
312
323
  invalidate() { },
313
324
  };
@@ -639,9 +650,7 @@ function createRendererResolver(options, resolvePresentation, resolveNativeRende
639
650
  : provider(resultContext.args, result, theme, resultContext);
640
651
  // Without an owner, a container frame is the only name-based decision left, and it
641
652
  // belongs to upstream codemode.
642
- const replacesCallOnPartial = presentation?.replaceCallOnPartial === true ||
643
- (presentation === undefined && SPECIALIZED_TOOL_SET.has(toolName));
644
- hideCallView(context, replacesCallOnPartial, renderOptions.isPartial);
653
+ hideCallView(context);
645
654
  if (presentation !== undefined) {
646
655
  return renderOwnedPresentation({
647
656
  toolName,
@@ -45,6 +45,8 @@ export interface ToolViewSpec {
45
45
  export declare function createToolView(spec: ToolViewSpec): ToolView;
46
46
  export declare const MAX_LEVEL2_SECTION_LINES = 500;
47
47
  export declare const MAX_LEVEL2_BYTES: number;
48
+ /** Internal transcript composition: original components keep their layout and interactions. */
49
+ export declare function renderWithoutToolRails(component: Component, width: number): string[];
48
50
  export declare function transitionFoldLevel(current: FoldLevel, ctrlKey: boolean): FoldLevel;
49
51
  export declare function formatStatusGlyph(status: ToolStatusKind | undefined, theme: Theme): string;
50
52
  /**
@@ -83,6 +85,8 @@ export declare class ToolView implements Component {
83
85
  get foldLevel(): FoldLevel;
84
86
  get effectiveLevel(): FoldLevel;
85
87
  get collapsed(): boolean;
88
+ /** Current semantic header, without fold/copy controls or body; hidden slots have no summary. */
89
+ get summary(): string | undefined;
86
90
  setFoldLevel(next: FoldLevel): void;
87
91
  setCollapsed(collapsed: boolean): void;
88
92
  toggle(ctrlKey?: boolean): void;
package/dist/tool-view.js CHANGED
@@ -18,6 +18,17 @@ const DEFAULT_MAX_SECTION_LINES = 9;
18
18
  export const MAX_LEVEL2_SECTION_LINES = 500;
19
19
  export const MAX_LEVEL2_BYTES = 64 * 1024;
20
20
  const DEFAULT_COPY_FEEDBACK_MS = 1200;
21
+ let outerRailDepth = 0;
22
+ /** Internal transcript composition: original components keep their layout and interactions. */
23
+ export function renderWithoutToolRails(component, width) {
24
+ outerRailDepth++;
25
+ try {
26
+ return component.render(width);
27
+ }
28
+ finally {
29
+ outerRailDepth--;
30
+ }
31
+ }
21
32
  function isRecord(value) {
22
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
23
34
  }
@@ -209,6 +220,7 @@ export class ToolView {
209
220
  #localTimer;
210
221
  #lastWidth = 80;
211
222
  #lastHeight = 0;
223
+ #lastNoRail = false;
212
224
  #cache;
213
225
  constructor(spec) {
214
226
  this.#spec = spec;
@@ -247,16 +259,15 @@ export class ToolView {
247
259
  return readFoldLevel(this.#spec.stateHolder, this.#localLevel);
248
260
  }
249
261
  get effectiveLevel() {
250
- const base = this.foldLevel;
251
- if (base === 0)
252
- return 0;
253
- if (this.#spec.globalExpanded === true)
254
- return 2;
255
- return base;
262
+ return this.#spec.globalExpanded === true ? 2 : this.foldLevel;
256
263
  }
257
264
  get collapsed() {
258
265
  return this.foldLevel === 0;
259
266
  }
267
+ /** Current semantic header, without fold/copy controls or body; hidden slots have no summary. */
268
+ get summary() {
269
+ return this.#hidden ? undefined : formatToolHeaderText(this.#spec.header, this.#spec.theme);
270
+ }
260
271
  #collapseChildren() {
261
272
  const { collapsedRows, collapsibleChildren } = this.#spec;
262
273
  if (collapsibleChildren !== undefined) {
@@ -284,7 +295,7 @@ export class ToolView {
284
295
  this.setFoldLevel(collapsed ? 0 : 1);
285
296
  }
286
297
  toggle(ctrlKey = false) {
287
- this.setFoldLevel(transitionFoldLevel(this.foldLevel, ctrlKey));
298
+ this.setFoldLevel(transitionFoldLevel(this.effectiveLevel, ctrlKey));
288
299
  }
289
300
  #triggerCopy(shiftKey) {
290
301
  const copySpec = this.#spec.copy;
@@ -363,7 +374,7 @@ export class ToolView {
363
374
  const safeWidth = Math.max(1, width - pad * 2);
364
375
  const level = this.effectiveLevel;
365
376
  const { theme, collapsedRows, sections } = this.#spec;
366
- const noRail = options?.noRail ?? this.#spec.noRail ?? false;
377
+ const noRail = options?.noRail ?? (outerRailDepth > 0 || this.#spec.noRail === true);
367
378
  const innerWidth = noRail ? safeWidth : Math.max(1, safeWidth - 2);
368
379
  const output = [];
369
380
  if (level === 0) {
@@ -420,15 +431,18 @@ export class ToolView {
420
431
  return [];
421
432
  const level = this.effectiveLevel;
422
433
  const copied = isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
434
+ const noRail = outerRailDepth > 0 || this.#spec.noRail === true;
423
435
  if (this.#cache !== undefined &&
424
436
  this.#cache.width === width &&
425
437
  this.#cache.level === level &&
438
+ this.#cache.noRail === noRail &&
426
439
  this.#cache.copied === copied) {
427
440
  return this.#cache.lines;
428
441
  }
429
442
  const pad = Math.max(0, this.#spec.outputPad ?? 0);
430
443
  const safeWidth = Math.max(1, width - pad * 2);
431
444
  this.#lastWidth = width;
445
+ this.#lastNoRail = noRail;
432
446
  if (level !== 0) {
433
447
  this.#collapseChildren();
434
448
  }
@@ -447,7 +461,7 @@ export class ToolView {
447
461
  }
448
462
  }
449
463
  this.#lastHeight = safeLines.length;
450
- this.#cache = { width, level, copied, lines: safeLines };
464
+ this.#cache = { width, level, copied, noRail, lines: safeLines };
451
465
  return safeLines;
452
466
  }
453
467
  handleMouse(event) {
@@ -488,7 +502,7 @@ export class ToolView {
488
502
  "handleMouse" in rows &&
489
503
  typeof rows.handleMouse === "function") {
490
504
  const handler = rows.handleMouse;
491
- const railOffset = this.#spec.noRail ? 0 : 2;
505
+ const railOffset = this.#lastNoRail ? 0 : 2;
492
506
  const subEvent = {
493
507
  ...event,
494
508
  y: event.y - 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hheei/pi-ext-ui",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Collapsible tool rendering frame and tool renderer pilot for Pi.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -36,11 +36,11 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@hheei/pi-ext-core": "^0.6.0"
39
+ "@hheei/pi-ext-core": "^0.6.4"
40
40
  },
41
41
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": ">=1.3.0",
43
- "@earendil-works/pi-tui": ">=1.3.0"
42
+ "@earendil-works/pi-coding-agent": ">=1.0.1",
43
+ "@earendil-works/pi-tui": ">=1.0.1"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "1.3.0",