@hheei/pi-ext-ui 0.6.0 → 0.7.1
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 +2 -2
- package/dist/background.d.ts +18 -0
- package/dist/background.js +44 -0
- package/dist/extension.js +59 -7
- package/dist/host-transcript-patch.js +186 -58
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/message-view.d.ts +4 -10
- package/dist/message-view.js +36 -18
- package/dist/native-tools.d.ts +69 -0
- package/dist/native-tools.js +414 -0
- package/dist/nested-calls-view.d.ts +6 -2
- package/dist/nested-calls-view.js +58 -73
- package/dist/payload-parser.d.ts +10 -28
- package/dist/payload-parser.js +9 -269
- package/dist/renderer.d.ts +6 -4
- package/dist/renderer.js +254 -189
- package/dist/runtime.d.ts +13 -0
- package/dist/runtime.js +16 -0
- package/dist/settings.d.ts +3 -0
- package/dist/settings.js +18 -1
- package/dist/tool-group-view.d.ts +54 -8
- package/dist/tool-group-view.js +399 -169
- package/dist/tool-view.d.ts +20 -6
- package/dist/tool-view.js +87 -34
- package/dist/tracker.d.ts +36 -17
- package/dist/tracker.js +157 -19
- package/package.json +2 -2
package/dist/tool-group-view.js
CHANGED
|
@@ -1,7 +1,77 @@
|
|
|
1
|
-
import { Container, MouseRegion, Spacer, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Box, Container, MouseRegion, Spacer, Text, truncateToWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
2
|
import { fitRow } from "@hheei/pi-ext-core";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { backgroundFill } from "./background.js";
|
|
4
|
+
import { isCollapsible, toContentRows, toSingleRow, } from "./tool-view.js";
|
|
5
|
+
/** A settled block keeps its surface this long, then drops it so the transcript stays quiet. */
|
|
6
|
+
const SURFACE_HOLD_MS = 5_000;
|
|
7
|
+
/** A tool that has not received its final result yet: its own rows are still changing height. */
|
|
8
|
+
function isLiveTail(member) {
|
|
9
|
+
return member.kind === "tool" && member.completed !== true;
|
|
10
|
+
}
|
|
11
|
+
/** Every original component of a member, in transcript order. */
|
|
12
|
+
export function memberParts(member) {
|
|
13
|
+
return member.parts ?? [member.component];
|
|
14
|
+
}
|
|
15
|
+
export class TailUnit {
|
|
16
|
+
#parts;
|
|
17
|
+
#offsets = [];
|
|
18
|
+
/** Rows already shown for a live member: a tool that grows may not shrink back and shift the block. */
|
|
19
|
+
#reserved = 0;
|
|
20
|
+
#live;
|
|
21
|
+
constructor(member) {
|
|
22
|
+
this.#parts = memberParts(member);
|
|
23
|
+
this.#live = isLiveTail(member);
|
|
24
|
+
}
|
|
25
|
+
/** The same tail with a newer fact: the component list can grow, the reserved height must not. */
|
|
26
|
+
update(member) {
|
|
27
|
+
this.#parts = memberParts(member);
|
|
28
|
+
this.#live = isLiveTail(member);
|
|
29
|
+
}
|
|
30
|
+
render(width) {
|
|
31
|
+
const offsets = [];
|
|
32
|
+
const rows = [];
|
|
33
|
+
for (const part of this.#parts) {
|
|
34
|
+
const all = part.render(width);
|
|
35
|
+
const skip = all[0] === "" ? 1 : 0;
|
|
36
|
+
const own = all.slice(skip);
|
|
37
|
+
if (own.length === 0)
|
|
38
|
+
continue;
|
|
39
|
+
if (rows.length > 0)
|
|
40
|
+
rows.push("");
|
|
41
|
+
offsets.push({ part, top: rows.length, height: own.length, skip });
|
|
42
|
+
rows.push(...own);
|
|
43
|
+
}
|
|
44
|
+
this.#offsets = offsets;
|
|
45
|
+
if (rows.length === 0)
|
|
46
|
+
return rows;
|
|
47
|
+
const out = rows[0] === "" ? rows : ["", ...rows];
|
|
48
|
+
// A live tail grows downward from a fixed top: the host keeps the transcript's bottom in view, so
|
|
49
|
+
// a row count that falls back drags everything above it down. Reserve the tallest frame instead.
|
|
50
|
+
if (this.#live)
|
|
51
|
+
this.#reserved = Math.max(this.#reserved, out.length);
|
|
52
|
+
else
|
|
53
|
+
this.#reserved = 0;
|
|
54
|
+
while (out.length < this.#reserved)
|
|
55
|
+
out.push("");
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
handleMouse(event) {
|
|
59
|
+
for (const { part, top, height, skip } of this.#offsets) {
|
|
60
|
+
if (event.y < top || event.y >= top + height)
|
|
61
|
+
continue;
|
|
62
|
+
return part.handleMouse?.({
|
|
63
|
+
...event,
|
|
64
|
+
y: event.y - top + skip,
|
|
65
|
+
height: height + skip,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
invalidate() {
|
|
71
|
+
for (const part of this.#parts)
|
|
72
|
+
part.invalidate?.();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
5
75
|
function formatElapsed(milliseconds) {
|
|
6
76
|
const total = Math.max(0, Math.floor(milliseconds / 1000));
|
|
7
77
|
const seconds = total % 60;
|
|
@@ -25,16 +95,37 @@ function formatTokens(count) {
|
|
|
25
95
|
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
26
96
|
return `${String(Math.round(count / 1_000_000))}M`;
|
|
27
97
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The native footer's cache-hit rule: the last request that actually measured a prompt. A zeroed
|
|
100
|
+
* streaming placeholder is not that request, and a measured prompt with no cache read is 0%, not
|
|
101
|
+
* unknown — missing data and a real zero must not share one placeholder.
|
|
102
|
+
*/
|
|
103
|
+
function cacheHitRate(latestPrompt) {
|
|
104
|
+
if (latestPrompt === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
const prompt = latestPrompt.input + latestPrompt.cacheRead + latestPrompt.cacheWrite;
|
|
32
107
|
if (prompt <= 0)
|
|
33
|
-
return
|
|
34
|
-
return `${((
|
|
108
|
+
return undefined;
|
|
109
|
+
return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1)}%`;
|
|
35
110
|
}
|
|
36
|
-
|
|
37
|
-
|
|
111
|
+
const PREVIEW_GRAPHEMES = 20;
|
|
112
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
113
|
+
/**
|
|
114
|
+
* The thinking preview: the first line's leading graphemes, and whether anything follows them. A
|
|
115
|
+
* grapheme cap keeps emoji and CJK clusters whole; the frame still truncates to the cells it has.
|
|
116
|
+
*/
|
|
117
|
+
function thinkingPreview(text) {
|
|
118
|
+
const content = text.trim();
|
|
119
|
+
const line = toSingleRow(content.split("\n").find((row) => row.trim() !== "") ?? "");
|
|
120
|
+
let head = "";
|
|
121
|
+
let count = 0;
|
|
122
|
+
for (const { segment } of graphemes.segment(line)) {
|
|
123
|
+
if (count === PREVIEW_GRAPHEMES)
|
|
124
|
+
break;
|
|
125
|
+
head += segment;
|
|
126
|
+
count++;
|
|
127
|
+
}
|
|
128
|
+
return { head, more: content !== head };
|
|
38
129
|
}
|
|
39
130
|
/**
|
|
40
131
|
* The collapsed frame of one activity block.
|
|
@@ -47,31 +138,48 @@ export class ToolGroupView extends Container {
|
|
|
47
138
|
#header = new Text("", 0, 0);
|
|
48
139
|
#ui;
|
|
49
140
|
#redraw;
|
|
50
|
-
|
|
141
|
+
/** The stable clock for this block; restored history stays unknown until live content joins. */
|
|
142
|
+
#clock;
|
|
143
|
+
/** The block's own members, drawn inside the group. */
|
|
51
144
|
#members = [];
|
|
52
|
-
|
|
145
|
+
/** The block's last visible unit, drawn outside the group by its own component. */
|
|
146
|
+
#tail;
|
|
147
|
+
/** Every model request the block owns, in transcript order, including the tail's and native prose. */
|
|
148
|
+
#requests = [];
|
|
53
149
|
#raw = false;
|
|
54
150
|
#category;
|
|
55
151
|
#item;
|
|
56
152
|
#renderedExpanded = false;
|
|
153
|
+
#shell = "collapsed";
|
|
154
|
+
/** When the block last became quiet; the surface hold counts from here. */
|
|
155
|
+
#settledAt;
|
|
156
|
+
#holdTimer;
|
|
57
157
|
#hits = [];
|
|
58
|
-
|
|
158
|
+
#heading;
|
|
159
|
+
#body;
|
|
160
|
+
// One extra colored row above and below: the block needs breathing room inside its background.
|
|
161
|
+
#box = new Box(0, 1);
|
|
162
|
+
#spacer = new Spacer(1);
|
|
163
|
+
constructor(ui, redraw) {
|
|
59
164
|
super();
|
|
60
165
|
this.#ui = ui;
|
|
61
166
|
this.#redraw = redraw;
|
|
62
|
-
this.#
|
|
63
|
-
const heading = new MouseRegion(this.#header, (event) => {
|
|
167
|
+
this.#heading = new MouseRegion(this.#header, (event) => {
|
|
64
168
|
if (event.type !== "click" || event.button !== "left")
|
|
65
169
|
return undefined;
|
|
66
170
|
this.#toggleRaw();
|
|
67
171
|
return { handled: true, render: true };
|
|
68
172
|
});
|
|
69
|
-
|
|
173
|
+
this.#body = {
|
|
70
174
|
render: (width) => this.#renderBody(width),
|
|
71
175
|
handleMouse: (event) => this.#hit(event),
|
|
72
176
|
invalidate() { },
|
|
73
177
|
};
|
|
74
|
-
|
|
178
|
+
// The group background covers the header, categories, and footer. Zero padding keeps the rows
|
|
179
|
+
// at the width they were laid out for; a tool's own outputPad must not be counted twice.
|
|
180
|
+
this.#box.addChild(this.#heading);
|
|
181
|
+
this.#box.addChild(this.#body);
|
|
182
|
+
this.children = [this.#spacer, this.#box];
|
|
75
183
|
}
|
|
76
184
|
#toggleRaw() {
|
|
77
185
|
this.#raw = this.#ui.getToolsExpanded() ? false : !this.#raw;
|
|
@@ -85,113 +193,176 @@ export class ToolGroupView extends Container {
|
|
|
85
193
|
}
|
|
86
194
|
this.#redraw();
|
|
87
195
|
}
|
|
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
196
|
#thinkingItems() {
|
|
116
|
-
return this.#members.filter((member) =>
|
|
197
|
+
return this.#members.filter((member) => member.thinking !== undefined && member.thinking !== "");
|
|
117
198
|
}
|
|
118
199
|
#noticeItems() {
|
|
119
200
|
return this.#members.filter((member) => member.kind === "notice");
|
|
120
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Completed calls only: a container's final children replace its parent exactly once, and a call
|
|
204
|
+
* still streaming args or running has not produced that fact yet.
|
|
205
|
+
*/
|
|
121
206
|
#calls() {
|
|
122
207
|
let total = 0;
|
|
123
|
-
|
|
124
|
-
|
|
208
|
+
// The tail outside the group is still this block's work, so its calls belong to the header.
|
|
209
|
+
for (const member of this.#block()) {
|
|
210
|
+
if (member.kind !== "tool" || member.completed !== true)
|
|
125
211
|
continue;
|
|
126
212
|
// The count is a fact the tool reports; a bounded child list must not lower it.
|
|
127
213
|
total += member.childCount ?? (member.children === undefined ? 1 : member.children.length);
|
|
128
214
|
}
|
|
129
215
|
return total;
|
|
130
216
|
}
|
|
217
|
+
/** The group's own members, then the unit it leaves outside. */
|
|
218
|
+
#block() {
|
|
219
|
+
return this.#tail === undefined ? [...this.#members] : [...this.#members, this.#tail];
|
|
220
|
+
}
|
|
221
|
+
/** The block's own last fact: the newest member that states a status, tail included. */
|
|
222
|
+
#lastStatus() {
|
|
223
|
+
const block = this.#block();
|
|
224
|
+
for (let index = block.length - 1; index >= 0; index -= 1) {
|
|
225
|
+
const status = block[index]?.status;
|
|
226
|
+
if (status !== undefined)
|
|
227
|
+
return status;
|
|
228
|
+
}
|
|
229
|
+
return undefined;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The surface states the block's last fact, not its worst one, so one old failure cannot tint a
|
|
233
|
+
* block forever. A settled block keeps that color for five seconds and then falls back to the
|
|
234
|
+
* neutral surface, because a transcript full of settled blocks is a transcript to read, not a wall
|
|
235
|
+
* of colored panels.
|
|
236
|
+
*/
|
|
237
|
+
#surfaceToken() {
|
|
238
|
+
const last = this.#lastStatus();
|
|
239
|
+
if (last === "running" || last === "streaming") {
|
|
240
|
+
this.#settledAt = undefined;
|
|
241
|
+
this.#stopHold();
|
|
242
|
+
return "toolPendingBg";
|
|
243
|
+
}
|
|
244
|
+
const now = performance.now();
|
|
245
|
+
this.#settledAt ??= now;
|
|
246
|
+
const held = now - this.#settledAt;
|
|
247
|
+
if (last === undefined || held >= SURFACE_HOLD_MS)
|
|
248
|
+
return "toolPendingBg";
|
|
249
|
+
this.#hold(SURFACE_HOLD_MS - held);
|
|
250
|
+
return last === "error" ? "toolErrorBg" : "toolSuccessBg";
|
|
251
|
+
}
|
|
252
|
+
/** One repaint when the hold expires; nothing keeps ticking after that. */
|
|
253
|
+
#hold(delay) {
|
|
254
|
+
if (this.#holdTimer !== undefined)
|
|
255
|
+
return;
|
|
256
|
+
this.#holdTimer = setTimeout(() => {
|
|
257
|
+
this.#holdTimer = undefined;
|
|
258
|
+
this.#redraw();
|
|
259
|
+
}, delay);
|
|
260
|
+
}
|
|
261
|
+
#stopHold() {
|
|
262
|
+
if (this.#holdTimer === undefined)
|
|
263
|
+
return;
|
|
264
|
+
clearTimeout(this.#holdTimer);
|
|
265
|
+
this.#holdTimer = undefined;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Release the block's own lifetime. The projection drops a block when it is evicted, when
|
|
269
|
+
* grouping is disabled, or when the patch unloads; an idempotent dispose keeps that path honest,
|
|
270
|
+
* so a removed block can never ask for a repaint.
|
|
271
|
+
*/
|
|
272
|
+
dispose() {
|
|
273
|
+
this.#stopHold();
|
|
274
|
+
}
|
|
131
275
|
#usage() {
|
|
132
276
|
let requests = 0;
|
|
133
277
|
let input = 0;
|
|
134
278
|
let output = 0;
|
|
135
|
-
let
|
|
136
|
-
let
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
continue;
|
|
279
|
+
let cacheRead = 0;
|
|
280
|
+
let cacheWrite = 0;
|
|
281
|
+
let latestPrompt;
|
|
282
|
+
for (const fact of this.#requests) {
|
|
140
283
|
requests += 1;
|
|
141
|
-
|
|
284
|
+
const usage = fact.usage;
|
|
285
|
+
if (usage === undefined)
|
|
142
286
|
continue;
|
|
143
|
-
input +=
|
|
144
|
-
output +=
|
|
145
|
-
|
|
146
|
-
|
|
287
|
+
input += usage.input;
|
|
288
|
+
output += usage.output;
|
|
289
|
+
cacheRead += usage.cacheRead;
|
|
290
|
+
cacheWrite += usage.cacheWrite;
|
|
291
|
+
// A streaming snapshot can arrive with every field zeroed; that is a placeholder, not a
|
|
292
|
+
// measured prompt, so it must not overwrite the last known cache-hit fact.
|
|
293
|
+
if (usage.input + usage.cacheRead + usage.cacheWrite > 0)
|
|
294
|
+
latestPrompt = usage;
|
|
147
295
|
}
|
|
148
|
-
return { requests, input, output,
|
|
296
|
+
return { requests, input, output, cacheRead, cacheWrite, latestPrompt };
|
|
149
297
|
}
|
|
150
|
-
/**
|
|
298
|
+
/**
|
|
299
|
+
* The block footer: the block's request count, then the same token facts and the same symbols
|
|
300
|
+
* Pi's own footer uses (`↑input ↓output RcacheRead WcacheWrite CHrate`). Pi totals the whole
|
|
301
|
+
* session; this totals one block, so the two never have to agree on the number.
|
|
302
|
+
*/
|
|
151
303
|
#footerRow() {
|
|
152
304
|
const theme = this.#ui.theme;
|
|
153
305
|
const usage = this.#usage();
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
306
|
+
const parts = [`requests ×${usage.requests}`];
|
|
307
|
+
if (usage.input + usage.output + usage.cacheRead + usage.cacheWrite === 0) {
|
|
308
|
+
parts.push("↑—", "↓—");
|
|
309
|
+
return theme.fg("dim", parts.join(" · "));
|
|
310
|
+
}
|
|
311
|
+
if (usage.input > 0)
|
|
312
|
+
parts.push(`↑${formatTokens(usage.input)}`);
|
|
313
|
+
if (usage.output > 0)
|
|
314
|
+
parts.push(`↓${formatTokens(usage.output)}`);
|
|
315
|
+
if (usage.cacheRead > 0)
|
|
316
|
+
parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
317
|
+
if (usage.cacheWrite > 0)
|
|
318
|
+
parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
319
|
+
if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
|
|
320
|
+
const rate = cacheHitRate(usage.latestPrompt);
|
|
321
|
+
if (rate !== undefined)
|
|
322
|
+
parts.push(`CH${rate}`);
|
|
323
|
+
}
|
|
162
324
|
return theme.fg("dim", parts.join(" · "));
|
|
163
325
|
}
|
|
164
326
|
#categoryRow(category, label, detail) {
|
|
165
327
|
const theme = this.#ui.theme;
|
|
166
|
-
const
|
|
328
|
+
const open = this.#category === category;
|
|
329
|
+
const pin = `${theme.fg("dim", open ? "▾" : "▸")} `;
|
|
167
330
|
const tail = detail === "" ? "" : `${theme.fg("dim", " · ")}${theme.fg("dim", detail)}`;
|
|
168
|
-
|
|
331
|
+
// The open category is the block's focus point; the arrow still carries the state alone.
|
|
332
|
+
return `${pin}${theme.fg(open ? "accent" : "text", label)}${tail}`;
|
|
169
333
|
}
|
|
170
334
|
#renderBody(width) {
|
|
171
335
|
if (this.#renderedExpanded)
|
|
172
336
|
return this.#renderRaw(width);
|
|
173
337
|
return this.#renderFrame(width);
|
|
174
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* The whole block expanded: no group shell and no per-member fill, so every block shows only the
|
|
341
|
+
* styling its own component draws.
|
|
342
|
+
*/
|
|
175
343
|
#renderRaw(width) {
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const lines = [rail];
|
|
179
|
-
const hits = [{ height: 1, action: { kind: "none" } }];
|
|
344
|
+
const lines = [];
|
|
345
|
+
const hits = [];
|
|
180
346
|
for (const member of this.#members) {
|
|
181
|
-
const wrapper = this.#
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
347
|
+
const wrapper = this.#memberWrapper(member);
|
|
348
|
+
const own = wrapper.render(Math.max(1, width));
|
|
349
|
+
if (own.length === 0)
|
|
184
350
|
continue;
|
|
185
|
-
|
|
186
|
-
|
|
351
|
+
// One blank row between adjacent semantic blocks. A thinking run has no surface of its own,
|
|
352
|
+
// so it keeps the plain separator even when tools paint their own padded surfaces.
|
|
353
|
+
if (lines.length > 0) {
|
|
354
|
+
lines.push("");
|
|
355
|
+
hits.push({ height: 1, action: { kind: "none" } });
|
|
356
|
+
}
|
|
357
|
+
// The expanded group is transparent: every block keeps only the styling it draws itself.
|
|
358
|
+
for (const row of own)
|
|
187
359
|
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
360
|
+
hits.push({ height: own.length, action: { kind: "member", wrapper } });
|
|
188
361
|
}
|
|
189
|
-
lines.push(rail);
|
|
190
|
-
hits.push({ height: 1, action: { kind: "none" } });
|
|
191
362
|
this.#hits = hits;
|
|
192
363
|
return lines;
|
|
193
364
|
}
|
|
194
|
-
/** Header,
|
|
365
|
+
/** Header, the three categories, and the statistics footer; every row is one terminal row. */
|
|
195
366
|
#renderFrame(width) {
|
|
196
367
|
const theme = this.#ui.theme;
|
|
197
368
|
const lines = [];
|
|
@@ -208,10 +379,12 @@ export class ToolGroupView extends Container {
|
|
|
208
379
|
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
209
380
|
hits.push({ height: rows.length, action });
|
|
210
381
|
};
|
|
211
|
-
const
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
|
|
382
|
+
const tools = [];
|
|
383
|
+
for (const [index, member] of this.#members.entries()) {
|
|
384
|
+
if (member.kind === "tool")
|
|
385
|
+
tools.push(index);
|
|
386
|
+
}
|
|
387
|
+
push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
|
|
215
388
|
const notices = this.#noticeItems();
|
|
216
389
|
if (notices.length > 0) {
|
|
217
390
|
const kinds = new Map();
|
|
@@ -229,19 +402,25 @@ export class ToolGroupView extends Container {
|
|
|
229
402
|
}
|
|
230
403
|
const thinking = this.#thinkingItems();
|
|
231
404
|
if (thinking.length > 0) {
|
|
232
|
-
const tokens = Math.ceil(thinking.reduce((sum, member) => sum + (member.
|
|
233
|
-
push(this.#categoryRow("thinking",
|
|
405
|
+
const tokens = Math.ceil(thinking.reduce((sum, member) => sum + (member.thinking?.length ?? 0), 0) / 4);
|
|
406
|
+
push(this.#categoryRow("thinking", `thinking x${thinking.length}`, `${String(tokens)} tokens`), {
|
|
234
407
|
kind: "category",
|
|
235
408
|
category: "thinking",
|
|
236
409
|
});
|
|
237
410
|
if (this.#category === "thinking")
|
|
238
411
|
this.#renderThinking(thinking, width, push);
|
|
239
412
|
}
|
|
413
|
+
// Names and header calls share one eligibility: only completed calls are counted. A tool that
|
|
414
|
+
// is still streaming or running stays listed and expandable, but adds no number.
|
|
240
415
|
const counts = new Map();
|
|
416
|
+
let listed = 0;
|
|
241
417
|
for (const index of tools) {
|
|
242
418
|
const member = this.#members[index];
|
|
243
419
|
if (member === undefined)
|
|
244
420
|
continue;
|
|
421
|
+
listed += 1;
|
|
422
|
+
if (member.completed !== true)
|
|
423
|
+
continue;
|
|
245
424
|
if (member.children === undefined) {
|
|
246
425
|
if (member.title !== undefined)
|
|
247
426
|
counts.set(member.title, (counts.get(member.title) ?? 0) + 1);
|
|
@@ -250,99 +429,104 @@ export class ToolGroupView extends Container {
|
|
|
250
429
|
for (const child of member.children)
|
|
251
430
|
counts.set(child.title, (counts.get(child.title) ?? 0) + 1);
|
|
252
431
|
}
|
|
253
|
-
if (
|
|
432
|
+
if (listed > 0) {
|
|
254
433
|
const names = [...counts]
|
|
255
434
|
.map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
|
|
256
435
|
.join(theme.fg("dim", " · "));
|
|
257
|
-
push(this.#categoryRow("tools", names, ""), {
|
|
436
|
+
push(this.#categoryRow("tools", names === "" ? "tools" : names, ""), {
|
|
258
437
|
kind: "category",
|
|
259
438
|
category: "tools",
|
|
260
439
|
});
|
|
261
440
|
if (this.#category === "tools")
|
|
262
|
-
this.#renderTools(
|
|
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));
|
|
441
|
+
this.#renderTools(tools, width, push, pushBlock);
|
|
271
442
|
}
|
|
443
|
+
// The rails bracket the header, categories, and any opened detail; the footer sits below them.
|
|
444
|
+
push(theme.fg("borderMuted", "─".repeat(Math.max(0, width))));
|
|
445
|
+
// The footer is always the block's request and token totals: one row, never a tool status.
|
|
446
|
+
push(this.#footerRow());
|
|
272
447
|
this.#hits = hits;
|
|
273
448
|
return lines;
|
|
274
449
|
}
|
|
275
|
-
/**
|
|
276
|
-
|
|
450
|
+
/**
|
|
451
|
+
* Every tool at its own fold level, one blank row between calls. A container is one tool here too:
|
|
452
|
+
* its component draws its own board, and `children`/childCount only feed the counts.
|
|
453
|
+
*/
|
|
454
|
+
#renderTools(tools, width, push, pushBlock) {
|
|
277
455
|
let first = true;
|
|
278
|
-
const separate = () => {
|
|
279
|
-
if (!first)
|
|
280
|
-
push("");
|
|
281
|
-
first = false;
|
|
282
|
-
};
|
|
283
456
|
for (const memberIndex of tools) {
|
|
284
457
|
const member = this.#members[memberIndex];
|
|
285
458
|
if (member === undefined)
|
|
286
459
|
continue;
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
});
|
|
460
|
+
const wrapper = this.#offsetWrapper(member.component);
|
|
461
|
+
const rows = wrapper.render(Math.max(1, width));
|
|
462
|
+
if (rows.length === 0)
|
|
295
463
|
continue;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
}
|
|
464
|
+
if (!first)
|
|
465
|
+
push("");
|
|
466
|
+
first = false;
|
|
467
|
+
// One hit for the whole tool: its own rows carry the header, copy slot, and fold state.
|
|
468
|
+
pushBlock(rows, { kind: "member", wrapper });
|
|
311
469
|
}
|
|
312
470
|
}
|
|
313
|
-
/**
|
|
471
|
+
/**
|
|
472
|
+
* Thinking items. While the host hides thinking, an item is one row: its 20-grapheme content
|
|
473
|
+
* preview, which opens the full text. While thinking is visible, the full text is already there.
|
|
474
|
+
*/
|
|
314
475
|
#renderThinking(thinking, width, push) {
|
|
315
|
-
const theme = this.#ui.theme;
|
|
316
|
-
let first = true;
|
|
317
476
|
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
477
|
const text = member.thinking;
|
|
325
478
|
if (text === undefined)
|
|
326
479
|
continue;
|
|
327
|
-
if (
|
|
328
|
-
for (const row of this.#
|
|
480
|
+
if (member.thinkingHidden !== true) {
|
|
481
|
+
for (const row of this.#thinkingRows(text, width))
|
|
329
482
|
push(row);
|
|
330
483
|
continue;
|
|
331
484
|
}
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
push(
|
|
485
|
+
const open = this.#item === member.component;
|
|
486
|
+
if (!open) {
|
|
487
|
+
push(this.#thinkingPreviewRow(text, width), { kind: "item", member: member.component });
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
// The first row is still the item's own hit area, so a click folds it back.
|
|
491
|
+
for (const [index, row] of this.#thinkingRows(text, width).entries())
|
|
492
|
+
push(row, index === 0 ? { kind: "item", member: member.component } : { kind: "none" });
|
|
335
493
|
}
|
|
336
494
|
}
|
|
337
|
-
/**
|
|
495
|
+
/** One preview row: the grapheme head plus an ellipsis, then cut to the cells the rail leaves. */
|
|
496
|
+
#thinkingPreviewRow(text, width) {
|
|
497
|
+
const theme = this.#ui.theme;
|
|
498
|
+
const { head, more } = thinkingPreview(text);
|
|
499
|
+
const room = Math.max(1, width - 2);
|
|
500
|
+
const fitted = truncateToWidth(head, more ? Math.max(1, room - 1) : room, "…");
|
|
501
|
+
const preview = more && fitted === head ? `${head}…` : fitted;
|
|
502
|
+
return `${theme.fg("dim", "▎")} ${theme.fg("thinkingText", theme.italic(preview))}`;
|
|
503
|
+
}
|
|
504
|
+
/** Full thinking text: paragraphs keep their separation, every row wraps to the indent's width. */
|
|
505
|
+
#thinkingRows(text, width) {
|
|
506
|
+
const theme = this.#ui.theme;
|
|
507
|
+
const inner = Math.max(1, width - 2);
|
|
508
|
+
return toContentRows(text).flatMap((line) => {
|
|
509
|
+
const wrapped = wrapTextWithAnsi(line, inner);
|
|
510
|
+
return (wrapped.length > 0 ? wrapped : [""]).map((row) => row === "" ? "" : ` ${theme.fg("thinkingText", theme.italic(row))}`);
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Notices print the content their own view lays out: the preview is directly visible, and only a
|
|
515
|
+
* real body adds a pin. A plain notify therefore appears once, indented, with no item action.
|
|
516
|
+
*/
|
|
338
517
|
#renderNotices(notices, width, push) {
|
|
339
518
|
const theme = this.#ui.theme;
|
|
340
519
|
for (const member of notices) {
|
|
341
520
|
const detail = member.detail;
|
|
342
|
-
const open = detail !== undefined && this.#item
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
521
|
+
const open = detail !== undefined && this.#item === member.component;
|
|
522
|
+
if (member.summary !== undefined) {
|
|
523
|
+
const pin = detail === undefined ? "" : `${theme.fg("dim", open ? "▾" : "▸")} `;
|
|
524
|
+
push(`${pin}${member.summary}`, detail === undefined ? { kind: "none" } : { kind: "item", member: member.component });
|
|
525
|
+
}
|
|
526
|
+
if (member.content !== undefined)
|
|
527
|
+
for (const row of member.content(width))
|
|
528
|
+
push(row);
|
|
529
|
+
if (!open || detail === undefined)
|
|
346
530
|
continue;
|
|
347
531
|
for (const row of detail(width))
|
|
348
532
|
push(row);
|
|
@@ -368,13 +552,7 @@ export class ToolGroupView extends Container {
|
|
|
368
552
|
return { handled: true, render: true };
|
|
369
553
|
}
|
|
370
554
|
if (action.kind === "item") {
|
|
371
|
-
|
|
372
|
-
this.#item = same
|
|
373
|
-
? undefined
|
|
374
|
-
: {
|
|
375
|
-
member: action.member,
|
|
376
|
-
...(action.child === undefined ? {} : { child: action.child }),
|
|
377
|
-
};
|
|
555
|
+
this.#item = this.#item === action.member ? undefined : action.member;
|
|
378
556
|
this.#redraw();
|
|
379
557
|
return { handled: true, render: true };
|
|
380
558
|
}
|
|
@@ -406,20 +584,58 @@ export class ToolGroupView extends Container {
|
|
|
406
584
|
};
|
|
407
585
|
}
|
|
408
586
|
/**
|
|
409
|
-
*
|
|
410
|
-
*
|
|
587
|
+
* A member's rows for the raw view: every original part in order, without Pi's leading separator
|
|
588
|
+
* and with exactly one blank row between parts (a thinking run keeps its paragraph separation).
|
|
411
589
|
*/
|
|
412
|
-
#
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
590
|
+
#memberWrapper(member) {
|
|
591
|
+
const parts = memberParts(member);
|
|
592
|
+
let offsets = [];
|
|
593
|
+
return {
|
|
594
|
+
render: (width) => {
|
|
595
|
+
const rows = [];
|
|
596
|
+
const next = [];
|
|
597
|
+
for (const part of parts) {
|
|
598
|
+
const all = part.render(width);
|
|
599
|
+
const skip = all[0] === "" ? 1 : 0;
|
|
600
|
+
const own = all.slice(skip);
|
|
601
|
+
if (own.length === 0)
|
|
602
|
+
continue;
|
|
603
|
+
if (rows.length > 0)
|
|
604
|
+
rows.push("");
|
|
605
|
+
next.push({ part, top: rows.length, height: own.length, skip });
|
|
606
|
+
rows.push(...own);
|
|
607
|
+
}
|
|
608
|
+
offsets = next;
|
|
609
|
+
return rows;
|
|
610
|
+
},
|
|
611
|
+
handleMouse(event) {
|
|
612
|
+
for (const { part, top, height, skip } of offsets) {
|
|
613
|
+
if (event.y < top || event.y >= top + height)
|
|
614
|
+
continue;
|
|
615
|
+
return part.handleMouse?.({
|
|
616
|
+
...event,
|
|
617
|
+
y: event.y - top + skip,
|
|
618
|
+
height: height + skip,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
return undefined;
|
|
622
|
+
},
|
|
623
|
+
invalidate() { },
|
|
624
|
+
};
|
|
418
625
|
}
|
|
419
|
-
|
|
626
|
+
/**
|
|
627
|
+
* `clock` belongs to this continuous block. Restored history has none until live content joins it.
|
|
628
|
+
*
|
|
629
|
+
* `tail` is the block's last visible unit, which the projection draws outside the group; its calls,
|
|
630
|
+
* requests, and background state still belong to this block.
|
|
631
|
+
*/
|
|
632
|
+
update(members, tail, requests, clock) {
|
|
420
633
|
this.#members = members;
|
|
421
|
-
this.#
|
|
422
|
-
|
|
634
|
+
this.#tail = tail;
|
|
635
|
+
this.#requests = requests;
|
|
636
|
+
if (this.#clock === undefined)
|
|
637
|
+
this.#clock = clock;
|
|
638
|
+
if (this.#item !== undefined && !members.some((m) => m.component === this.#item))
|
|
423
639
|
this.#item = undefined;
|
|
424
640
|
if (this.#category === "thinking" && this.#thinkingItems().length === 0)
|
|
425
641
|
this.#category = undefined;
|
|
@@ -443,16 +659,30 @@ export class ToolGroupView extends Container {
|
|
|
443
659
|
}
|
|
444
660
|
this.#renderedExpanded = this.#raw || globalExpanded;
|
|
445
661
|
const theme = this.#ui.theme;
|
|
662
|
+
this.#syncShell();
|
|
663
|
+
this.#box.setBgFn(backgroundFill(theme, this.#surfaceToken()));
|
|
446
664
|
const calls = this.#calls();
|
|
447
|
-
const elapsed = this.#
|
|
665
|
+
const elapsed = this.#clock === undefined
|
|
448
666
|
? "—"
|
|
449
|
-
: formatElapsed((this.#
|
|
667
|
+
: formatElapsed((this.#clock.endedAt ?? performance.now()) - this.#clock.startedAt);
|
|
450
668
|
const details = [
|
|
451
|
-
theme.fg("
|
|
669
|
+
theme.fg("text", "collapse views"),
|
|
452
670
|
theme.fg("dim", elapsed),
|
|
453
671
|
theme.fg("dim", `${calls} ${calls === 1 ? "call" : "calls"}`),
|
|
454
672
|
].join(theme.fg("dim", " · "));
|
|
455
673
|
this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width), "…"));
|
|
456
674
|
return super.render(width);
|
|
457
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* The group shell follows the fold state: a background block around the aggregate, or transparent
|
|
678
|
+
* rows when the whole block is expanded. Only the shell changes; members keep their components.
|
|
679
|
+
*/
|
|
680
|
+
#syncShell() {
|
|
681
|
+
const shell = this.#renderedExpanded ? "raw" : "collapsed";
|
|
682
|
+
if (this.#shell === shell)
|
|
683
|
+
return;
|
|
684
|
+
this.#shell = shell;
|
|
685
|
+
this.children =
|
|
686
|
+
shell === "raw" ? [this.#spacer, this.#heading, this.#body] : [this.#spacer, this.#box];
|
|
687
|
+
}
|
|
458
688
|
}
|