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