@hheei/pi-ext-ui 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extension.js +12 -0
- package/dist/host-transcript-patch.d.ts +2 -0
- package/dist/host-transcript-patch.js +123 -164
- package/dist/index.d.ts +1 -1
- package/dist/message-view.d.ts +12 -0
- package/dist/message-view.js +22 -5
- package/dist/nested-calls-view.js +41 -27
- package/dist/payload-parser.js +5 -5
- package/dist/renderer.js +45 -0
- package/dist/tool-group-view.d.ts +45 -0
- package/dist/tool-group-view.js +458 -0
- package/dist/tool-view.d.ts +15 -0
- package/dist/tool-view.js +39 -5
- package/package.json +2 -2
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import { Container, MouseRegion, Spacer, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { fitRow } from "@hheei/pi-ext-core";
|
|
3
|
+
import { formatDurationMs } from "./payload-parser.js";
|
|
4
|
+
import { formatToolHeaderText, isCollapsible, renderWithoutToolRails, } from "./tool-view.js";
|
|
5
|
+
function formatElapsed(milliseconds) {
|
|
6
|
+
const total = Math.max(0, Math.floor(milliseconds / 1000));
|
|
7
|
+
const seconds = total % 60;
|
|
8
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
9
|
+
const hours = Math.floor(total / 3600);
|
|
10
|
+
if (hours > 0)
|
|
11
|
+
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
|
|
12
|
+
if (minutes > 0)
|
|
13
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
14
|
+
return `${seconds}s`;
|
|
15
|
+
}
|
|
16
|
+
/** The same decimal steps the Pi footer uses, so two places never disagree about a token count. */
|
|
17
|
+
function formatTokens(count) {
|
|
18
|
+
if (count < 1000)
|
|
19
|
+
return String(count);
|
|
20
|
+
if (count < 10_000)
|
|
21
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
22
|
+
if (count < 1_000_000)
|
|
23
|
+
return `${String(Math.round(count / 1000))}k`;
|
|
24
|
+
if (count < 10_000_000)
|
|
25
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
26
|
+
return `${String(Math.round(count / 1_000_000))}M`;
|
|
27
|
+
}
|
|
28
|
+
function cacheHitRate(latest, cacheTotal) {
|
|
29
|
+
if (latest === undefined || cacheTotal <= 0)
|
|
30
|
+
return "—";
|
|
31
|
+
const prompt = latest.input + latest.cacheRead + latest.cacheWrite;
|
|
32
|
+
if (prompt <= 0)
|
|
33
|
+
return "—";
|
|
34
|
+
return `${((latest.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
|
35
|
+
}
|
|
36
|
+
function preview(text) {
|
|
37
|
+
return text.split("\n").find((line) => line.trim() !== "");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The collapsed frame of one activity block.
|
|
41
|
+
*
|
|
42
|
+
* The frame owns only layout and hit testing: tool rows keep their own component so their glyph,
|
|
43
|
+
* colors, copy action, and fold level stay the ones the transcript already draws. Anything this
|
|
44
|
+
* view synthesized itself would drift from those rows.
|
|
45
|
+
*/
|
|
46
|
+
export class ToolGroupView extends Container {
|
|
47
|
+
#header = new Text("", 0, 0);
|
|
48
|
+
#ui;
|
|
49
|
+
#redraw;
|
|
50
|
+
#run;
|
|
51
|
+
#members = [];
|
|
52
|
+
#settled = false;
|
|
53
|
+
#raw = false;
|
|
54
|
+
#category;
|
|
55
|
+
#item;
|
|
56
|
+
#renderedExpanded = false;
|
|
57
|
+
#hits = [];
|
|
58
|
+
constructor(ui, redraw, run) {
|
|
59
|
+
super();
|
|
60
|
+
this.#ui = ui;
|
|
61
|
+
this.#redraw = redraw;
|
|
62
|
+
this.#run = run;
|
|
63
|
+
const heading = new MouseRegion(this.#header, (event) => {
|
|
64
|
+
if (event.type !== "click" || event.button !== "left")
|
|
65
|
+
return undefined;
|
|
66
|
+
this.#toggleRaw();
|
|
67
|
+
return { handled: true, render: true };
|
|
68
|
+
});
|
|
69
|
+
const body = {
|
|
70
|
+
render: (width) => this.#renderBody(width),
|
|
71
|
+
handleMouse: (event) => this.#hit(event),
|
|
72
|
+
invalidate() { },
|
|
73
|
+
};
|
|
74
|
+
this.children = [new Spacer(1), heading, body];
|
|
75
|
+
}
|
|
76
|
+
#toggleRaw() {
|
|
77
|
+
this.#raw = this.#ui.getToolsExpanded() ? false : !this.#raw;
|
|
78
|
+
if (this.#raw) {
|
|
79
|
+
this.#category = undefined;
|
|
80
|
+
this.#item = undefined;
|
|
81
|
+
for (const member of this.#members) {
|
|
82
|
+
if (isCollapsible(member.component))
|
|
83
|
+
member.component.collapseAll();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this.#redraw();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The footer names work in progress: the latest top-level call that is still active, or the last
|
|
90
|
+
* one started when none is. Arrival order of partial updates must not decide it.
|
|
91
|
+
*/
|
|
92
|
+
#tailIndex() {
|
|
93
|
+
let last = -1;
|
|
94
|
+
let active = -1;
|
|
95
|
+
for (const [index, member] of this.#members.entries()) {
|
|
96
|
+
if (member.kind !== "tool")
|
|
97
|
+
continue;
|
|
98
|
+
last = index;
|
|
99
|
+
if (member.status === "running" || member.status === "streaming")
|
|
100
|
+
active = index;
|
|
101
|
+
}
|
|
102
|
+
return active === -1 ? last : active;
|
|
103
|
+
}
|
|
104
|
+
#historyTools(tailIndex) {
|
|
105
|
+
// The footer slot shows the tail only while the block runs; once it carries the request
|
|
106
|
+
// totals the tail belongs to the list, or its call would disappear from the block.
|
|
107
|
+
const tail = this.#settled ? -1 : tailIndex;
|
|
108
|
+
const indices = [];
|
|
109
|
+
for (const [index, member] of this.#members.entries()) {
|
|
110
|
+
if (member.kind === "tool" && index !== tail)
|
|
111
|
+
indices.push(index);
|
|
112
|
+
}
|
|
113
|
+
return indices;
|
|
114
|
+
}
|
|
115
|
+
#thinkingItems() {
|
|
116
|
+
return this.#members.filter((member) => (member.thinkingChars ?? 0) > 0);
|
|
117
|
+
}
|
|
118
|
+
#noticeItems() {
|
|
119
|
+
return this.#members.filter((member) => member.kind === "notice");
|
|
120
|
+
}
|
|
121
|
+
#calls() {
|
|
122
|
+
let total = 0;
|
|
123
|
+
for (const member of this.#members) {
|
|
124
|
+
if (member.kind !== "tool")
|
|
125
|
+
continue;
|
|
126
|
+
// The count is a fact the tool reports; a bounded child list must not lower it.
|
|
127
|
+
total += member.childCount ?? (member.children === undefined ? 1 : member.children.length);
|
|
128
|
+
}
|
|
129
|
+
return total;
|
|
130
|
+
}
|
|
131
|
+
#usage() {
|
|
132
|
+
let requests = 0;
|
|
133
|
+
let input = 0;
|
|
134
|
+
let output = 0;
|
|
135
|
+
let cacheTotal = 0;
|
|
136
|
+
let latest;
|
|
137
|
+
for (const member of this.#members) {
|
|
138
|
+
if (member.kind !== "assistant")
|
|
139
|
+
continue;
|
|
140
|
+
requests += 1;
|
|
141
|
+
if (member.usage === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
input += member.usage.input;
|
|
144
|
+
output += member.usage.output;
|
|
145
|
+
cacheTotal += member.usage.cacheRead + member.usage.cacheWrite;
|
|
146
|
+
latest = member.usage;
|
|
147
|
+
}
|
|
148
|
+
return { requests, input, output, cacheTotal, latest };
|
|
149
|
+
}
|
|
150
|
+
/** The block footer: the whole block's request and token totals, fixed under the body. */
|
|
151
|
+
#footerRow() {
|
|
152
|
+
const theme = this.#ui.theme;
|
|
153
|
+
const usage = this.#usage();
|
|
154
|
+
const input = usage.latest === undefined ? "—" : formatTokens(usage.input);
|
|
155
|
+
const output = usage.latest === undefined ? "—" : formatTokens(usage.output);
|
|
156
|
+
const parts = [
|
|
157
|
+
`Requests ×${usage.requests}`,
|
|
158
|
+
`IN ${input}`,
|
|
159
|
+
`OUT ${output}`,
|
|
160
|
+
`CH ${cacheHitRate(usage.latest, usage.cacheTotal)}`,
|
|
161
|
+
];
|
|
162
|
+
return theme.fg("dim", parts.join(" · "));
|
|
163
|
+
}
|
|
164
|
+
#categoryRow(category, label, detail) {
|
|
165
|
+
const theme = this.#ui.theme;
|
|
166
|
+
const pin = `${theme.fg("dim", this.#category === category ? "▾" : "▸")} `;
|
|
167
|
+
const tail = detail === "" ? "" : `${theme.fg("dim", " · ")}${theme.fg("dim", detail)}`;
|
|
168
|
+
return `${pin}${theme.fg("muted", label)}${tail}`;
|
|
169
|
+
}
|
|
170
|
+
#renderBody(width) {
|
|
171
|
+
if (this.#renderedExpanded)
|
|
172
|
+
return this.#renderRaw(width);
|
|
173
|
+
return this.#renderFrame(width);
|
|
174
|
+
}
|
|
175
|
+
#renderRaw(width) {
|
|
176
|
+
const theme = this.#ui.theme;
|
|
177
|
+
const rail = theme.fg("muted", "─".repeat(Math.max(0, width)));
|
|
178
|
+
const lines = [rail];
|
|
179
|
+
const hits = [{ height: 1, action: { kind: "none" } }];
|
|
180
|
+
for (const member of this.#members) {
|
|
181
|
+
const wrapper = this.#offsetWrapper(member.component);
|
|
182
|
+
const rows = renderWithoutToolRails(wrapper, Math.max(1, width));
|
|
183
|
+
if (rows.length === 0)
|
|
184
|
+
continue;
|
|
185
|
+
hits.push({ height: rows.length, action: { kind: "member", wrapper } });
|
|
186
|
+
for (const row of rows)
|
|
187
|
+
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
188
|
+
}
|
|
189
|
+
lines.push(rail);
|
|
190
|
+
hits.push({ height: 1, action: { kind: "none" } });
|
|
191
|
+
this.#hits = hits;
|
|
192
|
+
return lines;
|
|
193
|
+
}
|
|
194
|
+
/** Header, rails, the three categories, and the read-only tail; every row is one terminal row. */
|
|
195
|
+
#renderFrame(width) {
|
|
196
|
+
const theme = this.#ui.theme;
|
|
197
|
+
const lines = [];
|
|
198
|
+
const hits = [];
|
|
199
|
+
const push = (row, action = { kind: "none" }) => {
|
|
200
|
+
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
201
|
+
hits.push({ height: 1, action });
|
|
202
|
+
};
|
|
203
|
+
/** Rows that belong to one component keep one hit so its own geometry stays correct. */
|
|
204
|
+
const pushBlock = (rows, action) => {
|
|
205
|
+
if (rows.length === 0)
|
|
206
|
+
return;
|
|
207
|
+
for (const row of rows)
|
|
208
|
+
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
209
|
+
hits.push({ height: rows.length, action });
|
|
210
|
+
};
|
|
211
|
+
const rail = theme.fg("muted", "─".repeat(Math.max(0, width)));
|
|
212
|
+
const tailIndex = this.#tailIndex();
|
|
213
|
+
const tools = this.#historyTools(tailIndex);
|
|
214
|
+
push(rail);
|
|
215
|
+
const notices = this.#noticeItems();
|
|
216
|
+
if (notices.length > 0) {
|
|
217
|
+
const kinds = new Map();
|
|
218
|
+
for (const member of notices) {
|
|
219
|
+
const kind = member.title ?? "notice";
|
|
220
|
+
kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
|
|
221
|
+
}
|
|
222
|
+
const detail = [...kinds].map(([kind, count]) => `${kind} ×${count}`).join(" · ");
|
|
223
|
+
push(this.#categoryRow("notices", "notice", detail), {
|
|
224
|
+
kind: "category",
|
|
225
|
+
category: "notices",
|
|
226
|
+
});
|
|
227
|
+
if (this.#category === "notices")
|
|
228
|
+
this.#renderNotices(notices, width, push);
|
|
229
|
+
}
|
|
230
|
+
const thinking = this.#thinkingItems();
|
|
231
|
+
if (thinking.length > 0) {
|
|
232
|
+
const tokens = Math.ceil(thinking.reduce((sum, member) => sum + (member.thinkingChars ?? 0), 0) / 4);
|
|
233
|
+
push(this.#categoryRow("thinking", "thinking", `${String(tokens)} tokens`), {
|
|
234
|
+
kind: "category",
|
|
235
|
+
category: "thinking",
|
|
236
|
+
});
|
|
237
|
+
if (this.#category === "thinking")
|
|
238
|
+
this.#renderThinking(thinking, width, push);
|
|
239
|
+
}
|
|
240
|
+
const counts = new Map();
|
|
241
|
+
for (const index of tools) {
|
|
242
|
+
const member = this.#members[index];
|
|
243
|
+
if (member === undefined)
|
|
244
|
+
continue;
|
|
245
|
+
if (member.children === undefined) {
|
|
246
|
+
if (member.title !== undefined)
|
|
247
|
+
counts.set(member.title, (counts.get(member.title) ?? 0) + 1);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
for (const child of member.children)
|
|
251
|
+
counts.set(child.title, (counts.get(child.title) ?? 0) + 1);
|
|
252
|
+
}
|
|
253
|
+
if (counts.size > 0) {
|
|
254
|
+
const names = [...counts]
|
|
255
|
+
.map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
|
|
256
|
+
.join(theme.fg("dim", " · "));
|
|
257
|
+
push(this.#categoryRow("tools", names, ""), {
|
|
258
|
+
kind: "category",
|
|
259
|
+
category: "tools",
|
|
260
|
+
});
|
|
261
|
+
if (this.#category === "tools")
|
|
262
|
+
this.#renderTools(theme, tools, width, push, pushBlock);
|
|
263
|
+
}
|
|
264
|
+
push(rail);
|
|
265
|
+
// One footer slot: the running tail while the block is live, the block totals once it settles.
|
|
266
|
+
const tail = this.#members[tailIndex];
|
|
267
|
+
if (this.#settled)
|
|
268
|
+
push(this.#footerRow());
|
|
269
|
+
else if (tail !== undefined && tail.title !== undefined) {
|
|
270
|
+
push(formatToolHeaderText({ status: tail.status, title: tail.title }, theme));
|
|
271
|
+
}
|
|
272
|
+
this.#hits = hits;
|
|
273
|
+
return lines;
|
|
274
|
+
}
|
|
275
|
+
/** Every tool at its own level 0, one blank row between calls. */
|
|
276
|
+
#renderTools(theme, tools, width, push, pushBlock) {
|
|
277
|
+
let first = true;
|
|
278
|
+
const separate = () => {
|
|
279
|
+
if (!first)
|
|
280
|
+
push("");
|
|
281
|
+
first = false;
|
|
282
|
+
};
|
|
283
|
+
for (const memberIndex of tools) {
|
|
284
|
+
const member = this.#members[memberIndex];
|
|
285
|
+
if (member === undefined)
|
|
286
|
+
continue;
|
|
287
|
+
if (member.children === undefined) {
|
|
288
|
+
const wrapper = this.#offsetWrapper(member.component);
|
|
289
|
+
separate();
|
|
290
|
+
// One hit for the whole tool: its own rows carry the header, copy slot, and fold state.
|
|
291
|
+
pushBlock(renderWithoutToolRails(wrapper, Math.max(1, width)), {
|
|
292
|
+
kind: "member",
|
|
293
|
+
wrapper,
|
|
294
|
+
});
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
for (const [index, child] of member.children.entries()) {
|
|
298
|
+
separate();
|
|
299
|
+
const open = this.#item?.member === member.component && this.#item.child === index;
|
|
300
|
+
// The container's own row already reads `summary · duration`; a child listed in the
|
|
301
|
+
// group must read the same, or the same call looks different in the two views.
|
|
302
|
+
const summary = [child.summary ?? "", formatDurationMs(child.durationMs)]
|
|
303
|
+
.filter((part) => part !== "")
|
|
304
|
+
.join(" · ");
|
|
305
|
+
push(`${theme.fg("dim", open ? "▾" : "▸")} ${formatToolHeaderText({ status: child.status, title: child.title, summary }, theme)}`, { kind: "item", member: member.component, child: index });
|
|
306
|
+
if (!open || child.detail === undefined)
|
|
307
|
+
continue;
|
|
308
|
+
for (const row of this.#detailRows(child.detail, width))
|
|
309
|
+
push(row);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/** One thinking block per assistant turn: a label row, one preview row, and the text on demand. */
|
|
314
|
+
#renderThinking(thinking, width, push) {
|
|
315
|
+
const theme = this.#ui.theme;
|
|
316
|
+
let first = true;
|
|
317
|
+
for (const member of thinking) {
|
|
318
|
+
if (!first)
|
|
319
|
+
push("");
|
|
320
|
+
first = false;
|
|
321
|
+
const open = this.#item?.member === member.component;
|
|
322
|
+
const tokens = Math.ceil((member.thinkingChars ?? 0) / 4);
|
|
323
|
+
push(`${theme.fg("dim", open ? "▾" : "▸")} ${theme.fg("muted", "thinking")}${theme.fg("dim", ` · ${String(tokens)} tokens`)}`, { kind: "item", member: member.component });
|
|
324
|
+
const text = member.thinking;
|
|
325
|
+
if (text === undefined)
|
|
326
|
+
continue;
|
|
327
|
+
if (open) {
|
|
328
|
+
for (const row of this.#detailRows(text, width))
|
|
329
|
+
push(row);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const line = preview(text);
|
|
333
|
+
if (line !== undefined)
|
|
334
|
+
push(`${theme.fg("dim", "▎")} ${theme.fg("thinkingText", line)}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** Notices keep one row each and open their provider-rendered detail in place. */
|
|
338
|
+
#renderNotices(notices, width, push) {
|
|
339
|
+
const theme = this.#ui.theme;
|
|
340
|
+
for (const member of notices) {
|
|
341
|
+
const detail = member.detail;
|
|
342
|
+
const open = detail !== undefined && this.#item?.member === member.component;
|
|
343
|
+
const pin = detail === undefined ? "" : `${theme.fg("dim", open ? "▾" : "▸")} `;
|
|
344
|
+
push(`${pin}${member.summary ?? member.title ?? ""}`, detail === undefined ? { kind: "none" } : { kind: "item", member: member.component });
|
|
345
|
+
if (!open)
|
|
346
|
+
continue;
|
|
347
|
+
for (const row of detail(width))
|
|
348
|
+
push(row);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
#hit(event) {
|
|
352
|
+
let y = event.y;
|
|
353
|
+
for (const hit of this.#hits) {
|
|
354
|
+
if (y >= hit.height) {
|
|
355
|
+
y -= hit.height;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const action = hit.action;
|
|
359
|
+
if (action.kind === "none")
|
|
360
|
+
return undefined;
|
|
361
|
+
if (event.type !== "click" || event.button !== "left")
|
|
362
|
+
return undefined;
|
|
363
|
+
if (action.kind === "category") {
|
|
364
|
+
const open = this.#category === action.category;
|
|
365
|
+
this.#category = open ? undefined : action.category;
|
|
366
|
+
this.#item = undefined;
|
|
367
|
+
this.#redraw();
|
|
368
|
+
return { handled: true, render: true };
|
|
369
|
+
}
|
|
370
|
+
if (action.kind === "item") {
|
|
371
|
+
const same = this.#item?.member === action.member && this.#item.child === action.child;
|
|
372
|
+
this.#item = same
|
|
373
|
+
? undefined
|
|
374
|
+
: {
|
|
375
|
+
member: action.member,
|
|
376
|
+
...(action.child === undefined ? {} : { child: action.child }),
|
|
377
|
+
};
|
|
378
|
+
this.#redraw();
|
|
379
|
+
return { handled: true, render: true };
|
|
380
|
+
}
|
|
381
|
+
return action.wrapper.handleMouse?.({ ...event, y, height: hit.height });
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The member's own rows without Pi's leading separator. Built only for a member the current frame
|
|
387
|
+
* actually draws, so a collapsed category allocates nothing for the rows it hides.
|
|
388
|
+
*/
|
|
389
|
+
#offsetWrapper(component) {
|
|
390
|
+
let offset = 0;
|
|
391
|
+
return {
|
|
392
|
+
render(width) {
|
|
393
|
+
const rows = component.render(width);
|
|
394
|
+
// Pi adds one leading separator per tool; the group supplies its own spacing.
|
|
395
|
+
offset = rows[0] === "" ? 1 : 0;
|
|
396
|
+
return rows.slice(offset);
|
|
397
|
+
},
|
|
398
|
+
handleMouse(event) {
|
|
399
|
+
return component.handleMouse?.({
|
|
400
|
+
...event,
|
|
401
|
+
y: event.y + offset,
|
|
402
|
+
height: event.height + offset,
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
invalidate() { },
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Expanded detail keeps its text. The user opened it to read it, so a line wider than the frame is
|
|
410
|
+
* wrapped to the width the indent leaves instead of being cut with an ellipsis.
|
|
411
|
+
*/
|
|
412
|
+
#detailRows(text, width) {
|
|
413
|
+
const inner = Math.max(1, width - 2);
|
|
414
|
+
return text.split("\n").flatMap((line) => {
|
|
415
|
+
const wrapped = wrapTextWithAnsi(line, inner);
|
|
416
|
+
return (wrapped.length > 0 ? wrapped : [""]).map((row) => ` ${row}`);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
update(members, settled) {
|
|
420
|
+
this.#members = members;
|
|
421
|
+
this.#settled = settled;
|
|
422
|
+
if (this.#item !== undefined && !members.some((m) => m.component === this.#item?.member))
|
|
423
|
+
this.#item = undefined;
|
|
424
|
+
if (this.#category === "thinking" && this.#thinkingItems().length === 0)
|
|
425
|
+
this.#category = undefined;
|
|
426
|
+
if (this.#category === "notices" && this.#noticeItems().length === 0)
|
|
427
|
+
this.#category = undefined;
|
|
428
|
+
}
|
|
429
|
+
invalidate() {
|
|
430
|
+
// Original chatContainer.invalidate invalidates original members exactly once; only the header
|
|
431
|
+
// text belongs to this view.
|
|
432
|
+
this.#header.invalidate();
|
|
433
|
+
}
|
|
434
|
+
render(width) {
|
|
435
|
+
const globalExpanded = this.#ui.getToolsExpanded();
|
|
436
|
+
if (globalExpanded) {
|
|
437
|
+
// The host's forced expansion owns the frame; closing it returns to a fully closed group.
|
|
438
|
+
// A raw view the user opened by hand must not outlive it, or closing Ctrl+O would leave the
|
|
439
|
+
// block expanded with no way back to the aggregate.
|
|
440
|
+
this.#raw = false;
|
|
441
|
+
this.#category = undefined;
|
|
442
|
+
this.#item = undefined;
|
|
443
|
+
}
|
|
444
|
+
this.#renderedExpanded = this.#raw || globalExpanded;
|
|
445
|
+
const theme = this.#ui.theme;
|
|
446
|
+
const calls = this.#calls();
|
|
447
|
+
const elapsed = this.#run === undefined
|
|
448
|
+
? "—"
|
|
449
|
+
: formatElapsed((this.#run.endedAt ?? performance.now()) - this.#run.startedAt);
|
|
450
|
+
const details = [
|
|
451
|
+
theme.fg("muted", "collapse views"),
|
|
452
|
+
theme.fg("dim", elapsed),
|
|
453
|
+
theme.fg("dim", `${calls} ${calls === 1 ? "call" : "calls"}`),
|
|
454
|
+
].join(theme.fg("dim", " · "));
|
|
455
|
+
this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width), "…"));
|
|
456
|
+
return super.render(width);
|
|
457
|
+
}
|
|
458
|
+
}
|
package/dist/tool-view.d.ts
CHANGED
|
@@ -23,11 +23,22 @@ export interface Collapsible {
|
|
|
23
23
|
collapseAll(): void;
|
|
24
24
|
}
|
|
25
25
|
export declare function isCollapsible(value: unknown): value is Collapsible;
|
|
26
|
+
/** One nested call inside a container, reduced to what a row and its detail need. */
|
|
27
|
+
export interface ToolViewChild {
|
|
28
|
+
readonly title: string;
|
|
29
|
+
readonly summary?: string | undefined;
|
|
30
|
+
readonly status?: ToolStatusKind | undefined;
|
|
31
|
+
readonly durationMs?: number | undefined;
|
|
32
|
+
readonly detail?: string | undefined;
|
|
33
|
+
}
|
|
26
34
|
export interface ToolViewSpec {
|
|
27
35
|
readonly theme: Theme;
|
|
28
36
|
readonly header: ToolViewHeaderSpec;
|
|
29
37
|
readonly collapsedRows?: readonly string[] | Component | (Component & Collapsible) | undefined;
|
|
30
38
|
readonly sections?: readonly RailSection[] | undefined;
|
|
39
|
+
readonly children?: readonly ToolViewChild[] | undefined;
|
|
40
|
+
/** Nested calls the tool made, when the list above is only what fits. */
|
|
41
|
+
readonly childCount?: number | undefined;
|
|
31
42
|
readonly collapsibleChildren?: readonly Collapsible[] | undefined;
|
|
32
43
|
readonly copy?: ToolViewCopySpec | undefined;
|
|
33
44
|
readonly stateHolder?: unknown;
|
|
@@ -87,6 +98,10 @@ export declare class ToolView implements Component {
|
|
|
87
98
|
get collapsed(): boolean;
|
|
88
99
|
/** Current semantic header, without fold/copy controls or body; hidden slots have no summary. */
|
|
89
100
|
get summary(): string | undefined;
|
|
101
|
+
/** Nested calls this tool reported; a hidden slot reports none. */
|
|
102
|
+
get children(): readonly ToolViewChild[] | undefined;
|
|
103
|
+
/** How many nested calls the tool made, even when `children` lists only what fits. */
|
|
104
|
+
get childCount(): number | undefined;
|
|
90
105
|
setFoldLevel(next: FoldLevel): void;
|
|
91
106
|
setCollapsed(collapsed: boolean): void;
|
|
92
107
|
toggle(ctrlKey?: boolean): void;
|
package/dist/tool-view.js
CHANGED
|
@@ -13,6 +13,7 @@ export function createToolView(spec) {
|
|
|
13
13
|
}
|
|
14
14
|
const FOLD_LEVEL_STATE_KEY = "__piExtUiFoldLevel";
|
|
15
15
|
const COPIED_UNTIL_STATE_KEY = "__piExtUiCopiedUntil";
|
|
16
|
+
const COPY_FAILED_STATE_KEY = "__piExtUiCopyFailed";
|
|
16
17
|
const COPY_FEEDBACK_TIMER_KEY = "__piExtUiCopyTimer";
|
|
17
18
|
const DEFAULT_MAX_SECTION_LINES = 9;
|
|
18
19
|
export const MAX_LEVEL2_SECTION_LINES = 500;
|
|
@@ -61,6 +62,12 @@ function isCopiedActive(stateHolder, localCopiedUntil) {
|
|
|
61
62
|
const until = stateHolder[COPIED_UNTIL_STATE_KEY];
|
|
62
63
|
return typeof until === "number" && until > now;
|
|
63
64
|
}
|
|
65
|
+
/** A rejected write is feedback of its own; it must not leave the success mark on screen. */
|
|
66
|
+
function isCopyFailed(stateHolder, localFailed) {
|
|
67
|
+
if (localFailed)
|
|
68
|
+
return true;
|
|
69
|
+
return isRecord(stateHolder) && stateHolder[COPY_FAILED_STATE_KEY] === true;
|
|
70
|
+
}
|
|
64
71
|
export function formatStatusGlyph(status, theme) {
|
|
65
72
|
switch (status ?? "ok") {
|
|
66
73
|
case "streaming":
|
|
@@ -217,6 +224,7 @@ export class ToolView {
|
|
|
217
224
|
#spec;
|
|
218
225
|
#localLevel;
|
|
219
226
|
#localCopiedUntil = 0;
|
|
227
|
+
#localCopyFailed = false;
|
|
220
228
|
#localTimer;
|
|
221
229
|
#lastWidth = 80;
|
|
222
230
|
#lastHeight = 0;
|
|
@@ -233,12 +241,15 @@ export class ToolView {
|
|
|
233
241
|
const until = spec.stateHolder[COPIED_UNTIL_STATE_KEY];
|
|
234
242
|
if (typeof until === "number" && until > Date.now()) {
|
|
235
243
|
this.#localCopiedUntil = until;
|
|
244
|
+
this.#localCopyFailed = spec.stateHolder[COPY_FAILED_STATE_KEY] === true;
|
|
236
245
|
const remaining = until - Date.now();
|
|
237
246
|
if (!spec.stateHolder[COPY_FEEDBACK_TIMER_KEY]) {
|
|
238
247
|
const timer = setTimeout(() => {
|
|
239
248
|
this.#localCopiedUntil = 0;
|
|
249
|
+
this.#localCopyFailed = false;
|
|
240
250
|
if (isRecord(this.#spec.stateHolder)) {
|
|
241
251
|
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
|
|
252
|
+
this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = false;
|
|
242
253
|
this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
|
|
243
254
|
}
|
|
244
255
|
this.#localTimer = undefined;
|
|
@@ -268,6 +279,14 @@ export class ToolView {
|
|
|
268
279
|
get summary() {
|
|
269
280
|
return this.#hidden ? undefined : formatToolHeaderText(this.#spec.header, this.#spec.theme);
|
|
270
281
|
}
|
|
282
|
+
/** Nested calls this tool reported; a hidden slot reports none. */
|
|
283
|
+
get children() {
|
|
284
|
+
return this.#hidden ? undefined : this.#spec.children;
|
|
285
|
+
}
|
|
286
|
+
/** How many nested calls the tool made, even when `children` lists only what fits. */
|
|
287
|
+
get childCount() {
|
|
288
|
+
return this.#hidden ? undefined : this.#spec.childCount;
|
|
289
|
+
}
|
|
271
290
|
#collapseChildren() {
|
|
272
291
|
const { collapsedRows, collapsibleChildren } = this.#spec;
|
|
273
292
|
if (collapsibleChildren !== undefined) {
|
|
@@ -306,24 +325,34 @@ export class ToolView {
|
|
|
306
325
|
: copySpec.primary;
|
|
307
326
|
const writeClip = this.#spec.copyToClipboard ?? copyToSystemClipboard;
|
|
308
327
|
const durationMs = this.#spec.copyFeedbackDurationMs ?? DEFAULT_COPY_FEEDBACK_MS;
|
|
328
|
+
// Feedback starts when the write settles. Marking it before the promise made a rejected
|
|
329
|
+
// clipboard write look exactly like a successful copy.
|
|
330
|
+
void writeClip(payload).then(() => this.#showCopyFeedback(durationMs, false), () => this.#showCopyFeedback(durationMs, true));
|
|
331
|
+
}
|
|
332
|
+
/** One feedback window in the copy slot: ✓ when the text reached the clipboard, ✗ when it did not. */
|
|
333
|
+
#showCopyFeedback(durationMs, failed) {
|
|
309
334
|
const until = Date.now() + durationMs;
|
|
310
|
-
this.#localCopiedUntil = until;
|
|
335
|
+
this.#localCopiedUntil = failed ? 0 : until;
|
|
336
|
+
this.#localCopyFailed = failed;
|
|
311
337
|
if (isRecord(this.#spec.stateHolder)) {
|
|
312
338
|
const prevTimer = this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY];
|
|
313
339
|
if (prevTimer !== undefined) {
|
|
314
340
|
clearTimeout(prevTimer);
|
|
315
341
|
}
|
|
316
|
-
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] =
|
|
342
|
+
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = this.#localCopiedUntil;
|
|
343
|
+
this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = failed;
|
|
317
344
|
}
|
|
318
345
|
if (this.#localTimer !== undefined) {
|
|
319
346
|
clearTimeout(this.#localTimer);
|
|
320
347
|
}
|
|
321
|
-
|
|
348
|
+
this.#cache = undefined;
|
|
322
349
|
this.#spec.invalidate?.();
|
|
323
350
|
const timer = setTimeout(() => {
|
|
324
351
|
this.#localCopiedUntil = 0;
|
|
352
|
+
this.#localCopyFailed = false;
|
|
325
353
|
if (isRecord(this.#spec.stateHolder)) {
|
|
326
354
|
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
|
|
355
|
+
this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = false;
|
|
327
356
|
this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
|
|
328
357
|
}
|
|
329
358
|
this.#localTimer = undefined;
|
|
@@ -345,8 +374,13 @@ export class ToolView {
|
|
|
345
374
|
if (copy === undefined || safeWidth < 6) {
|
|
346
375
|
return truncate(leftContent, safeWidth);
|
|
347
376
|
}
|
|
348
|
-
const
|
|
349
|
-
const
|
|
377
|
+
const failed = isCopyFailed(this.#spec.stateHolder, this.#localCopyFailed);
|
|
378
|
+
const copied = !failed && isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
|
|
379
|
+
const copyIcon = failed
|
|
380
|
+
? theme.fg("error", "✗")
|
|
381
|
+
: copied
|
|
382
|
+
? theme.fg("success", "✓")
|
|
383
|
+
: theme.fg("dim", "");
|
|
350
384
|
// Leave 1 column on the right for Pi's fullscreen scrollbar so the copy icon doesn't collide
|
|
351
385
|
const rightMargin = 1;
|
|
352
386
|
const copySlotWidth = 2 + rightMargin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hheei/pi-ext-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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,7 +36,7 @@
|
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@hheei/pi-ext-core": "^0.
|
|
39
|
+
"@hheei/pi-ext-core": "^0.7.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@earendil-works/pi-coding-agent": ">=1.0.1",
|