@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
package/dist/tool-view.d.ts
CHANGED
|
@@ -11,8 +11,12 @@ export interface RailSection {
|
|
|
11
11
|
export interface ToolViewHeaderSpec {
|
|
12
12
|
readonly title: string;
|
|
13
13
|
readonly status?: ToolStatusKind | undefined;
|
|
14
|
+
/** The call's own text. Plain unless `summaryStyled` says the tool layer themed it already. */
|
|
14
15
|
readonly summary?: string | undefined;
|
|
15
|
-
|
|
16
|
+
/** The summary already carries its semantic tokens; the frame must not recolor it. */
|
|
17
|
+
readonly summaryStyled?: boolean | undefined;
|
|
18
|
+
/** Secondary facts after the summary: duration, timeout. Always dim. */
|
|
19
|
+
readonly meta?: string | undefined;
|
|
16
20
|
}
|
|
17
21
|
export interface ToolViewCopySpec {
|
|
18
22
|
readonly primary: string;
|
|
@@ -23,11 +27,21 @@ export interface Collapsible {
|
|
|
23
27
|
collapseAll(): void;
|
|
24
28
|
}
|
|
25
29
|
export declare function isCollapsible(value: unknown): value is Collapsible;
|
|
30
|
+
/** One nested call inside a container, reduced to what a summary row and its count need. */
|
|
31
|
+
export interface ToolViewChild {
|
|
32
|
+
readonly title: string;
|
|
33
|
+
readonly summary?: string | undefined;
|
|
34
|
+
readonly status?: ToolStatusKind | undefined;
|
|
35
|
+
readonly durationMs?: number | undefined;
|
|
36
|
+
}
|
|
26
37
|
export interface ToolViewSpec {
|
|
27
38
|
readonly theme: Theme;
|
|
28
39
|
readonly header: ToolViewHeaderSpec;
|
|
29
40
|
readonly collapsedRows?: readonly string[] | Component | (Component & Collapsible) | undefined;
|
|
30
41
|
readonly sections?: readonly RailSection[] | undefined;
|
|
42
|
+
readonly children?: readonly ToolViewChild[] | undefined;
|
|
43
|
+
/** Nested calls the tool made, when the list above is only what fits. */
|
|
44
|
+
readonly childCount?: number | undefined;
|
|
31
45
|
readonly collapsibleChildren?: readonly Collapsible[] | undefined;
|
|
32
46
|
readonly copy?: ToolViewCopySpec | undefined;
|
|
33
47
|
readonly stateHolder?: unknown;
|
|
@@ -40,14 +54,24 @@ export interface ToolViewSpec {
|
|
|
40
54
|
readonly copyFeedbackDurationMs?: number | undefined;
|
|
41
55
|
readonly noRail?: boolean | undefined;
|
|
42
56
|
readonly railToken?: RailColorToken | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Pi-native surface: success/error fill plus one padding row, and no leading rail. Read per render,
|
|
59
|
+
* so a settings change reaches a view the host already holds.
|
|
60
|
+
*/
|
|
61
|
+
readonly background?: (() => boolean) | undefined;
|
|
43
62
|
}
|
|
44
63
|
/** Public composition entry point for extensions that need the shared view behavior. */
|
|
45
64
|
export declare function createToolView(spec: ToolViewSpec): ToolView;
|
|
46
65
|
export declare const MAX_LEVEL2_SECTION_LINES = 500;
|
|
47
66
|
export declare const MAX_LEVEL2_BYTES: number;
|
|
48
|
-
/** Internal transcript composition: original components keep their layout and interactions. */
|
|
49
|
-
export declare function renderWithoutToolRails(component: Component, width: number): string[];
|
|
50
67
|
export declare function transitionFoldLevel(current: FoldLevel, ctrlKey: boolean): FoldLevel;
|
|
68
|
+
export declare const TOOL_SPINNER_FRAMES: readonly ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
|
69
|
+
export declare const TOOL_SPINNER_INTERVAL_MS = 100;
|
|
70
|
+
/**
|
|
71
|
+
* One clock for every live tool, so the phase is shared and never restarts: streaming args and a
|
|
72
|
+
* running execution draw the same frame and differ only in color.
|
|
73
|
+
*/
|
|
74
|
+
export declare function toolSpinnerFrame(now?: number): string;
|
|
51
75
|
export declare function formatStatusGlyph(status: ToolStatusKind | undefined, theme: Theme): string;
|
|
52
76
|
/**
|
|
53
77
|
* The glyph, name, and summary of one tool execution, colored the way every tool row colors them.
|
|
@@ -62,7 +86,8 @@ export declare function formatToolHeaderText(parts: {
|
|
|
62
86
|
readonly status?: ToolStatusKind | undefined;
|
|
63
87
|
readonly title: string;
|
|
64
88
|
readonly summary?: string | undefined;
|
|
65
|
-
readonly
|
|
89
|
+
readonly summaryStyled?: boolean | undefined;
|
|
90
|
+
readonly meta?: string | undefined;
|
|
66
91
|
}, theme: Theme): string;
|
|
67
92
|
/**
|
|
68
93
|
* Collapses text that must occupy one row: header titles and summaries, board rows, and inline
|
|
@@ -87,6 +112,10 @@ export declare class ToolView implements Component {
|
|
|
87
112
|
get collapsed(): boolean;
|
|
88
113
|
/** Current semantic header, without fold/copy controls or body; hidden slots have no summary. */
|
|
89
114
|
get summary(): string | undefined;
|
|
115
|
+
/** Nested calls this tool reported; a hidden slot reports none. */
|
|
116
|
+
get children(): readonly ToolViewChild[] | undefined;
|
|
117
|
+
/** How many nested calls the tool made, even when `children` lists only what fits. */
|
|
118
|
+
get childCount(): number | undefined;
|
|
90
119
|
setFoldLevel(next: FoldLevel): void;
|
|
91
120
|
setCollapsed(collapsed: boolean): void;
|
|
92
121
|
toggle(ctrlKey?: boolean): void;
|
package/dist/tool-view.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { copyToClipboard as copyToSystemClipboard, } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
4
|
+
import { backgroundFill } from "./background.js";
|
|
5
|
+
/** Pi native tool surfaces: a failure outranks work in progress, which outranks a settled tool. */
|
|
6
|
+
function nativeSurface(status) {
|
|
7
|
+
if (status === "error")
|
|
8
|
+
return "toolErrorBg";
|
|
9
|
+
if (status === "running" || status === "streaming")
|
|
10
|
+
return "toolPendingBg";
|
|
11
|
+
return "toolSuccessBg";
|
|
12
|
+
}
|
|
4
13
|
export function isCollapsible(value) {
|
|
5
14
|
return (typeof value === "object" &&
|
|
6
15
|
value !== null &&
|
|
@@ -13,22 +22,12 @@ export function createToolView(spec) {
|
|
|
13
22
|
}
|
|
14
23
|
const FOLD_LEVEL_STATE_KEY = "__piExtUiFoldLevel";
|
|
15
24
|
const COPIED_UNTIL_STATE_KEY = "__piExtUiCopiedUntil";
|
|
25
|
+
const COPY_FAILED_STATE_KEY = "__piExtUiCopyFailed";
|
|
16
26
|
const COPY_FEEDBACK_TIMER_KEY = "__piExtUiCopyTimer";
|
|
17
27
|
const DEFAULT_MAX_SECTION_LINES = 9;
|
|
18
28
|
export const MAX_LEVEL2_SECTION_LINES = 500;
|
|
19
29
|
export const MAX_LEVEL2_BYTES = 64 * 1024;
|
|
20
30
|
const DEFAULT_COPY_FEEDBACK_MS = 1200;
|
|
21
|
-
let outerRailDepth = 0;
|
|
22
|
-
/** Internal transcript composition: original components keep their layout and interactions. */
|
|
23
|
-
export function renderWithoutToolRails(component, width) {
|
|
24
|
-
outerRailDepth++;
|
|
25
|
-
try {
|
|
26
|
-
return component.render(width);
|
|
27
|
-
}
|
|
28
|
-
finally {
|
|
29
|
-
outerRailDepth--;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
31
|
function isRecord(value) {
|
|
33
32
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
33
|
}
|
|
@@ -61,12 +60,28 @@ function isCopiedActive(stateHolder, localCopiedUntil) {
|
|
|
61
60
|
const until = stateHolder[COPIED_UNTIL_STATE_KEY];
|
|
62
61
|
return typeof until === "number" && until > now;
|
|
63
62
|
}
|
|
63
|
+
/** A rejected write is feedback of its own; it must not leave the success mark on screen. */
|
|
64
|
+
function isCopyFailed(stateHolder, localFailed) {
|
|
65
|
+
if (localFailed)
|
|
66
|
+
return true;
|
|
67
|
+
return isRecord(stateHolder) && stateHolder[COPY_FAILED_STATE_KEY] === true;
|
|
68
|
+
}
|
|
69
|
+
export const TOOL_SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
|
70
|
+
export const TOOL_SPINNER_INTERVAL_MS = 100;
|
|
71
|
+
/**
|
|
72
|
+
* One clock for every live tool, so the phase is shared and never restarts: streaming args and a
|
|
73
|
+
* running execution draw the same frame and differ only in color.
|
|
74
|
+
*/
|
|
75
|
+
export function toolSpinnerFrame(now = performance.now()) {
|
|
76
|
+
const at = Math.floor(now / TOOL_SPINNER_INTERVAL_MS) % TOOL_SPINNER_FRAMES.length;
|
|
77
|
+
return TOOL_SPINNER_FRAMES[at] ?? "·";
|
|
78
|
+
}
|
|
64
79
|
export function formatStatusGlyph(status, theme) {
|
|
65
80
|
switch (status ?? "ok") {
|
|
66
81
|
case "streaming":
|
|
67
|
-
return theme.fg("muted",
|
|
82
|
+
return theme.fg("muted", toolSpinnerFrame());
|
|
68
83
|
case "running":
|
|
69
|
-
return theme.fg("accent",
|
|
84
|
+
return theme.fg("accent", toolSpinnerFrame());
|
|
70
85
|
case "ok":
|
|
71
86
|
return theme.fg("success", "✓");
|
|
72
87
|
case "warn":
|
|
@@ -91,9 +106,20 @@ export function formatToolHeaderText(parts, theme) {
|
|
|
91
106
|
const title = toSingleRow(parts.title);
|
|
92
107
|
const titlePart = theme.fg("toolTitle", theme.bold(title));
|
|
93
108
|
const summaryRaw = parts.summary === undefined ? "" : toSingleRow(parts.summary);
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
// A command is the row's content, not its caption: bash states it the way the native shell row
|
|
110
|
+
// does. A summary the tool layer already themed keeps its own tokens.
|
|
111
|
+
const summaryPart = summaryRaw === ""
|
|
112
|
+
? ""
|
|
113
|
+
: parts.summaryStyled === true
|
|
114
|
+
? ` ${summaryRaw}`
|
|
115
|
+
: title === "bash"
|
|
116
|
+
? ` ${theme.fg("toolTitle", theme.bold(summaryRaw))}`
|
|
117
|
+
: ` ${theme.fg("dim", summaryRaw)}`;
|
|
118
|
+
const metaRaw = parts.meta === undefined ? "" : toSingleRow(parts.meta);
|
|
119
|
+
const metaPart = metaRaw === ""
|
|
120
|
+
? ""
|
|
121
|
+
: `${summaryPart === "" ? " " : theme.fg("dim", " · ")}${theme.fg("dim", metaRaw)}`;
|
|
122
|
+
return `${statusPart === "" ? "" : `${statusPart} `}${titlePart}${summaryPart}${metaPart}`;
|
|
97
123
|
}
|
|
98
124
|
const truncate = (text, width) => truncateToWidth(text, width, "…");
|
|
99
125
|
/**
|
|
@@ -217,10 +243,13 @@ export class ToolView {
|
|
|
217
243
|
#spec;
|
|
218
244
|
#localLevel;
|
|
219
245
|
#localCopiedUntil = 0;
|
|
246
|
+
#localCopyFailed = false;
|
|
220
247
|
#localTimer;
|
|
221
248
|
#lastWidth = 80;
|
|
222
249
|
#lastHeight = 0;
|
|
223
250
|
#lastNoRail = false;
|
|
251
|
+
/** Rows the native surface adds above the header. */
|
|
252
|
+
#lastTopPad = 0;
|
|
224
253
|
#cache;
|
|
225
254
|
constructor(spec) {
|
|
226
255
|
this.#spec = spec;
|
|
@@ -233,12 +262,15 @@ export class ToolView {
|
|
|
233
262
|
const until = spec.stateHolder[COPIED_UNTIL_STATE_KEY];
|
|
234
263
|
if (typeof until === "number" && until > Date.now()) {
|
|
235
264
|
this.#localCopiedUntil = until;
|
|
265
|
+
this.#localCopyFailed = spec.stateHolder[COPY_FAILED_STATE_KEY] === true;
|
|
236
266
|
const remaining = until - Date.now();
|
|
237
267
|
if (!spec.stateHolder[COPY_FEEDBACK_TIMER_KEY]) {
|
|
238
268
|
const timer = setTimeout(() => {
|
|
239
269
|
this.#localCopiedUntil = 0;
|
|
270
|
+
this.#localCopyFailed = false;
|
|
240
271
|
if (isRecord(this.#spec.stateHolder)) {
|
|
241
272
|
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
|
|
273
|
+
this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = false;
|
|
242
274
|
this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
|
|
243
275
|
}
|
|
244
276
|
this.#localTimer = undefined;
|
|
@@ -268,6 +300,14 @@ export class ToolView {
|
|
|
268
300
|
get summary() {
|
|
269
301
|
return this.#hidden ? undefined : formatToolHeaderText(this.#spec.header, this.#spec.theme);
|
|
270
302
|
}
|
|
303
|
+
/** Nested calls this tool reported; a hidden slot reports none. */
|
|
304
|
+
get children() {
|
|
305
|
+
return this.#hidden ? undefined : this.#spec.children;
|
|
306
|
+
}
|
|
307
|
+
/** How many nested calls the tool made, even when `children` lists only what fits. */
|
|
308
|
+
get childCount() {
|
|
309
|
+
return this.#hidden ? undefined : this.#spec.childCount;
|
|
310
|
+
}
|
|
271
311
|
#collapseChildren() {
|
|
272
312
|
const { collapsedRows, collapsibleChildren } = this.#spec;
|
|
273
313
|
if (collapsibleChildren !== undefined) {
|
|
@@ -306,24 +346,34 @@ export class ToolView {
|
|
|
306
346
|
: copySpec.primary;
|
|
307
347
|
const writeClip = this.#spec.copyToClipboard ?? copyToSystemClipboard;
|
|
308
348
|
const durationMs = this.#spec.copyFeedbackDurationMs ?? DEFAULT_COPY_FEEDBACK_MS;
|
|
349
|
+
// Feedback starts when the write settles. Marking it before the promise made a rejected
|
|
350
|
+
// clipboard write look exactly like a successful copy.
|
|
351
|
+
void writeClip(payload).then(() => this.#showCopyFeedback(durationMs, false), () => this.#showCopyFeedback(durationMs, true));
|
|
352
|
+
}
|
|
353
|
+
/** One feedback window in the copy slot: ✓ when the text reached the clipboard, ✗ when it did not. */
|
|
354
|
+
#showCopyFeedback(durationMs, failed) {
|
|
309
355
|
const until = Date.now() + durationMs;
|
|
310
|
-
this.#localCopiedUntil = until;
|
|
356
|
+
this.#localCopiedUntil = failed ? 0 : until;
|
|
357
|
+
this.#localCopyFailed = failed;
|
|
311
358
|
if (isRecord(this.#spec.stateHolder)) {
|
|
312
359
|
const prevTimer = this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY];
|
|
313
360
|
if (prevTimer !== undefined) {
|
|
314
361
|
clearTimeout(prevTimer);
|
|
315
362
|
}
|
|
316
|
-
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] =
|
|
363
|
+
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = this.#localCopiedUntil;
|
|
364
|
+
this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = failed;
|
|
317
365
|
}
|
|
318
366
|
if (this.#localTimer !== undefined) {
|
|
319
367
|
clearTimeout(this.#localTimer);
|
|
320
368
|
}
|
|
321
|
-
|
|
369
|
+
this.#cache = undefined;
|
|
322
370
|
this.#spec.invalidate?.();
|
|
323
371
|
const timer = setTimeout(() => {
|
|
324
372
|
this.#localCopiedUntil = 0;
|
|
373
|
+
this.#localCopyFailed = false;
|
|
325
374
|
if (isRecord(this.#spec.stateHolder)) {
|
|
326
375
|
this.#spec.stateHolder[COPIED_UNTIL_STATE_KEY] = 0;
|
|
376
|
+
this.#spec.stateHolder[COPY_FAILED_STATE_KEY] = false;
|
|
327
377
|
this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = undefined;
|
|
328
378
|
}
|
|
329
379
|
this.#localTimer = undefined;
|
|
@@ -345,8 +395,13 @@ export class ToolView {
|
|
|
345
395
|
if (copy === undefined || safeWidth < 6) {
|
|
346
396
|
return truncate(leftContent, safeWidth);
|
|
347
397
|
}
|
|
348
|
-
const
|
|
349
|
-
const
|
|
398
|
+
const failed = isCopyFailed(this.#spec.stateHolder, this.#localCopyFailed);
|
|
399
|
+
const copied = !failed && isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
|
|
400
|
+
const copyIcon = failed
|
|
401
|
+
? theme.fg("error", "✗")
|
|
402
|
+
: copied
|
|
403
|
+
? theme.fg("success", "✓")
|
|
404
|
+
: theme.fg("dim", "");
|
|
350
405
|
// Leave 1 column on the right for Pi's fullscreen scrollbar so the copy icon doesn't collide
|
|
351
406
|
const rightMargin = 1;
|
|
352
407
|
const copySlotWidth = 2 + rightMargin;
|
|
@@ -374,7 +429,7 @@ export class ToolView {
|
|
|
374
429
|
const safeWidth = Math.max(1, width - pad * 2);
|
|
375
430
|
const level = this.effectiveLevel;
|
|
376
431
|
const { theme, collapsedRows, sections } = this.#spec;
|
|
377
|
-
const noRail = options?.noRail ?? (
|
|
432
|
+
const noRail = options?.noRail ?? (this.#spec.noRail === true || this.#spec.background?.() === true);
|
|
378
433
|
const innerWidth = noRail ? safeWidth : Math.max(1, safeWidth - 2);
|
|
379
434
|
const output = [];
|
|
380
435
|
if (level === 0) {
|
|
@@ -392,11 +447,14 @@ export class ToolView {
|
|
|
392
447
|
return output;
|
|
393
448
|
}
|
|
394
449
|
for (const section of sections) {
|
|
450
|
+
const sectionOutput = [];
|
|
395
451
|
const rail = noRail ? "" : `${theme.fg(section.railToken, "▎")} `;
|
|
396
452
|
const { rows: rawLines, isByteTruncated, totalBytes, } = resolveRows(section.content, innerWidth, toContentRows);
|
|
397
453
|
const cap = level === 1 ? (section.maxLines ?? DEFAULT_MAX_SECTION_LINES) : MAX_LEVEL2_SECTION_LINES;
|
|
398
454
|
const sectionRows = [];
|
|
399
455
|
let logicalLinesCount = 0;
|
|
456
|
+
// Wrapped rows the last shown line still has: a single long line is not "1 more line".
|
|
457
|
+
let cutChunks = 0;
|
|
400
458
|
for (const line of rawLines) {
|
|
401
459
|
const wrapped = wrapTextWithAnsi(line, innerWidth);
|
|
402
460
|
const chunks = wrapped.length > 0 ? wrapped : [""];
|
|
@@ -408,21 +466,36 @@ export class ToolView {
|
|
|
408
466
|
sectionRows.push(chunk);
|
|
409
467
|
}
|
|
410
468
|
}
|
|
469
|
+
// Only a line that started showing is "continues"; a fully omitted one is a whole line.
|
|
470
|
+
cutChunks = canTake > 0 ? chunks.length - canTake : 0;
|
|
411
471
|
break;
|
|
412
472
|
}
|
|
413
473
|
sectionRows.push(...chunks);
|
|
414
474
|
logicalLinesCount++;
|
|
415
475
|
}
|
|
416
|
-
|
|
476
|
+
// The line that broke the cap is not an omitted whole line: its hidden part is `cutChunks`.
|
|
477
|
+
const omittedCount = rawLines.length - logicalLinesCount - (cutChunks > 0 ? 1 : 0);
|
|
478
|
+
// The hint names the real way to the hidden text; without a copy it must not promise one.
|
|
479
|
+
const copyHint = this.#spec.copy?.full !== undefined && this.#spec.copy.full !== ""
|
|
480
|
+
? ", shift+click copies all"
|
|
481
|
+
: "";
|
|
417
482
|
for (const row of sectionRows) {
|
|
418
|
-
|
|
483
|
+
sectionOutput.push(truncate(rail + row, safeWidth));
|
|
419
484
|
}
|
|
420
|
-
if (omittedCount > 0 || isByteTruncated) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
485
|
+
if (omittedCount > 0 || cutChunks > 0 || isByteTruncated) {
|
|
486
|
+
let text;
|
|
487
|
+
if (isByteTruncated) {
|
|
488
|
+
text = `… [Output truncated: showing 64KB of ${Math.round(totalBytes / 1024)}KB${copyHint}]`;
|
|
489
|
+
}
|
|
490
|
+
else if (omittedCount > 0) {
|
|
491
|
+
text = `… (${String(omittedCount)} more lines${cutChunks > 0 ? ", current line continues" : ""}${copyHint})`;
|
|
492
|
+
}
|
|
493
|
+
else {
|
|
494
|
+
text = `… (current line continues${copyHint})`;
|
|
495
|
+
}
|
|
496
|
+
sectionOutput.push(truncate(rail + theme.fg("dim", text), safeWidth));
|
|
425
497
|
}
|
|
498
|
+
output.push(...sectionOutput);
|
|
426
499
|
}
|
|
427
500
|
return output;
|
|
428
501
|
}
|
|
@@ -431,11 +504,14 @@ export class ToolView {
|
|
|
431
504
|
return [];
|
|
432
505
|
const level = this.effectiveLevel;
|
|
433
506
|
const copied = isCopiedActive(this.#spec.stateHolder, this.#localCopiedUntil);
|
|
434
|
-
const
|
|
507
|
+
const native = this.#spec.background?.() === true;
|
|
508
|
+
const surface = native ? nativeSurface(this.#spec.header.status) : undefined;
|
|
509
|
+
const noRail = this.#spec.noRail === true || native;
|
|
435
510
|
if (this.#cache !== undefined &&
|
|
436
511
|
this.#cache.width === width &&
|
|
437
512
|
this.#cache.level === level &&
|
|
438
513
|
this.#cache.noRail === noRail &&
|
|
514
|
+
this.#cache.surface === surface &&
|
|
439
515
|
this.#cache.copied === copied) {
|
|
440
516
|
return this.#cache.lines;
|
|
441
517
|
}
|
|
@@ -460,9 +536,13 @@ export class ToolView {
|
|
|
460
536
|
safeLines.push(leftPad !== "" ? `${leftPad}${rawLine}` : rawLine);
|
|
461
537
|
}
|
|
462
538
|
}
|
|
463
|
-
this.#
|
|
464
|
-
|
|
465
|
-
|
|
539
|
+
this.#lastTopPad = surface === undefined ? 0 : 1;
|
|
540
|
+
const lines = surface === undefined
|
|
541
|
+
? safeLines
|
|
542
|
+
: ["", ...safeLines, ""].map((line) => backgroundFill(this.#spec.theme, surface)(line + " ".repeat(Math.max(0, width - visibleWidth(line)))));
|
|
543
|
+
this.#lastHeight = lines.length;
|
|
544
|
+
this.#cache = { width, level, copied, noRail, surface, lines };
|
|
545
|
+
return lines;
|
|
466
546
|
}
|
|
467
547
|
handleMouse(event) {
|
|
468
548
|
if (event.type === "wheel" || this.#hidden) {
|
|
@@ -483,7 +563,8 @@ export class ToolView {
|
|
|
483
563
|
if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight)) {
|
|
484
564
|
return undefined;
|
|
485
565
|
}
|
|
486
|
-
|
|
566
|
+
const row = event.y - this.#lastTopPad;
|
|
567
|
+
if (row === 0) {
|
|
487
568
|
if (this.#spec.copy !== undefined &&
|
|
488
569
|
innerWidth >= 6 &&
|
|
489
570
|
innerX >= innerWidth - 3 &&
|
|
@@ -505,7 +586,7 @@ export class ToolView {
|
|
|
505
586
|
const railOffset = this.#lastNoRail ? 0 : 2;
|
|
506
587
|
const subEvent = {
|
|
507
588
|
...event,
|
|
508
|
-
y:
|
|
589
|
+
y: row - 1,
|
|
509
590
|
x: Math.max(0, innerX - railOffset),
|
|
510
591
|
width: Math.max(1, innerWidth - railOffset),
|
|
511
592
|
};
|
package/dist/tracker.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
/** The public read-only session view the lifecycle context hands an extension. */
|
|
3
|
+
type SessionReadSource = ExtensionContext["sessionManager"];
|
|
2
4
|
export interface CapturedToolCall {
|
|
3
5
|
readonly id: string;
|
|
4
6
|
readonly toolName: string;
|
|
7
|
+
/** Positive resolution fact, when the host's `tool_call` hook reported this call. */
|
|
8
|
+
readonly resolved?: boolean | undefined;
|
|
5
9
|
readonly args?: unknown;
|
|
10
|
+
/** The request existed but was too large to keep; the bytes are not recorded. */
|
|
11
|
+
readonly argsTruncated?: boolean | undefined;
|
|
12
|
+
readonly argsBytes?: number | undefined;
|
|
6
13
|
readonly result?: AgentToolResult<unknown> | undefined;
|
|
7
14
|
readonly isError?: boolean | undefined;
|
|
8
15
|
readonly durationMs?: number | undefined;
|
|
@@ -25,21 +32,33 @@ export declare class ToolExecutionTracker {
|
|
|
25
32
|
parentToolCallId?: string | undefined;
|
|
26
33
|
}): void;
|
|
27
34
|
getCaptured(parentToolCallId: string | undefined): readonly CapturedToolCall[] | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The host's `tool_call` hook runs after the tool resolved and its arguments validated, so a
|
|
37
|
+
* recorded id is a positive fact. Nothing is recorded for a call the hook never saw: an unknown
|
|
38
|
+
* tool, an invalid argument set, a blocked or cancelled call all stay unknown.
|
|
39
|
+
*/
|
|
40
|
+
recordResolved(event: {
|
|
41
|
+
toolCallId: string;
|
|
42
|
+
parentToolCallId?: string | undefined;
|
|
43
|
+
}): void;
|
|
44
|
+
getResolved(parentToolCallId: string | undefined): ReadonlySet<string> | undefined;
|
|
28
45
|
clear(): void;
|
|
29
46
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
/** What the session branch says about one nested call's request. */
|
|
48
|
+
export interface NestedCallArgsFact {
|
|
49
|
+
readonly args: unknown;
|
|
50
|
+
/** The host kept the size but not the arguments, so the request is not recoverable. */
|
|
51
|
+
readonly truncated: boolean;
|
|
52
|
+
readonly bytes?: number | undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The arguments a resumed session saved for its nested calls. One index belongs to one runtime: it
|
|
56
|
+
* follows that runtime's session, and shutting the runtime down or switching sessions drops it.
|
|
57
|
+
*/
|
|
58
|
+
export declare class NestedCallArgsIndex {
|
|
59
|
+
#private;
|
|
60
|
+
/** The session owns the index: switching, reloading or shutting down replaces it. */
|
|
61
|
+
setSource(manager: SessionReadSource | undefined): void;
|
|
62
|
+
args(toolCallId: string | undefined): NestedCallArgsFact | undefined;
|
|
63
|
+
}
|
|
64
|
+
export {};
|
package/dist/tracker.js
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
const MAX_STORED_PARENTS = 200;
|
|
2
|
+
/** A live capture keeps the request whole or not at all; a partial request would be a lie. */
|
|
2
3
|
const MAX_CAPTURED_PAYLOAD_CHARS = 32_768;
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
/** Host records at most this many nested calls per model-issued call. */
|
|
5
|
+
const MAX_RESOLVED_CALLS = 256;
|
|
6
|
+
/** Recovered arguments for at most this many calls; beyond it a row says it is not recorded. */
|
|
7
|
+
const MAX_INDEXED_CALLS = 512;
|
|
8
|
+
/**
|
|
9
|
+
* The captured request, or the fact that it was too large to keep. Nothing is rewritten: a caller
|
|
10
|
+
* either has the bytes the model sent or knows they are not recorded.
|
|
11
|
+
*/
|
|
12
|
+
function capturedArgs(args) {
|
|
13
|
+
let serialized;
|
|
14
|
+
try {
|
|
15
|
+
serialized = JSON.stringify(args);
|
|
8
16
|
}
|
|
9
|
-
|
|
17
|
+
catch {
|
|
18
|
+
// A value the host could not serialize is kept as-is; its size is unknown, not zero.
|
|
19
|
+
return { args };
|
|
20
|
+
}
|
|
21
|
+
if (serialized === undefined || serialized.length <= MAX_CAPTURED_PAYLOAD_CHARS)
|
|
22
|
+
return { args };
|
|
23
|
+
return { argsTruncated: true, argsBytes: serialized.length };
|
|
10
24
|
}
|
|
11
25
|
export class ToolExecutionTracker {
|
|
12
26
|
#capturedByParentId = new Map();
|
|
27
|
+
#resolvedByParentId = new Map();
|
|
13
28
|
recordStart(event) {
|
|
14
29
|
if (!event.parentToolCallId)
|
|
15
30
|
return;
|
|
@@ -27,7 +42,7 @@ export class ToolExecutionTracker {
|
|
|
27
42
|
const entry = {
|
|
28
43
|
id: event.toolCallId,
|
|
29
44
|
toolName: event.toolName,
|
|
30
|
-
|
|
45
|
+
...capturedArgs(event.args),
|
|
31
46
|
};
|
|
32
47
|
if (idx >= 0) {
|
|
33
48
|
list[idx] = { ...list[idx], ...entry };
|
|
@@ -77,20 +92,143 @@ export class ToolExecutionTracker {
|
|
|
77
92
|
return undefined;
|
|
78
93
|
return this.#capturedByParentId.get(parentToolCallId);
|
|
79
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* The host's `tool_call` hook runs after the tool resolved and its arguments validated, so a
|
|
97
|
+
* recorded id is a positive fact. Nothing is recorded for a call the hook never saw: an unknown
|
|
98
|
+
* tool, an invalid argument set, a blocked or cancelled call all stay unknown.
|
|
99
|
+
*/
|
|
100
|
+
recordResolved(event) {
|
|
101
|
+
const parent = event.parentToolCallId;
|
|
102
|
+
if (!parent)
|
|
103
|
+
return;
|
|
104
|
+
let ids = this.#resolvedByParentId.get(parent);
|
|
105
|
+
if (!ids) {
|
|
106
|
+
if (this.#resolvedByParentId.size >= MAX_STORED_PARENTS) {
|
|
107
|
+
const firstKey = this.#resolvedByParentId.keys().next().value;
|
|
108
|
+
if (firstKey !== undefined)
|
|
109
|
+
this.#resolvedByParentId.delete(firstKey);
|
|
110
|
+
}
|
|
111
|
+
ids = new Set();
|
|
112
|
+
this.#resolvedByParentId.set(parent, ids);
|
|
113
|
+
}
|
|
114
|
+
if (ids.size >= MAX_RESOLVED_CALLS)
|
|
115
|
+
return;
|
|
116
|
+
ids.add(event.toolCallId);
|
|
117
|
+
}
|
|
118
|
+
getResolved(parentToolCallId) {
|
|
119
|
+
if (!parentToolCallId)
|
|
120
|
+
return undefined;
|
|
121
|
+
return this.#resolvedByParentId.get(parentToolCallId);
|
|
122
|
+
}
|
|
80
123
|
clear() {
|
|
81
124
|
this.#capturedByParentId.clear();
|
|
125
|
+
this.#resolvedByParentId.clear();
|
|
82
126
|
}
|
|
83
127
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
export
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
128
|
+
/**
|
|
129
|
+
* The arguments a resumed session saved for its nested calls. One index belongs to one runtime: it
|
|
130
|
+
* follows that runtime's session, and shutting the runtime down or switching sessions drops it.
|
|
131
|
+
*/
|
|
132
|
+
export class NestedCallArgsIndex {
|
|
133
|
+
#source;
|
|
134
|
+
/** The leaf the index is complete up to; undefined means no index exists yet. */
|
|
135
|
+
#indexedLeaf;
|
|
136
|
+
#argsById = new Map();
|
|
137
|
+
/** The session owns the index: switching, reloading or shutting down replaces it. */
|
|
138
|
+
setSource(manager) {
|
|
139
|
+
this.#source = manager;
|
|
140
|
+
this.#reset();
|
|
141
|
+
}
|
|
142
|
+
args(toolCallId) {
|
|
143
|
+
const manager = this.#source;
|
|
144
|
+
if (manager === undefined || toolCallId === undefined)
|
|
145
|
+
return undefined;
|
|
146
|
+
try {
|
|
147
|
+
this.#refresh(manager);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
this.#reset();
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return this.#argsById.get(toolCallId);
|
|
154
|
+
}
|
|
155
|
+
#reset() {
|
|
156
|
+
this.#argsById.clear();
|
|
157
|
+
this.#indexedLeaf = undefined;
|
|
158
|
+
}
|
|
159
|
+
#remember(id, fact) {
|
|
160
|
+
if (!this.#argsById.has(id) && this.#argsById.size >= MAX_INDEXED_CALLS) {
|
|
161
|
+
const firstKey = this.#argsById.keys().next().value;
|
|
162
|
+
if (firstKey !== undefined)
|
|
163
|
+
this.#argsById.delete(firstKey);
|
|
164
|
+
}
|
|
165
|
+
this.#argsById.set(id, fact);
|
|
166
|
+
}
|
|
167
|
+
/** The host saves a bounded record on the parent's tool result; this reads it back by call id. */
|
|
168
|
+
#scan(entry) {
|
|
169
|
+
if (typeof entry !== "object" || entry === null)
|
|
170
|
+
return;
|
|
171
|
+
const record = entry;
|
|
172
|
+
if (record.type !== "message")
|
|
173
|
+
return;
|
|
174
|
+
const message = record.message;
|
|
175
|
+
if (typeof message !== "object" || message === null)
|
|
176
|
+
return;
|
|
177
|
+
const toolResult = message;
|
|
178
|
+
if (toolResult.role !== "toolResult")
|
|
179
|
+
return;
|
|
180
|
+
const nested = toolResult.nestedCalls;
|
|
181
|
+
if (typeof nested !== "object" || nested === null)
|
|
182
|
+
return;
|
|
183
|
+
const calls = nested.calls;
|
|
184
|
+
if (!Array.isArray(calls))
|
|
185
|
+
return;
|
|
186
|
+
for (const call of calls) {
|
|
187
|
+
if (typeof call !== "object" || call === null)
|
|
188
|
+
continue;
|
|
189
|
+
const saved = call;
|
|
190
|
+
if (typeof saved.id !== "string")
|
|
191
|
+
continue;
|
|
192
|
+
if (saved.arguments !== undefined) {
|
|
193
|
+
this.#remember(saved.id, { args: saved.arguments, truncated: false });
|
|
194
|
+
}
|
|
195
|
+
else if (typeof saved.argumentsBytes === "number") {
|
|
196
|
+
this.#remember(saved.id, {
|
|
197
|
+
args: undefined,
|
|
198
|
+
truncated: true,
|
|
199
|
+
bytes: saved.argumentsBytes,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Extend the index to the current leaf. An append walks only the new entries up to the previous
|
|
206
|
+
* leaf; a fork, branch switch or reload has no such chain and rebuilds. A render never scans the
|
|
207
|
+
* whole branch once the index is current, and a read failure is not cached as an empty answer.
|
|
208
|
+
*/
|
|
209
|
+
#refresh(manager) {
|
|
210
|
+
const leaf = manager.getLeafId();
|
|
211
|
+
if (leaf === this.#indexedLeaf)
|
|
212
|
+
return;
|
|
213
|
+
if (leaf !== null && this.#indexedLeaf !== undefined) {
|
|
214
|
+
const chain = [];
|
|
215
|
+
let current = leaf;
|
|
216
|
+
while (current !== null &&
|
|
217
|
+
current !== this.#indexedLeaf &&
|
|
218
|
+
chain.length <= MAX_INDEXED_CALLS) {
|
|
219
|
+
chain.push(current);
|
|
220
|
+
current = manager.getEntry(current)?.parentId ?? null;
|
|
221
|
+
}
|
|
222
|
+
if (current === this.#indexedLeaf) {
|
|
223
|
+
for (const id of chain.reverse())
|
|
224
|
+
this.#scan(manager.getEntry(id));
|
|
225
|
+
this.#indexedLeaf = leaf;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
this.#argsById.clear();
|
|
230
|
+
for (const entry of manager.getBranch())
|
|
231
|
+
this.#scan(entry);
|
|
232
|
+
this.#indexedLeaf = leaf;
|
|
233
|
+
}
|
|
96
234
|
}
|