@hheei/pi-ext-ui 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/background.d.ts +7 -0
- package/dist/background.js +13 -0
- package/dist/extension.js +2 -36
- package/dist/host-transcript-patch.d.ts +1 -1
- package/dist/host-transcript-patch.js +17 -16
- package/dist/message-view.js +8 -3
- package/dist/nested-calls-view.js +30 -4
- package/dist/renderer.js +12 -8
- package/dist/tool-group-view.d.ts +2 -2
- package/dist/tool-group-view.js +81 -23
- package/dist/tool-view.d.ts +1 -1
- package/dist/tool-view.js +42 -11
- package/package.json +1 -1
package/dist/background.d.ts
CHANGED
|
@@ -9,3 +9,10 @@ import type { Theme, ThemeBg } from "@earendil-works/pi-coding-agent";
|
|
|
9
9
|
* one paints the rows it draws, not because this function tracks ANSI state.
|
|
10
10
|
*/
|
|
11
11
|
export declare function backgroundFill(theme: Theme, token: ThemeBg): (text: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* One independent surface: a filled padding row above and below, every row reaching `width`.
|
|
14
|
+
*
|
|
15
|
+
* A block that owns a surface also owns its breathing room, so two adjacent blocks separate with
|
|
16
|
+
* their own padding rows instead of sharing one unstyled gap.
|
|
17
|
+
*/
|
|
18
|
+
export declare function surfaceBlock(theme: Theme, token: ThemeBg, rows: readonly string[], width: number): string[];
|
package/dist/background.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
1
2
|
const MARK = "\u0000";
|
|
2
3
|
/** Any SGR sequence: parameters are `;`-separated, an empty parameter list means reset. */
|
|
3
4
|
const SGR = /\x1b\[([0-9;]*)m/g;
|
|
@@ -29,3 +30,15 @@ export function backgroundFill(theme, token) {
|
|
|
29
30
|
return `${out}${text.slice(from)}${end}`;
|
|
30
31
|
};
|
|
31
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* One independent surface: a filled padding row above and below, every row reaching `width`.
|
|
35
|
+
*
|
|
36
|
+
* A block that owns a surface also owns its breathing room, so two adjacent blocks separate with
|
|
37
|
+
* their own padding rows instead of sharing one unstyled gap.
|
|
38
|
+
*/
|
|
39
|
+
export function surfaceBlock(theme, token, rows, width) {
|
|
40
|
+
const fill = backgroundFill(theme, token);
|
|
41
|
+
const pad = fill(" ".repeat(Math.max(0, width)));
|
|
42
|
+
const body = rows.map((row) => fill(row + " ".repeat(Math.max(0, width - visibleWidth(row)))));
|
|
43
|
+
return [pad, ...body, pad];
|
|
44
|
+
}
|
package/dist/extension.js
CHANGED
|
@@ -4,7 +4,6 @@ import { createMessageView } from "./message-view.js";
|
|
|
4
4
|
import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
|
|
5
5
|
import { ToolUiRuntime } from "./runtime.js";
|
|
6
6
|
import { activityGroupsEnabled, createToolGroupSettings, toolBackgroundsEnabled, } from "./settings.js";
|
|
7
|
-
import { TOOL_SPINNER_INTERVAL_MS } from "./tool-view.js";
|
|
8
7
|
export default function (pi) {
|
|
9
8
|
// One runtime per extension instance: two Pi runtimes in one process never share these facts.
|
|
10
9
|
const runtime = new ToolUiRuntime();
|
|
@@ -27,7 +26,7 @@ export default function (pi) {
|
|
|
27
26
|
if (patch !== undefined)
|
|
28
27
|
return;
|
|
29
28
|
try {
|
|
30
|
-
patch = installHostToolGroups(context.extension);
|
|
29
|
+
patch = installHostToolGroups(context.extension, () => runtime.backgrounds);
|
|
31
30
|
redrawGroups = patch.requestRender;
|
|
32
31
|
}
|
|
33
32
|
catch (error) {
|
|
@@ -46,47 +45,14 @@ export default function (pi) {
|
|
|
46
45
|
patch?.beginRun();
|
|
47
46
|
redrawGroups?.();
|
|
48
47
|
});
|
|
49
|
-
// A live tool animates its status glyph. The ticker exists only while one is running, and the
|
|
50
|
-
// frame itself comes from one shared clock, so a phase never restarts on a status change.
|
|
51
|
-
let liveTools = 0;
|
|
52
|
-
let ticker;
|
|
53
|
-
const stopTicker = () => {
|
|
54
|
-
if (ticker === undefined)
|
|
55
|
-
return;
|
|
56
|
-
clearInterval(ticker);
|
|
57
|
-
ticker = undefined;
|
|
58
|
-
};
|
|
59
48
|
const onAgentEnd = pi.on("agent_end", () => {
|
|
60
49
|
patch?.endRun();
|
|
61
|
-
// No tool outlives the agent loop, so the animation cannot keep a ticker alive either.
|
|
62
|
-
liveTools = 0;
|
|
63
|
-
stopTicker();
|
|
64
50
|
redrawGroups?.();
|
|
65
51
|
});
|
|
66
|
-
|
|
67
|
-
liveTools += 1;
|
|
68
|
-
if (ticker !== undefined)
|
|
69
|
-
return;
|
|
70
|
-
ticker = setInterval(() => {
|
|
71
|
-
if (liveTools === 0 || redrawGroups === undefined) {
|
|
72
|
-
stopTicker();
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
redrawGroups();
|
|
76
|
-
}, TOOL_SPINNER_INTERVAL_MS);
|
|
77
|
-
});
|
|
78
|
-
const onToolEnd = pi.on("tool_execution_end", () => {
|
|
79
|
-
liveTools = Math.max(0, liveTools - 1);
|
|
80
|
-
if (liveTools === 0)
|
|
81
|
-
stopTicker();
|
|
82
|
-
});
|
|
52
|
+
// The live glyph clock lives with the rows that draw it, so nothing here ticks.
|
|
83
53
|
context.resources.add("run-clock", () => {
|
|
84
54
|
onAgentStart();
|
|
85
55
|
onAgentEnd();
|
|
86
|
-
onToolStart();
|
|
87
|
-
onToolEnd();
|
|
88
|
-
stopTicker();
|
|
89
|
-
liveTools = 0;
|
|
90
56
|
});
|
|
91
57
|
context.resources.add("tool-group-settings", registerSettings(provider, getRuntimeSettingsRegistry(pi)));
|
|
92
58
|
context.resources.add("tool-groups", () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
/** Private to pi-ext-ui: two process hooks, scoped by the actual host UI facade. */
|
|
3
|
-
export declare function installHostToolGroups(context: ExtensionContext): {
|
|
3
|
+
export declare function installHostToolGroups(context: ExtensionContext, background: () => boolean): {
|
|
4
4
|
dispose(): void;
|
|
5
5
|
requestRender(): void;
|
|
6
6
|
beginRun(): void;
|
|
@@ -182,7 +182,7 @@ class NotifyView {
|
|
|
182
182
|
this.#text.invalidate();
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
|
-
function attach(host, run) {
|
|
185
|
+
function attach(host, run, background) {
|
|
186
186
|
const chat = host.chat;
|
|
187
187
|
const original = {
|
|
188
188
|
render: chat.render,
|
|
@@ -266,6 +266,14 @@ function attach(host, run) {
|
|
|
266
266
|
*/
|
|
267
267
|
const flush = (ended, boundary) => {
|
|
268
268
|
const first = members[0]?.component;
|
|
269
|
+
// Empty assistant/tool-call placeholders do not replace the last visible tail.
|
|
270
|
+
const tail = ended
|
|
271
|
+
? undefined
|
|
272
|
+
: [...members]
|
|
273
|
+
.reverse()
|
|
274
|
+
.find((member) => member.kind !== "thinking" ||
|
|
275
|
+
(member.thinking !== undefined && member.thinking !== ""));
|
|
276
|
+
const inside = members.filter((member) => member !== tail);
|
|
269
277
|
let weight = 0;
|
|
270
278
|
for (const member of members) {
|
|
271
279
|
if (member.kind === "tool")
|
|
@@ -275,26 +283,19 @@ function attach(host, run) {
|
|
|
275
283
|
weight += 0.5;
|
|
276
284
|
}
|
|
277
285
|
if (first !== undefined && weight >= 2) {
|
|
278
|
-
const group = groups.get(first) ?? new ToolGroupView(host.ui, host.requestRender);
|
|
279
|
-
const tail = ended ? undefined : members.at(-1);
|
|
280
|
-
const inside = tail === undefined ? members : members.slice(0, -1);
|
|
286
|
+
const group = groups.get(first) ?? new ToolGroupView(host.ui, host.requestRender, background);
|
|
281
287
|
const boundaryTime = ended && boundary !== undefined
|
|
282
288
|
? (run.observed.get(boundary)?.at ?? performance.now())
|
|
283
289
|
: undefined;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
members: inside,
|
|
287
|
-
tail,
|
|
288
|
-
requests,
|
|
289
|
-
clock: clockOf(members, boundaryTime),
|
|
290
|
-
});
|
|
290
|
+
const clock = clockOf(members, boundaryTime);
|
|
291
|
+
blocks.push({ group, members: inside, tail, requests, clock });
|
|
291
292
|
next.set(first, group);
|
|
292
293
|
children.push(group);
|
|
293
|
-
if (tail !== undefined)
|
|
294
|
-
children.push(new TailUnit(tail));
|
|
295
294
|
}
|
|
296
295
|
else
|
|
297
|
-
children.push(...
|
|
296
|
+
children.push(...inside.flatMap(memberParts));
|
|
297
|
+
if (tail !== undefined)
|
|
298
|
+
children.push(new TailUnit(tail, background));
|
|
298
299
|
members = [];
|
|
299
300
|
requests = [];
|
|
300
301
|
};
|
|
@@ -512,7 +513,7 @@ function patchState() {
|
|
|
512
513
|
return state;
|
|
513
514
|
}
|
|
514
515
|
/** Private to pi-ext-ui: two process hooks, scoped by the actual host UI facade. */
|
|
515
|
-
export function installHostToolGroups(context) {
|
|
516
|
+
export function installHostToolGroups(context, background) {
|
|
516
517
|
const parts = VERSION.split(".").map(Number);
|
|
517
518
|
const [major = 0, minor = 0, patch = 0] = parts;
|
|
518
519
|
if (parts.length !== 3 ||
|
|
@@ -544,7 +545,7 @@ export function installHostToolGroups(context) {
|
|
|
544
545
|
ui,
|
|
545
546
|
hosts: new Map(),
|
|
546
547
|
failed: new WeakSet(),
|
|
547
|
-
attach: (host) => attach(host, run),
|
|
548
|
+
attach: (host) => attach(host, run, background),
|
|
548
549
|
};
|
|
549
550
|
state.owners.set(ui, owner);
|
|
550
551
|
// Reload rebuilds history before session_start; reconnect already discovered instances.
|
package/dist/message-view.js
CHANGED
|
@@ -69,8 +69,12 @@ export class MessageView {
|
|
|
69
69
|
* `customMessageBg` box when no renderer is installed, so this view supplies the same block
|
|
70
70
|
* itself. Inside an activity group the rows come from `previewRows`/`bodyRows` and keep the
|
|
71
71
|
* group's background instead.
|
|
72
|
+
*
|
|
73
|
+
* A surface that owns its rows also owns one padding row above and below, the same breathing room
|
|
74
|
+
* a Pi-native tool surface gives itself; without it the colored block is a tight strip of text.
|
|
72
75
|
*/
|
|
73
|
-
#
|
|
76
|
+
#padY = 1;
|
|
77
|
+
#shell = new Box(0, this.#padY);
|
|
74
78
|
constructor(spec) {
|
|
75
79
|
this.#spec = spec;
|
|
76
80
|
this.#localExpanded = remembered(spec.stateHolder, spec.defaultExpanded === true);
|
|
@@ -130,9 +134,10 @@ export class MessageView {
|
|
|
130
134
|
const innerWidth = Math.max(1, effectiveWidth - pad * 2);
|
|
131
135
|
if (innerX < 0 || innerX >= innerWidth)
|
|
132
136
|
return undefined;
|
|
133
|
-
if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight))
|
|
137
|
+
if (this.#lastHeight > 0 && (event.y < 0 || event.y >= this.#lastHeight + this.#padY))
|
|
134
138
|
return undefined;
|
|
135
|
-
|
|
139
|
+
// The shell's own padding rows belong to the surface, not to the header row.
|
|
140
|
+
if (event.y !== this.#padY)
|
|
136
141
|
return undefined;
|
|
137
142
|
this.toggle();
|
|
138
143
|
return { handled: true, render: true };
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import { copyToClipboard as copyToSystemClipboard, getLanguageFromPath, highlightCode, } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
3
3
|
import { extractChildToolCopyText, inlinePresentation, nativeRequestCopy } from "./native-tools.js";
|
|
4
|
-
import { formatToolHeaderText, ToolView, toContentRows, transitionFoldLevel, } from "./tool-view.js";
|
|
4
|
+
import { formatToolHeaderText, TOOL_SPINNER_INTERVAL_MS, ToolView, toContentRows, toolSpinnerFrame, transitionFoldLevel, } from "./tool-view.js";
|
|
5
5
|
function truncate(text, width) {
|
|
6
6
|
return truncateToWidth(text, Math.max(1, width));
|
|
7
7
|
}
|
|
8
8
|
function isRecord(value) {
|
|
9
9
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* A call without a final result is still running: its row carries the live spinner frame. A persisted
|
|
13
|
+
* record states its own status, so a recovered board never animates a call that already ended.
|
|
14
|
+
*/
|
|
15
|
+
function isLiveCall(call) {
|
|
16
|
+
return "status" in call
|
|
17
|
+
? call.status === "running"
|
|
18
|
+
: call.result === undefined;
|
|
19
|
+
}
|
|
11
20
|
/**
|
|
12
21
|
* A copy keeps the request half byte-exact and strips only what follows it: the result is display
|
|
13
22
|
* text, while the request is what the model sent and may legitimately contain escapes.
|
|
@@ -31,6 +40,7 @@ export class NestedCallsView {
|
|
|
31
40
|
#copiedIndex = undefined;
|
|
32
41
|
#failedIndex = undefined;
|
|
33
42
|
#copiedTimer = undefined;
|
|
43
|
+
#liveTimer = undefined;
|
|
34
44
|
#lineMapping = [];
|
|
35
45
|
#renderCache;
|
|
36
46
|
constructor(props) {
|
|
@@ -191,14 +201,30 @@ export class NestedCallsView {
|
|
|
191
201
|
if (state !== undefined)
|
|
192
202
|
delete state[NESTED_CALLS_COPIED_KEY];
|
|
193
203
|
}
|
|
204
|
+
/** A live child row owns the next frame; a settled board asks for none. */
|
|
205
|
+
#requestNextFrame() {
|
|
206
|
+
if (this.#liveTimer !== undefined)
|
|
207
|
+
return;
|
|
208
|
+
this.#liveTimer = setTimeout(() => {
|
|
209
|
+
this.#liveTimer = undefined;
|
|
210
|
+
this.#props.invalidate?.();
|
|
211
|
+
}, TOOL_SPINNER_INTERVAL_MS);
|
|
212
|
+
this.#liveTimer.unref?.();
|
|
213
|
+
}
|
|
194
214
|
render(width) {
|
|
195
215
|
const safeWidth = Math.max(1, width);
|
|
196
216
|
const levelsKey = this.#getLevelsKey();
|
|
217
|
+
// A live child row draws the shared spinner, so a cached board would freeze that glyph.
|
|
218
|
+
const frame = this.#props.calls.some(isLiveCall) ? toolSpinnerFrame() : "";
|
|
219
|
+
// A live child row draws the glyph, so this board owns the next frame for as long as it does.
|
|
220
|
+
if (frame !== "")
|
|
221
|
+
this.#requestNextFrame();
|
|
197
222
|
if (this.#renderCache !== undefined &&
|
|
198
223
|
this.#renderCache.width === safeWidth &&
|
|
199
224
|
this.#renderCache.copiedIndex === this.#copiedIndex &&
|
|
200
225
|
this.#renderCache.failedIndex === this.#failedIndex &&
|
|
201
|
-
this.#renderCache.levelsKey === levelsKey
|
|
226
|
+
this.#renderCache.levelsKey === levelsKey &&
|
|
227
|
+
this.#renderCache.frame === frame) {
|
|
202
228
|
this.#lineMapping = this.#renderCache.lineMapping;
|
|
203
229
|
return this.#renderCache.lines;
|
|
204
230
|
}
|
|
@@ -212,8 +238,7 @@ export class NestedCallsView {
|
|
|
212
238
|
continue;
|
|
213
239
|
const toolName = "toolName" in call ? call.toolName : call.name;
|
|
214
240
|
const isError = Boolean(call.isError || ("status" in call && call.status === "error"));
|
|
215
|
-
const isRunning = (
|
|
216
|
-
(!("status" in call) && call.result === undefined);
|
|
241
|
+
const isRunning = isLiveCall(call);
|
|
217
242
|
const expansionLevel = this.#getExpansionLevel(i);
|
|
218
243
|
const isExpanded = expansionLevel !== 0;
|
|
219
244
|
const rawSummary = "summary" in call ? call.summary : undefined;
|
|
@@ -281,6 +306,7 @@ export class NestedCallsView {
|
|
|
281
306
|
copiedIndex: this.#copiedIndex,
|
|
282
307
|
failedIndex: this.#failedIndex,
|
|
283
308
|
levelsKey,
|
|
309
|
+
frame,
|
|
284
310
|
lines,
|
|
285
311
|
lineMapping: this.#lineMapping,
|
|
286
312
|
};
|
package/dist/renderer.js
CHANGED
|
@@ -262,9 +262,15 @@ function renderGenericResult(params) {
|
|
|
262
262
|
const { outputText: rawOutput, wallTimeSeconds } = nativeResultText(toolName, result);
|
|
263
263
|
const isError = (context.isError ?? false) || (result.isError ?? false);
|
|
264
264
|
const status = toolName === "codemode"
|
|
265
|
-
? childCalls.length
|
|
266
|
-
? "
|
|
267
|
-
:
|
|
265
|
+
? childCalls.length > 0
|
|
266
|
+
? "none"
|
|
267
|
+
: isError
|
|
268
|
+
? "error"
|
|
269
|
+
: renderOptions.isPartial === true
|
|
270
|
+
? context.argsComplete
|
|
271
|
+
? "running"
|
|
272
|
+
: "streaming"
|
|
273
|
+
: "none"
|
|
268
274
|
: isMetaTool
|
|
269
275
|
? resolveContainerStatus({
|
|
270
276
|
isError,
|
|
@@ -672,11 +678,9 @@ function createRendererResolver(runtime, options, resolvePresentation, resolveNa
|
|
|
672
678
|
const isStreamingArgs = context.isPartial && !context.argsComplete;
|
|
673
679
|
const status = context.isError
|
|
674
680
|
? "error"
|
|
675
|
-
:
|
|
676
|
-
? "
|
|
677
|
-
:
|
|
678
|
-
? "streaming"
|
|
679
|
-
: "running";
|
|
681
|
+
: isStreamingArgs
|
|
682
|
+
? "streaming"
|
|
683
|
+
: "running";
|
|
680
684
|
const streamedTokens = isStreamingArgs ? estimateStreamingTokens(args) : 0;
|
|
681
685
|
const header = isStreamingArgs
|
|
682
686
|
? { summary: streamedTokens > 0 ? `· ${String(streamedTokens)} tokens` : "" }
|
|
@@ -56,7 +56,7 @@ export interface ToolGroupClock {
|
|
|
56
56
|
export declare function memberParts(member: Member): readonly Component[];
|
|
57
57
|
export declare class TailUnit implements Component {
|
|
58
58
|
#private;
|
|
59
|
-
constructor(member: Member);
|
|
59
|
+
constructor(member: Member, background: () => boolean);
|
|
60
60
|
render(width: number): string[];
|
|
61
61
|
handleMouse(event: Parameters<NonNullable<Component["handleMouse"]>>[0]): import("@earendil-works/pi-tui").TuiMouseEventResult | undefined;
|
|
62
62
|
invalidate(): void;
|
|
@@ -70,7 +70,7 @@ export declare class TailUnit implements Component {
|
|
|
70
70
|
*/
|
|
71
71
|
export declare class ToolGroupView extends Container {
|
|
72
72
|
#private;
|
|
73
|
-
constructor(ui: ExtensionUIContext, redraw: () => void);
|
|
73
|
+
constructor(ui: ExtensionUIContext, redraw: () => void, background: () => boolean);
|
|
74
74
|
/**
|
|
75
75
|
* Release the block's own lifetime. The projection drops a block when it is evicted, when
|
|
76
76
|
* grouping is disabled, or when the patch unloads; an idempotent dispose keeps that path honest,
|
package/dist/tool-group-view.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Box, Container, MouseRegion, Spacer, Text, truncateToWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
1
|
+
import { Box, Container, MouseRegion, Spacer, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
2
|
import { fitRow } from "@hheei/pi-ext-core";
|
|
3
3
|
import { backgroundFill } from "./background.js";
|
|
4
4
|
import { isCollapsible, toContentRows, toSingleRow, } from "./tool-view.js";
|
|
@@ -8,28 +8,56 @@ const SURFACE_HOLD_MS = 5_000;
|
|
|
8
8
|
export function memberParts(member) {
|
|
9
9
|
return member.parts ?? [member.component];
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* A row that draws nothing visible. A separator row is not always an empty string: Pi marks the start
|
|
13
|
+
* of a message zone with an OSC 133 row that paints no cells, and counting it as content would add a
|
|
14
|
+
* second blank row after the block.
|
|
15
|
+
*/
|
|
16
|
+
function isBlankRow(row) {
|
|
17
|
+
return row === undefined || visibleWidth(row) === 0;
|
|
18
|
+
}
|
|
11
19
|
export class TailUnit {
|
|
12
20
|
#parts;
|
|
21
|
+
#background;
|
|
13
22
|
#offsets = [];
|
|
14
|
-
constructor(member) {
|
|
23
|
+
constructor(member, background) {
|
|
15
24
|
this.#parts = memberParts(member);
|
|
25
|
+
this.#background = background;
|
|
16
26
|
}
|
|
17
27
|
render(width) {
|
|
18
28
|
const offsets = [];
|
|
19
29
|
const rows = [];
|
|
30
|
+
let contentRows = 0;
|
|
20
31
|
for (const part of this.#parts) {
|
|
21
32
|
const all = part.render(width);
|
|
22
33
|
const skip = all[0] === "" ? 1 : 0;
|
|
23
34
|
const own = all.slice(skip);
|
|
24
35
|
if (own.length === 0)
|
|
25
36
|
continue;
|
|
26
|
-
if (rows.length > 0)
|
|
37
|
+
if (rows.length > 0) {
|
|
27
38
|
rows.push("");
|
|
39
|
+
contentRows++;
|
|
40
|
+
}
|
|
41
|
+
// A leading OSC-only separator stays in the output, but is not a content row.
|
|
42
|
+
contentRows += own.length - (isBlankRow(own[0]) ? 1 : 0);
|
|
28
43
|
offsets.push({ part, top: rows.length, height: own.length, skip });
|
|
29
44
|
rows.push(...own);
|
|
30
45
|
}
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
if (rows.length === 0) {
|
|
47
|
+
this.#offsets = offsets;
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
50
|
+
const out = isBlankRow(rows[0]) ? rows : ["", ...rows];
|
|
51
|
+
// The separator above is a row of this unit too, so every part offset moves down with it.
|
|
52
|
+
const shift = out.length - rows.length;
|
|
53
|
+
this.#offsets = offsets.map((offset) => ({ ...offset, top: offset.top + shift }));
|
|
54
|
+
// Surface padding counts toward the minimum; added rows stay outside surfaces and hit areas.
|
|
55
|
+
const minimum = this.#background() ? 4 : 2;
|
|
56
|
+
if (contentRows > 0) {
|
|
57
|
+
for (let row = contentRows; row < minimum; row++)
|
|
58
|
+
out.push("");
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
33
61
|
}
|
|
34
62
|
handleMouse(event) {
|
|
35
63
|
for (const { part, top, height, skip } of this.#offsets) {
|
|
@@ -78,9 +106,11 @@ function formatTokens(count) {
|
|
|
78
106
|
*/
|
|
79
107
|
function cacheHitRate(latestPrompt) {
|
|
80
108
|
if (latestPrompt === undefined)
|
|
81
|
-
return
|
|
109
|
+
return undefined;
|
|
82
110
|
const prompt = latestPrompt.input + latestPrompt.cacheRead + latestPrompt.cacheWrite;
|
|
83
|
-
|
|
111
|
+
if (prompt <= 0)
|
|
112
|
+
return undefined;
|
|
113
|
+
return `${((latestPrompt.cacheRead / prompt) * 100).toFixed(1)}%`;
|
|
84
114
|
}
|
|
85
115
|
const PREVIEW_GRAPHEMES = 20;
|
|
86
116
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
@@ -112,6 +142,8 @@ export class ToolGroupView extends Container {
|
|
|
112
142
|
#header = new Text("", 0, 0);
|
|
113
143
|
#ui;
|
|
114
144
|
#redraw;
|
|
145
|
+
/** Pi-native block backgrounds are opt-in; while they are on, the fill replaces the rails. */
|
|
146
|
+
#background;
|
|
115
147
|
/** The stable clock for this block; restored history stays unknown until live content joins. */
|
|
116
148
|
#clock;
|
|
117
149
|
/** The block's own members, drawn inside the group. */
|
|
@@ -134,10 +166,11 @@ export class ToolGroupView extends Container {
|
|
|
134
166
|
// One extra colored row above and below: the block needs breathing room inside its background.
|
|
135
167
|
#box = new Box(0, 1);
|
|
136
168
|
#spacer = new Spacer(1);
|
|
137
|
-
constructor(ui, redraw) {
|
|
169
|
+
constructor(ui, redraw, background) {
|
|
138
170
|
super();
|
|
139
171
|
this.#ui = ui;
|
|
140
172
|
this.#redraw = redraw;
|
|
173
|
+
this.#background = background;
|
|
141
174
|
this.#heading = new MouseRegion(this.#header, (event) => {
|
|
142
175
|
if (event.type !== "click" || event.button !== "left")
|
|
143
176
|
return undefined;
|
|
@@ -250,35 +283,51 @@ export class ToolGroupView extends Container {
|
|
|
250
283
|
let requests = 0;
|
|
251
284
|
let input = 0;
|
|
252
285
|
let output = 0;
|
|
253
|
-
let
|
|
286
|
+
let cacheRead = 0;
|
|
287
|
+
let cacheWrite = 0;
|
|
254
288
|
let latestPrompt;
|
|
255
289
|
for (const fact of this.#requests) {
|
|
256
290
|
requests += 1;
|
|
257
291
|
const usage = fact.usage;
|
|
258
292
|
if (usage === undefined)
|
|
259
293
|
continue;
|
|
260
|
-
measured = true;
|
|
261
294
|
input += usage.input;
|
|
262
295
|
output += usage.output;
|
|
296
|
+
cacheRead += usage.cacheRead;
|
|
297
|
+
cacheWrite += usage.cacheWrite;
|
|
263
298
|
// A streaming snapshot can arrive with every field zeroed; that is a placeholder, not a
|
|
264
299
|
// measured prompt, so it must not overwrite the last known cache-hit fact.
|
|
265
300
|
if (usage.input + usage.cacheRead + usage.cacheWrite > 0)
|
|
266
301
|
latestPrompt = usage;
|
|
267
302
|
}
|
|
268
|
-
return { requests,
|
|
303
|
+
return { requests, input, output, cacheRead, cacheWrite, latestPrompt };
|
|
269
304
|
}
|
|
270
|
-
/**
|
|
305
|
+
/**
|
|
306
|
+
* The block footer: the block's request count, then the same token facts and the same symbols
|
|
307
|
+
* Pi's own footer uses (`↑input ↓output RcacheRead WcacheWrite CHrate`). Pi totals the whole
|
|
308
|
+
* session; this totals one block, so the two never have to agree on the number.
|
|
309
|
+
*/
|
|
271
310
|
#footerRow() {
|
|
272
311
|
const theme = this.#ui.theme;
|
|
273
312
|
const usage = this.#usage();
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
313
|
+
const parts = [`requests ×${usage.requests}`];
|
|
314
|
+
if (usage.input + usage.output + usage.cacheRead + usage.cacheWrite === 0) {
|
|
315
|
+
parts.push("↑—", "↓—");
|
|
316
|
+
return theme.fg("dim", parts.join(" · "));
|
|
317
|
+
}
|
|
318
|
+
if (usage.input > 0)
|
|
319
|
+
parts.push(`↑${formatTokens(usage.input)}`);
|
|
320
|
+
if (usage.output > 0)
|
|
321
|
+
parts.push(`↓${formatTokens(usage.output)}`);
|
|
322
|
+
if (usage.cacheRead > 0)
|
|
323
|
+
parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
324
|
+
if (usage.cacheWrite > 0)
|
|
325
|
+
parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
326
|
+
if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
|
|
327
|
+
const rate = cacheHitRate(usage.latestPrompt);
|
|
328
|
+
if (rate !== undefined)
|
|
329
|
+
parts.push(`CH${rate}`);
|
|
330
|
+
}
|
|
282
331
|
return theme.fg("dim", parts.join(" · "));
|
|
283
332
|
}
|
|
284
333
|
#categoryRow(category, label, detail) {
|
|
@@ -306,7 +355,8 @@ export class ToolGroupView extends Container {
|
|
|
306
355
|
const own = wrapper.render(Math.max(1, width));
|
|
307
356
|
if (own.length === 0)
|
|
308
357
|
continue;
|
|
309
|
-
// One blank row between adjacent semantic blocks
|
|
358
|
+
// One blank row between adjacent semantic blocks. A thinking run has no surface of its own,
|
|
359
|
+
// so it keeps the plain separator even when tools paint their own padded surfaces.
|
|
310
360
|
if (lines.length > 0) {
|
|
311
361
|
lines.push("");
|
|
312
362
|
hits.push({ height: 1, action: { kind: "none" } });
|
|
@@ -449,6 +499,13 @@ export class ToolGroupView extends Container {
|
|
|
449
499
|
push(row, index === 0 ? { kind: "item", member: member.component } : { kind: "none" });
|
|
450
500
|
}
|
|
451
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* The cells a thinking row starts with. Thinking has no surface of its own, so it keeps the same
|
|
504
|
+
* rail as a tool at every level; once a block is filled, the fill delimits it and the rail goes.
|
|
505
|
+
*/
|
|
506
|
+
#thinkingPrefix() {
|
|
507
|
+
return this.#background() ? " " : `${this.#ui.theme.fg("dim", "▎")} `;
|
|
508
|
+
}
|
|
452
509
|
/** One preview row: the grapheme head plus an ellipsis, then cut to the cells the rail leaves. */
|
|
453
510
|
#thinkingPreviewRow(text, width) {
|
|
454
511
|
const theme = this.#ui.theme;
|
|
@@ -456,15 +513,16 @@ export class ToolGroupView extends Container {
|
|
|
456
513
|
const room = Math.max(1, width - 2);
|
|
457
514
|
const fitted = truncateToWidth(head, more ? Math.max(1, room - 1) : room, "…");
|
|
458
515
|
const preview = more && fitted === head ? `${head}…` : fitted;
|
|
459
|
-
return `${
|
|
516
|
+
return `${this.#thinkingPrefix()}${theme.fg("thinkingText", theme.italic(preview))}`;
|
|
460
517
|
}
|
|
461
518
|
/** Full thinking text: paragraphs keep their separation, every row wraps to the indent's width. */
|
|
462
519
|
#thinkingRows(text, width) {
|
|
463
520
|
const theme = this.#ui.theme;
|
|
521
|
+
const prefix = this.#thinkingPrefix();
|
|
464
522
|
const inner = Math.max(1, width - 2);
|
|
465
523
|
return toContentRows(text).flatMap((line) => {
|
|
466
524
|
const wrapped = wrapTextWithAnsi(line, inner);
|
|
467
|
-
return (wrapped.length > 0 ? wrapped : [""]).map((row) => row === "" ? "" :
|
|
525
|
+
return (wrapped.length > 0 ? wrapped : [""]).map((row) => row === "" ? "" : `${prefix}${theme.fg("thinkingText", theme.italic(row))}`);
|
|
468
526
|
});
|
|
469
527
|
}
|
|
470
528
|
/**
|
package/dist/tool-view.d.ts
CHANGED
|
@@ -66,7 +66,7 @@ export declare const MAX_LEVEL2_SECTION_LINES = 500;
|
|
|
66
66
|
export declare const MAX_LEVEL2_BYTES: number;
|
|
67
67
|
export declare function transitionFoldLevel(current: FoldLevel, ctrlKey: boolean): FoldLevel;
|
|
68
68
|
export declare const TOOL_SPINNER_FRAMES: readonly ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
|
69
|
-
export declare const TOOL_SPINNER_INTERVAL_MS =
|
|
69
|
+
export declare const TOOL_SPINNER_INTERVAL_MS = 200;
|
|
70
70
|
/**
|
|
71
71
|
* One clock for every live tool, so the phase is shared and never restarts: streaming args and a
|
|
72
72
|
* running execution draw the same frame and differ only in color.
|
package/dist/tool-view.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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 {
|
|
4
|
+
import { surfaceBlock } from "./background.js";
|
|
5
5
|
/** Pi native tool surfaces: a failure outranks work in progress, which outranks a settled tool. */
|
|
6
6
|
function nativeSurface(status) {
|
|
7
7
|
if (status === "error")
|
|
@@ -67,7 +67,7 @@ function isCopyFailed(stateHolder, localFailed) {
|
|
|
67
67
|
return isRecord(stateHolder) && stateHolder[COPY_FAILED_STATE_KEY] === true;
|
|
68
68
|
}
|
|
69
69
|
export const TOOL_SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
|
70
|
-
export const TOOL_SPINNER_INTERVAL_MS =
|
|
70
|
+
export const TOOL_SPINNER_INTERVAL_MS = 200;
|
|
71
71
|
/**
|
|
72
72
|
* One clock for every live tool, so the phase is shared and never restarts: streaming args and a
|
|
73
73
|
* running execution draw the same frame and differ only in color.
|
|
@@ -245,6 +245,7 @@ export class ToolView {
|
|
|
245
245
|
#localCopiedUntil = 0;
|
|
246
246
|
#localCopyFailed = false;
|
|
247
247
|
#localTimer;
|
|
248
|
+
#liveTimer;
|
|
248
249
|
#lastWidth = 80;
|
|
249
250
|
#lastHeight = 0;
|
|
250
251
|
#lastNoRail = false;
|
|
@@ -386,12 +387,30 @@ export class ToolView {
|
|
|
386
387
|
this.#spec.stateHolder[COPY_FEEDBACK_TIMER_KEY] = timer;
|
|
387
388
|
}
|
|
388
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* A live glyph needs someone to ask for the next frame, and no host event says "still running".
|
|
392
|
+
* The frame that drew one owns that timer: it fires once, the repaint renders again, and a settled
|
|
393
|
+
* row simply stops asking.
|
|
394
|
+
*/
|
|
395
|
+
#requestNextFrame() {
|
|
396
|
+
if (this.#liveTimer !== undefined)
|
|
397
|
+
return;
|
|
398
|
+
this.#liveTimer = setTimeout(() => {
|
|
399
|
+
this.#liveTimer = undefined;
|
|
400
|
+
this.#spec.invalidate?.();
|
|
401
|
+
}, TOOL_SPINNER_INTERVAL_MS);
|
|
402
|
+
this.#liveTimer.unref?.();
|
|
403
|
+
}
|
|
389
404
|
#renderHeader(width, level) {
|
|
390
405
|
const { theme, header, copy } = this.#spec;
|
|
391
406
|
const safeWidth = Math.max(1, width);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
407
|
+
// A live tool says so in its leading slot: one glyph, no `▸` in front of a second status mark.
|
|
408
|
+
// A container that already lists child calls is not live any more, so it keeps the fold pin.
|
|
409
|
+
const live = level === 0 && (header.status === "streaming" || header.status === "running");
|
|
410
|
+
const pin = live
|
|
411
|
+
? formatStatusGlyph(header.status, theme)
|
|
412
|
+
: theme.fg("dim", level === 0 ? "▸" : "▾");
|
|
413
|
+
const leftContent = `${pin} ${formatToolHeaderText(live ? { ...header, status: "none" } : header, theme)}`;
|
|
395
414
|
if (copy === undefined || safeWidth < 6) {
|
|
396
415
|
return truncate(leftContent, safeWidth);
|
|
397
416
|
}
|
|
@@ -507,12 +526,24 @@ export class ToolView {
|
|
|
507
526
|
const native = this.#spec.background?.() === true;
|
|
508
527
|
const surface = native ? nativeSurface(this.#spec.header.status) : undefined;
|
|
509
528
|
const noRail = this.#spec.noRail === true || native;
|
|
510
|
-
|
|
529
|
+
// The glyph of a live tool changes on its own clock, so a cached frame would freeze it.
|
|
530
|
+
const status = this.#spec.header.status;
|
|
531
|
+
const glyph = status === "streaming" || status === "running" ? toolSpinnerFrame() : "";
|
|
532
|
+
// A live row at level 0 draws the glyph, so this frame owns the next one.
|
|
533
|
+
if (glyph !== "" && level === 0)
|
|
534
|
+
this.#requestNextFrame();
|
|
535
|
+
// A component body draws on a clock of its own: a container's child board animates a live child
|
|
536
|
+
// while the container itself already shows its fold pin, so a cached frame would freeze the
|
|
537
|
+
// child's glyph. Only a body of literal rows is fully known here.
|
|
538
|
+
const bodyOwnsItsClock = this.#spec.collapsedRows !== undefined && !Array.isArray(this.#spec.collapsedRows);
|
|
539
|
+
if (!bodyOwnsItsClock &&
|
|
540
|
+
this.#cache !== undefined &&
|
|
511
541
|
this.#cache.width === width &&
|
|
512
542
|
this.#cache.level === level &&
|
|
513
543
|
this.#cache.noRail === noRail &&
|
|
514
544
|
this.#cache.surface === surface &&
|
|
515
|
-
this.#cache.copied === copied
|
|
545
|
+
this.#cache.copied === copied &&
|
|
546
|
+
this.#cache.glyph === glyph) {
|
|
516
547
|
return this.#cache.lines;
|
|
517
548
|
}
|
|
518
549
|
const pad = Math.max(0, this.#spec.outputPad ?? 0);
|
|
@@ -537,11 +568,11 @@ export class ToolView {
|
|
|
537
568
|
}
|
|
538
569
|
}
|
|
539
570
|
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)))));
|
|
571
|
+
const lines = surface === undefined ? safeLines : surfaceBlock(this.#spec.theme, surface, safeLines, width);
|
|
543
572
|
this.#lastHeight = lines.length;
|
|
544
|
-
this.#cache =
|
|
573
|
+
this.#cache = bodyOwnsItsClock
|
|
574
|
+
? undefined
|
|
575
|
+
: { width, level, copied, noRail, surface, glyph, lines };
|
|
545
576
|
return lines;
|
|
546
577
|
}
|
|
547
578
|
handleMouse(event) {
|