@hheei/pi-ext-ui 0.5.2 → 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 +71 -7
- package/dist/host-transcript-patch.d.ts +2 -0
- package/dist/host-transcript-patch.js +258 -181
- package/dist/index.d.ts +6 -4
- package/dist/index.js +5 -3
- package/dist/message-view.d.ts +6 -0
- package/dist/message-view.js +38 -8
- 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 +98 -99
- 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 +280 -174
- 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 +89 -0
- package/dist/tool-group-view.js +645 -0
- package/dist/tool-view.d.ts +33 -4
- package/dist/tool-view.js +116 -35
- package/dist/tracker.d.ts +36 -17
- package/dist/tracker.js +157 -19
- package/package.json +2 -2
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { Box, Container, MouseRegion, Spacer, Text, truncateToWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { fitRow } from "@hheei/pi-ext-core";
|
|
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
|
+
}
|
|
51
|
+
function formatElapsed(milliseconds) {
|
|
52
|
+
const total = Math.max(0, Math.floor(milliseconds / 1000));
|
|
53
|
+
const seconds = total % 60;
|
|
54
|
+
const minutes = Math.floor(total / 60) % 60;
|
|
55
|
+
const hours = Math.floor(total / 3600);
|
|
56
|
+
if (hours > 0)
|
|
57
|
+
return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
|
|
58
|
+
if (minutes > 0)
|
|
59
|
+
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
60
|
+
return `${seconds}s`;
|
|
61
|
+
}
|
|
62
|
+
/** The same decimal steps the Pi footer uses, so two places never disagree about a token count. */
|
|
63
|
+
function formatTokens(count) {
|
|
64
|
+
if (count < 1000)
|
|
65
|
+
return String(count);
|
|
66
|
+
if (count < 10_000)
|
|
67
|
+
return `${(count / 1000).toFixed(1)}k`;
|
|
68
|
+
if (count < 1_000_000)
|
|
69
|
+
return `${String(Math.round(count / 1000))}k`;
|
|
70
|
+
if (count < 10_000_000)
|
|
71
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
72
|
+
return `${String(Math.round(count / 1_000_000))}M`;
|
|
73
|
+
}
|
|
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)
|
|
81
|
+
return "—";
|
|
82
|
+
const prompt = latestPrompt.input + latestPrompt.cacheRead + latestPrompt.cacheWrite;
|
|
83
|
+
return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
|
84
|
+
}
|
|
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 };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The collapsed frame of one activity block.
|
|
106
|
+
*
|
|
107
|
+
* The frame owns only layout and hit testing: tool rows keep their own component so their glyph,
|
|
108
|
+
* colors, copy action, and fold level stay the ones the transcript already draws. Anything this
|
|
109
|
+
* view synthesized itself would drift from those rows.
|
|
110
|
+
*/
|
|
111
|
+
export class ToolGroupView extends Container {
|
|
112
|
+
#header = new Text("", 0, 0);
|
|
113
|
+
#ui;
|
|
114
|
+
#redraw;
|
|
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. */
|
|
118
|
+
#members = [];
|
|
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 = [];
|
|
123
|
+
#raw = false;
|
|
124
|
+
#category;
|
|
125
|
+
#item;
|
|
126
|
+
#renderedExpanded = false;
|
|
127
|
+
#shell = "collapsed";
|
|
128
|
+
/** When the block last became quiet; the surface hold counts from here. */
|
|
129
|
+
#settledAt;
|
|
130
|
+
#holdTimer;
|
|
131
|
+
#hits = [];
|
|
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) {
|
|
138
|
+
super();
|
|
139
|
+
this.#ui = ui;
|
|
140
|
+
this.#redraw = redraw;
|
|
141
|
+
this.#heading = new MouseRegion(this.#header, (event) => {
|
|
142
|
+
if (event.type !== "click" || event.button !== "left")
|
|
143
|
+
return undefined;
|
|
144
|
+
this.#toggleRaw();
|
|
145
|
+
return { handled: true, render: true };
|
|
146
|
+
});
|
|
147
|
+
this.#body = {
|
|
148
|
+
render: (width) => this.#renderBody(width),
|
|
149
|
+
handleMouse: (event) => this.#hit(event),
|
|
150
|
+
invalidate() { },
|
|
151
|
+
};
|
|
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];
|
|
157
|
+
}
|
|
158
|
+
#toggleRaw() {
|
|
159
|
+
this.#raw = this.#ui.getToolsExpanded() ? false : !this.#raw;
|
|
160
|
+
if (this.#raw) {
|
|
161
|
+
this.#category = undefined;
|
|
162
|
+
this.#item = undefined;
|
|
163
|
+
for (const member of this.#members) {
|
|
164
|
+
if (isCollapsible(member.component))
|
|
165
|
+
member.component.collapseAll();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
this.#redraw();
|
|
169
|
+
}
|
|
170
|
+
#thinkingItems() {
|
|
171
|
+
return this.#members.filter((member) => member.thinking !== undefined && member.thinking !== "");
|
|
172
|
+
}
|
|
173
|
+
#noticeItems() {
|
|
174
|
+
return this.#members.filter((member) => member.kind === "notice");
|
|
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
|
+
*/
|
|
180
|
+
#calls() {
|
|
181
|
+
let total = 0;
|
|
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)
|
|
185
|
+
continue;
|
|
186
|
+
// The count is a fact the tool reports; a bounded child list must not lower it.
|
|
187
|
+
total += member.childCount ?? (member.children === undefined ? 1 : member.children.length);
|
|
188
|
+
}
|
|
189
|
+
return total;
|
|
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
|
+
}
|
|
249
|
+
#usage() {
|
|
250
|
+
let requests = 0;
|
|
251
|
+
let input = 0;
|
|
252
|
+
let output = 0;
|
|
253
|
+
let measured = false;
|
|
254
|
+
let latestPrompt;
|
|
255
|
+
for (const fact of this.#requests) {
|
|
256
|
+
requests += 1;
|
|
257
|
+
const usage = fact.usage;
|
|
258
|
+
if (usage === undefined)
|
|
259
|
+
continue;
|
|
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;
|
|
267
|
+
}
|
|
268
|
+
return { requests, measured, input, output, latestPrompt };
|
|
269
|
+
}
|
|
270
|
+
/** The block footer: the whole block's request and token totals, fixed under the body. */
|
|
271
|
+
#footerRow() {
|
|
272
|
+
const theme = this.#ui.theme;
|
|
273
|
+
const usage = this.#usage();
|
|
274
|
+
const input = usage.measured ? formatTokens(usage.input) : "—";
|
|
275
|
+
const output = usage.measured ? formatTokens(usage.output) : "—";
|
|
276
|
+
const parts = [
|
|
277
|
+
`requests ×${usage.requests}`,
|
|
278
|
+
`IN ${input}`,
|
|
279
|
+
`OUT ${output}`,
|
|
280
|
+
`CH ${cacheHitRate(usage.latestPrompt)}`,
|
|
281
|
+
];
|
|
282
|
+
return theme.fg("dim", parts.join(" · "));
|
|
283
|
+
}
|
|
284
|
+
#categoryRow(category, label, detail) {
|
|
285
|
+
const theme = this.#ui.theme;
|
|
286
|
+
const open = this.#category === category;
|
|
287
|
+
const pin = `${theme.fg("dim", open ? "▾" : "▸")} `;
|
|
288
|
+
const tail = detail === "" ? "" : `${theme.fg("dim", " · ")}${theme.fg("dim", detail)}`;
|
|
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}`;
|
|
291
|
+
}
|
|
292
|
+
#renderBody(width) {
|
|
293
|
+
if (this.#renderedExpanded)
|
|
294
|
+
return this.#renderRaw(width);
|
|
295
|
+
return this.#renderFrame(width);
|
|
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
|
+
*/
|
|
301
|
+
#renderRaw(width) {
|
|
302
|
+
const lines = [];
|
|
303
|
+
const hits = [];
|
|
304
|
+
for (const member of this.#members) {
|
|
305
|
+
const wrapper = this.#memberWrapper(member);
|
|
306
|
+
const own = wrapper.render(Math.max(1, width));
|
|
307
|
+
if (own.length === 0)
|
|
308
|
+
continue;
|
|
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)
|
|
316
|
+
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
317
|
+
hits.push({ height: own.length, action: { kind: "member", wrapper } });
|
|
318
|
+
}
|
|
319
|
+
this.#hits = hits;
|
|
320
|
+
return lines;
|
|
321
|
+
}
|
|
322
|
+
/** Header, the three categories, and the statistics footer; every row is one terminal row. */
|
|
323
|
+
#renderFrame(width) {
|
|
324
|
+
const theme = this.#ui.theme;
|
|
325
|
+
const lines = [];
|
|
326
|
+
const hits = [];
|
|
327
|
+
const push = (row, action = { kind: "none" }) => {
|
|
328
|
+
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
329
|
+
hits.push({ height: 1, action });
|
|
330
|
+
};
|
|
331
|
+
/** Rows that belong to one component keep one hit so its own geometry stays correct. */
|
|
332
|
+
const pushBlock = (rows, action) => {
|
|
333
|
+
if (rows.length === 0)
|
|
334
|
+
return;
|
|
335
|
+
for (const row of rows)
|
|
336
|
+
lines.push(fitRow(row, Math.max(0, width), "…"));
|
|
337
|
+
hits.push({ height: rows.length, action });
|
|
338
|
+
};
|
|
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))));
|
|
345
|
+
const notices = this.#noticeItems();
|
|
346
|
+
if (notices.length > 0) {
|
|
347
|
+
const kinds = new Map();
|
|
348
|
+
for (const member of notices) {
|
|
349
|
+
const kind = member.title ?? "notice";
|
|
350
|
+
kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
|
|
351
|
+
}
|
|
352
|
+
const detail = [...kinds].map(([kind, count]) => `${kind} ×${count}`).join(" · ");
|
|
353
|
+
push(this.#categoryRow("notices", "notice", detail), {
|
|
354
|
+
kind: "category",
|
|
355
|
+
category: "notices",
|
|
356
|
+
});
|
|
357
|
+
if (this.#category === "notices")
|
|
358
|
+
this.#renderNotices(notices, width, push);
|
|
359
|
+
}
|
|
360
|
+
const thinking = this.#thinkingItems();
|
|
361
|
+
if (thinking.length > 0) {
|
|
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`), {
|
|
364
|
+
kind: "category",
|
|
365
|
+
category: "thinking",
|
|
366
|
+
});
|
|
367
|
+
if (this.#category === "thinking")
|
|
368
|
+
this.#renderThinking(thinking, width, push);
|
|
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.
|
|
372
|
+
const counts = new Map();
|
|
373
|
+
let listed = 0;
|
|
374
|
+
for (const index of tools) {
|
|
375
|
+
const member = this.#members[index];
|
|
376
|
+
if (member === undefined)
|
|
377
|
+
continue;
|
|
378
|
+
listed += 1;
|
|
379
|
+
if (member.completed !== true)
|
|
380
|
+
continue;
|
|
381
|
+
if (member.children === undefined) {
|
|
382
|
+
if (member.title !== undefined)
|
|
383
|
+
counts.set(member.title, (counts.get(member.title) ?? 0) + 1);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
for (const child of member.children)
|
|
387
|
+
counts.set(child.title, (counts.get(child.title) ?? 0) + 1);
|
|
388
|
+
}
|
|
389
|
+
if (listed > 0) {
|
|
390
|
+
const names = [...counts]
|
|
391
|
+
.map(([name, count]) => `${theme.fg("toolTitle", theme.bold(name))} ${theme.fg("dim", `×${count}`)}`)
|
|
392
|
+
.join(theme.fg("dim", " · "));
|
|
393
|
+
push(this.#categoryRow("tools", names === "" ? "tools" : names, ""), {
|
|
394
|
+
kind: "category",
|
|
395
|
+
category: "tools",
|
|
396
|
+
});
|
|
397
|
+
if (this.#category === "tools")
|
|
398
|
+
this.#renderTools(tools, width, push, pushBlock);
|
|
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());
|
|
404
|
+
this.#hits = hits;
|
|
405
|
+
return lines;
|
|
406
|
+
}
|
|
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) {
|
|
412
|
+
let first = true;
|
|
413
|
+
for (const memberIndex of tools) {
|
|
414
|
+
const member = this.#members[memberIndex];
|
|
415
|
+
if (member === undefined)
|
|
416
|
+
continue;
|
|
417
|
+
const wrapper = this.#offsetWrapper(member.component);
|
|
418
|
+
const rows = wrapper.render(Math.max(1, width));
|
|
419
|
+
if (rows.length === 0)
|
|
420
|
+
continue;
|
|
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 });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
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
|
+
*/
|
|
432
|
+
#renderThinking(thinking, width, push) {
|
|
433
|
+
for (const member of thinking) {
|
|
434
|
+
const text = member.thinking;
|
|
435
|
+
if (text === undefined)
|
|
436
|
+
continue;
|
|
437
|
+
if (member.thinkingHidden !== true) {
|
|
438
|
+
for (const row of this.#thinkingRows(text, width))
|
|
439
|
+
push(row);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
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" });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
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
|
+
*/
|
|
474
|
+
#renderNotices(notices, width, push) {
|
|
475
|
+
const theme = this.#ui.theme;
|
|
476
|
+
for (const member of notices) {
|
|
477
|
+
const detail = member.detail;
|
|
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)
|
|
487
|
+
continue;
|
|
488
|
+
for (const row of detail(width))
|
|
489
|
+
push(row);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
#hit(event) {
|
|
493
|
+
let y = event.y;
|
|
494
|
+
for (const hit of this.#hits) {
|
|
495
|
+
if (y >= hit.height) {
|
|
496
|
+
y -= hit.height;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
const action = hit.action;
|
|
500
|
+
if (action.kind === "none")
|
|
501
|
+
return undefined;
|
|
502
|
+
if (event.type !== "click" || event.button !== "left")
|
|
503
|
+
return undefined;
|
|
504
|
+
if (action.kind === "category") {
|
|
505
|
+
const open = this.#category === action.category;
|
|
506
|
+
this.#category = open ? undefined : action.category;
|
|
507
|
+
this.#item = undefined;
|
|
508
|
+
this.#redraw();
|
|
509
|
+
return { handled: true, render: true };
|
|
510
|
+
}
|
|
511
|
+
if (action.kind === "item") {
|
|
512
|
+
this.#item = this.#item === action.member ? undefined : action.member;
|
|
513
|
+
this.#redraw();
|
|
514
|
+
return { handled: true, render: true };
|
|
515
|
+
}
|
|
516
|
+
return action.wrapper.handleMouse?.({ ...event, y, height: hit.height });
|
|
517
|
+
}
|
|
518
|
+
return undefined;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* The member's own rows without Pi's leading separator. Built only for a member the current frame
|
|
522
|
+
* actually draws, so a collapsed category allocates nothing for the rows it hides.
|
|
523
|
+
*/
|
|
524
|
+
#offsetWrapper(component) {
|
|
525
|
+
let offset = 0;
|
|
526
|
+
return {
|
|
527
|
+
render(width) {
|
|
528
|
+
const rows = component.render(width);
|
|
529
|
+
// Pi adds one leading separator per tool; the group supplies its own spacing.
|
|
530
|
+
offset = rows[0] === "" ? 1 : 0;
|
|
531
|
+
return rows.slice(offset);
|
|
532
|
+
},
|
|
533
|
+
handleMouse(event) {
|
|
534
|
+
return component.handleMouse?.({
|
|
535
|
+
...event,
|
|
536
|
+
y: event.y + offset,
|
|
537
|
+
height: event.height + offset,
|
|
538
|
+
});
|
|
539
|
+
},
|
|
540
|
+
invalidate() { },
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
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).
|
|
546
|
+
*/
|
|
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
|
+
};
|
|
582
|
+
}
|
|
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) {
|
|
590
|
+
this.#members = members;
|
|
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))
|
|
596
|
+
this.#item = undefined;
|
|
597
|
+
if (this.#category === "thinking" && this.#thinkingItems().length === 0)
|
|
598
|
+
this.#category = undefined;
|
|
599
|
+
if (this.#category === "notices" && this.#noticeItems().length === 0)
|
|
600
|
+
this.#category = undefined;
|
|
601
|
+
}
|
|
602
|
+
invalidate() {
|
|
603
|
+
// Original chatContainer.invalidate invalidates original members exactly once; only the header
|
|
604
|
+
// text belongs to this view.
|
|
605
|
+
this.#header.invalidate();
|
|
606
|
+
}
|
|
607
|
+
render(width) {
|
|
608
|
+
const globalExpanded = this.#ui.getToolsExpanded();
|
|
609
|
+
if (globalExpanded) {
|
|
610
|
+
// The host's forced expansion owns the frame; closing it returns to a fully closed group.
|
|
611
|
+
// A raw view the user opened by hand must not outlive it, or closing Ctrl+O would leave the
|
|
612
|
+
// block expanded with no way back to the aggregate.
|
|
613
|
+
this.#raw = false;
|
|
614
|
+
this.#category = undefined;
|
|
615
|
+
this.#item = undefined;
|
|
616
|
+
}
|
|
617
|
+
this.#renderedExpanded = this.#raw || globalExpanded;
|
|
618
|
+
const theme = this.#ui.theme;
|
|
619
|
+
this.#syncShell();
|
|
620
|
+
this.#box.setBgFn(backgroundFill(theme, this.#surfaceToken()));
|
|
621
|
+
const calls = this.#calls();
|
|
622
|
+
const elapsed = this.#clock === undefined
|
|
623
|
+
? "—"
|
|
624
|
+
: formatElapsed((this.#clock.endedAt ?? performance.now()) - this.#clock.startedAt);
|
|
625
|
+
const details = [
|
|
626
|
+
theme.fg("text", "collapse views"),
|
|
627
|
+
theme.fg("dim", elapsed),
|
|
628
|
+
theme.fg("dim", `${calls} ${calls === 1 ? "call" : "calls"}`),
|
|
629
|
+
].join(theme.fg("dim", " · "));
|
|
630
|
+
this.#header.setText(fitRow(`${theme.fg("dim", this.#renderedExpanded ? "▾" : "▸")} ${details}`, Math.max(0, width), "…"));
|
|
631
|
+
return super.render(width);
|
|
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
|
+
}
|
|
645
|
+
}
|