@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 +6 -0
- package/dist/extension.js +21 -0
- package/dist/host-transcript-patch.d.ts +6 -0
- package/dist/host-transcript-patch.js +462 -0
- package/dist/nested-calls-view.js +1 -0
- package/dist/payload-parser.d.ts +1 -1
- package/dist/payload-parser.js +19 -2
- package/dist/renderer.d.ts +5 -1
- package/dist/renderer.js +23 -14
- package/dist/tool-view.d.ts +4 -0
- package/dist/tool-view.js +24 -10
- package/package.json +4 -4
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
|
+
}
|
package/dist/payload-parser.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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";
|
package/dist/payload-parser.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/renderer.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ToolRendererResolver, type ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
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
|
|
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
|
|
110
|
-
* call
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/tool-view.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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.#
|
|
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.
|
|
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.
|
|
39
|
+
"@hheei/pi-ext-core": "^0.6.4"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@earendil-works/pi-coding-agent": ">=1.
|
|
43
|
-
"@earendil-works/pi-tui": ">=1.
|
|
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",
|