pi-compact-tools 0.9.2 → 0.10.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 +49 -4
- package/extensions/compact-tools-activity.ts +351 -0
- package/extensions/compact-tools-clock.ts +29 -0
- package/extensions/compact-tools-config.ts +47 -5
- package/extensions/compact-tools-custom.ts +77 -0
- package/extensions/compact-tools-hook.ts +47 -0
- package/extensions/compact-tools-invocation.ts +72 -0
- package/extensions/compact-tools-layout.ts +10 -0
- package/extensions/compact-tools-palette.ts +18 -0
- package/extensions/compact-tools-progress.ts +15 -10
- package/extensions/compact-tools-release.ts +136 -0
- package/extensions/compact-tools-runtime.ts +38 -11
- package/extensions/compact-tools-silent.ts +334 -0
- package/extensions/compact-tools-thinking.ts +7 -7
- package/extensions/compact-tools-types.ts +14 -1
- package/extensions/compact-tools-viewport.ts +234 -0
- package/extensions/compact-tools.ts +156 -12
- package/package.json +5 -4
- package/release-notes.json +10 -0
- package/schemas/compact-tools.schema.json +34 -2
- package/scripts/check-resources.mjs +8 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replacement body. `original` is the unwrapped method itself, not bound, so
|
|
3
|
+
* nothing is allocated per call: behaviors invoke it as `original.apply(self, args)`.
|
|
4
|
+
*/
|
|
5
|
+
export type MethodBehavior = (self: any, args: any[], original: (...args: any[]) => any) => any;
|
|
6
|
+
|
|
7
|
+
type HookRecord = { behavior: MethodBehavior };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wrap `target[method]` once per process and route every call through the
|
|
11
|
+
* behavior that the most recently loaded copy of this extension supplied.
|
|
12
|
+
*
|
|
13
|
+
* Pi's classes and TUI outlive a /reload, but this module does not. A wrapper
|
|
14
|
+
* that closed over its own code would keep running the first version until Pi
|
|
15
|
+
* restarts, so the wrapper only looks up the behavior, and each load replaces it.
|
|
16
|
+
* `legacy` names the markers under which older versions kept the unwrapped
|
|
17
|
+
* method; their wrappers are dropped in favor of that original rather than
|
|
18
|
+
* stacked beneath the new one.
|
|
19
|
+
*/
|
|
20
|
+
export function hookMethod(
|
|
21
|
+
target: object,
|
|
22
|
+
method: string,
|
|
23
|
+
name: string,
|
|
24
|
+
behavior: MethodBehavior,
|
|
25
|
+
legacy: readonly symbol[] = [],
|
|
26
|
+
): boolean {
|
|
27
|
+
const holder = target as Record<PropertyKey, any>;
|
|
28
|
+
const key = Symbol.for(`pi-compact-tools.hook.${name}`);
|
|
29
|
+
if (Object.hasOwn(holder, key)) {
|
|
30
|
+
(holder[key] as HookRecord).behavior = behavior;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (typeof holder[method] !== "function") return false;
|
|
34
|
+
let original: (...args: any[]) => any = holder[method];
|
|
35
|
+
for (const marker of legacy) {
|
|
36
|
+
if (Object.hasOwn(holder, marker) && typeof holder[marker] === "function") {
|
|
37
|
+
original = holder[marker];
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const record: HookRecord = { behavior };
|
|
42
|
+
holder[method] = function (this: unknown, ...args: any[]) {
|
|
43
|
+
return record.behavior(this, args, original);
|
|
44
|
+
};
|
|
45
|
+
Object.defineProperty(holder, key, { value: record });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
3
|
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
3
4
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
4
5
|
|
|
@@ -32,6 +33,40 @@ export function getCallDetails(name: string, args: ToolArgs): string {
|
|
|
32
33
|
return name === "grep" ? `/${pattern}/ in ${path}` : `${pattern} in ${path}`;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/** Argument names that usually identify what a tool call is about, most specific first. */
|
|
37
|
+
const CUSTOM_SUMMARY_KEYS = [
|
|
38
|
+
"command", "query", "queries", "q", "url", "urls", "path", "file_path", "paths",
|
|
39
|
+
"pattern", "prompt", "question", "claim", "name", "id", "tool",
|
|
40
|
+
];
|
|
41
|
+
const CUSTOM_SUMMARY_MAX_LENGTH = 160;
|
|
42
|
+
|
|
43
|
+
function summaryValue(value: unknown): string | undefined {
|
|
44
|
+
if (typeof value === "string") return value;
|
|
45
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
const parts = value.filter((item) => typeof item === "string" || typeof item === "number").map(String);
|
|
48
|
+
return parts.length > 0 ? parts.join(", ") : undefined;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One line describing a custom tool call; the full arguments stay available when expanded. */
|
|
54
|
+
export function summarizeCustomArguments(args: ToolArgs): string {
|
|
55
|
+
const keys = [...CUSTOM_SUMMARY_KEYS.filter((key) => key in args), ...Object.keys(args)];
|
|
56
|
+
for (const key of keys) {
|
|
57
|
+
const value = summaryValue(args[key]);
|
|
58
|
+
if (!value?.trim()) continue;
|
|
59
|
+
const line = stripTerminalSequences(normalizeLineEndings(value))
|
|
60
|
+
.replace(/[\x00-\x1f\x7f]/gu, " ")
|
|
61
|
+
.replace(/\s+/gu, " ")
|
|
62
|
+
.trim();
|
|
63
|
+
return line.length > CUSTOM_SUMMARY_MAX_LENGTH
|
|
64
|
+
? `${line.slice(0, CUSTOM_SUMMARY_MAX_LENGTH - 1).trimEnd()}…`
|
|
65
|
+
: line;
|
|
66
|
+
}
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
|
|
35
70
|
export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
36
71
|
if (name === "edit" || INLINE_ARGUMENT_TOOLS.has(name)) return {};
|
|
37
72
|
return collectArgumentDetails(name, args);
|
|
@@ -319,6 +354,30 @@ export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
|
319
354
|
return normalizeLineEndings(parts.join("\n")).trimEnd();
|
|
320
355
|
}
|
|
321
356
|
|
|
357
|
+
const FAILURE_REASON_MAX_LENGTH = 140;
|
|
358
|
+
|
|
359
|
+
/** Pull a useful diagnostic into a collapsed failed row without exposing raw control sequences. */
|
|
360
|
+
export function summarizeFailure(name: string, output: string): string | undefined {
|
|
361
|
+
const lines = normalizeLineEndings(stripTerminalSequences(output))
|
|
362
|
+
.split("\n")
|
|
363
|
+
.map((line) => line.replace(/[\x00-\x1f\x7f]/gu, " ").trim())
|
|
364
|
+
.filter(Boolean);
|
|
365
|
+
if (lines.length === 0) return undefined;
|
|
366
|
+
const last = lines[lines.length - 1]!;
|
|
367
|
+
const shellStatus = (name === "bash" || name === "powershell")
|
|
368
|
+
&& /^Command (?:exited with code \d+|timed out.*|aborted|terminated.*)$/iu.test(last)
|
|
369
|
+
? last
|
|
370
|
+
: undefined;
|
|
371
|
+
const diagnostic = shellStatus
|
|
372
|
+
? lines.slice(0, -1).reverse().find((line) =>
|
|
373
|
+
/\b(?:error|failed|failure|fatal|exception|denied|not found|no such file)\b|ENOENT/iu.test(line))
|
|
374
|
+
: undefined;
|
|
375
|
+
const reason = diagnostic ? `${diagnostic} (${shellStatus})` : shellStatus ?? lines[0]!;
|
|
376
|
+
return reason.length > FAILURE_REASON_MAX_LENGTH
|
|
377
|
+
? `${reason.slice(0, FAILURE_REASON_MAX_LENGTH - 1).trimEnd()}…`
|
|
378
|
+
: reason;
|
|
379
|
+
}
|
|
380
|
+
|
|
322
381
|
type ResultDetails = {
|
|
323
382
|
diff?: unknown;
|
|
324
383
|
patch?: unknown;
|
|
@@ -361,6 +420,19 @@ export function getEditChanges(result: AgentToolResult<unknown>): string {
|
|
|
361
420
|
.trimEnd();
|
|
362
421
|
}
|
|
363
422
|
|
|
423
|
+
/** How many lines an edit added and removed, counted from the diff Pi attaches to its result. */
|
|
424
|
+
export function countEditChanges(result: AgentToolResult<unknown>): { added: number; removed: number } | undefined {
|
|
425
|
+
const diff = getEditDiff(result);
|
|
426
|
+
if (!diff) return undefined;
|
|
427
|
+
let added = 0;
|
|
428
|
+
let removed = 0;
|
|
429
|
+
for (const line of diff.split("\n")) {
|
|
430
|
+
if (line.startsWith("+")) added++;
|
|
431
|
+
else if (line.startsWith("-")) removed++;
|
|
432
|
+
}
|
|
433
|
+
return { added, removed };
|
|
434
|
+
}
|
|
435
|
+
|
|
364
436
|
const READ_FOOTER = /\n\n(\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\])$/u;
|
|
365
437
|
|
|
366
438
|
/** Separate a read's file contents from the continuation notice Pi appends to them. */
|
|
@@ -399,6 +399,16 @@ export function limitComponentLines(component: Component, maximumLines: number,
|
|
|
399
399
|
}, () => component.invalidate?.());
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
+
/** Indent another renderer's component under the result rail so it reads as part of the row. */
|
|
403
|
+
export function railComponent(component: Component, theme: Theme): Component {
|
|
404
|
+
const rail = paintChrome(theme, " │ ");
|
|
405
|
+
const railWidth = visibleWidth(rail);
|
|
406
|
+
return new CachedComponent(
|
|
407
|
+
(width) => component.render(Math.max(1, width - railWidth)).map((line) => rail + line),
|
|
408
|
+
() => component.invalidate?.(),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
402
412
|
export function renderArguments(args: ToolArgs, theme: Theme): Component {
|
|
403
413
|
const json = JSON.stringify(args, null, 2) ?? "{}";
|
|
404
414
|
const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
colorizeRgb,
|
|
4
|
+
interpolateRgb,
|
|
4
5
|
liftContrast,
|
|
5
6
|
neutralizeRgb,
|
|
6
7
|
themeColorRgb,
|
|
@@ -107,6 +108,23 @@ export function indicatorPulse(theme: Theme): ColorRamp | undefined {
|
|
|
107
108
|
return ramp(theme, PULSE_LOW, PULSE_HIGH);
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
/** How far the brightest activity dot moves from the level's color toward the label's crest. */
|
|
112
|
+
const ACTIVITY_CREST_MIX = 0.3;
|
|
113
|
+
/** Contrast against the background of an unlit dot: the level's hue, barely there. */
|
|
114
|
+
const ACTIVITY_UNLIT = 0.05;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Silent-mode activity dots: an unlit dot is the thinking level's own hue faded
|
|
118
|
+
* almost into the background, and a lit one shows that hue lifted a little. The
|
|
119
|
+
* label's full crest washes every level toward the same near-white.
|
|
120
|
+
*/
|
|
121
|
+
export function activityGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
|
|
122
|
+
const glow = progressGlow(theme, level);
|
|
123
|
+
if (!glow) return undefined;
|
|
124
|
+
const lit = interpolateRgb(glow.from, glow.to, ACTIVITY_CREST_MIX);
|
|
125
|
+
return { from: withContrast(theme, lit, ACTIVITY_UNLIT, ACTIVITY_UNLIT), to: lit };
|
|
126
|
+
}
|
|
127
|
+
|
|
110
128
|
/** Sweep endpoints for the working label at a thinking level, resting color to crest. */
|
|
111
129
|
export function progressGlow(theme: Theme, level: ThinkingLevel): ColorRamp | undefined {
|
|
112
130
|
const raw = themeColorRgb(theme, thinkingLevelColor(level));
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { onTick } from "./compact-tools-clock.ts";
|
|
3
4
|
import { colorizeRgb, interpolateRgb } from "./compact-tools-color.ts";
|
|
4
5
|
import { progressGlow, type ColorRamp, type ThinkingLevel } from "./compact-tools-palette.ts";
|
|
5
6
|
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
6
7
|
|
|
7
|
-
const GLOW_INTERVAL_MS = 80;
|
|
8
8
|
|
|
9
9
|
const TOOL_PROGRESS_MESSAGES: Readonly<Record<string, string>> = {
|
|
10
10
|
read: "Reading file…",
|
|
@@ -89,7 +89,8 @@ export class ProgressController {
|
|
|
89
89
|
private message: string | undefined;
|
|
90
90
|
private frames: string[] = [];
|
|
91
91
|
private frame = 0;
|
|
92
|
-
|
|
92
|
+
/** Stops this label's share of the animation clock. */
|
|
93
|
+
private stopTicking: (() => void) | undefined;
|
|
93
94
|
private level: ThinkingLevel = "medium";
|
|
94
95
|
|
|
95
96
|
constructor(private readonly pi: ExtensionAPI) {
|
|
@@ -119,7 +120,12 @@ export class ProgressController {
|
|
|
119
120
|
const remaining = [...this.activeTools.values()].at(-1);
|
|
120
121
|
this.setMessage(remaining ?? "Processing results…");
|
|
121
122
|
});
|
|
122
|
-
|
|
123
|
+
// agent_end closes one low-level run, after which Pi may still retry, compact,
|
|
124
|
+
// or continue from a settlement boundary. Pi hides its own working row at that
|
|
125
|
+
// point and rebuilds it at the next turn_start from the message last set here,
|
|
126
|
+
// so releasing the label at agent_end makes a retried turn reappear under Pi's
|
|
127
|
+
// default label. Release it at the final settlement instead.
|
|
128
|
+
pi.on("agent_settled", () => {
|
|
123
129
|
if (!this.context) return;
|
|
124
130
|
this.activeTools.clear();
|
|
125
131
|
this.clearMessage();
|
|
@@ -161,17 +167,16 @@ export class ProgressController {
|
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
private setMessage(message: string): void {
|
|
164
|
-
if (!this.context || (this.message === message && this.
|
|
170
|
+
if (!this.context || (this.message === message && this.stopTicking)) return;
|
|
165
171
|
this.message = message;
|
|
166
172
|
this.frames = createGlowFrames(message, this.context.ui.theme, this.level);
|
|
167
173
|
this.frame = 0;
|
|
168
174
|
this.renderMessage();
|
|
169
|
-
if (this.
|
|
170
|
-
this.
|
|
175
|
+
if (this.stopTicking) return;
|
|
176
|
+
this.stopTicking = onTick(() => {
|
|
171
177
|
this.frame++;
|
|
172
178
|
this.renderMessage();
|
|
173
|
-
}
|
|
174
|
-
this.timer.unref?.();
|
|
179
|
+
});
|
|
175
180
|
}
|
|
176
181
|
|
|
177
182
|
private renderMessage(): void {
|
|
@@ -188,7 +193,7 @@ export class ProgressController {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
private stopAnimation(): void {
|
|
191
|
-
|
|
192
|
-
this.
|
|
196
|
+
this.stopTicking?.();
|
|
197
|
+
this.stopTicking = undefined;
|
|
193
198
|
}
|
|
194
199
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DynamicBorder, getAgentDir, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Spacer, Text } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
/** One marker file per version shown, so two Pi processes can't both show it. */
|
|
7
|
+
const NOTICE_DIRECTORY = "compact-tools-notices";
|
|
8
|
+
const WIDGET_KEY = "compact-tools-release-notes";
|
|
9
|
+
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
10
|
+
|
|
11
|
+
export type ReleaseNotes = Record<string, readonly string[]>;
|
|
12
|
+
|
|
13
|
+
type Chat = { children: unknown[]; addChild(component: unknown): void };
|
|
14
|
+
|
|
15
|
+
function readReleaseNotes(): ReleaseNotes | undefined {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(readFileSync(new URL("../release-notes.json", import.meta.url), "utf8")) as ReleaseNotes;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function installedVersion(): string {
|
|
24
|
+
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string };
|
|
25
|
+
return manifest.version;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Order two plain x.y.z versions; anything else sorts first. */
|
|
29
|
+
export function compareVersions(left: string, right: string): number {
|
|
30
|
+
const a = VERSION.exec(left);
|
|
31
|
+
const b = VERSION.exec(right);
|
|
32
|
+
if (!a || !b) return a ? 1 : b ? -1 : 0;
|
|
33
|
+
for (let part = 1; part <= 3; part++) {
|
|
34
|
+
const difference = Number(a[part]) - Number(b[part]);
|
|
35
|
+
if (difference !== 0) return difference;
|
|
36
|
+
}
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The notes to show, newest first: every version with notes after the last one
|
|
42
|
+
* shown, up to the installed one. With nothing shown before, only the installed
|
|
43
|
+
* version's notes appear: that is either a new install or an update from a
|
|
44
|
+
* version that predates these notices, and there is no telling which.
|
|
45
|
+
*/
|
|
46
|
+
export function releasesToShow(
|
|
47
|
+
installed: string,
|
|
48
|
+
notes: ReleaseNotes,
|
|
49
|
+
lastShown: string | undefined,
|
|
50
|
+
): Array<{ version: string; notes: readonly string[] }> {
|
|
51
|
+
const versions = lastShown === undefined
|
|
52
|
+
? (notes[installed]?.length ? [installed] : [])
|
|
53
|
+
: Object.keys(notes).filter((version) =>
|
|
54
|
+
VERSION.test(version)
|
|
55
|
+
&& compareVersions(version, lastShown) > 0
|
|
56
|
+
&& compareVersions(version, installed) <= 0
|
|
57
|
+
&& notes[version]!.length > 0);
|
|
58
|
+
return versions
|
|
59
|
+
.sort((left, right) => compareVersions(right, left))
|
|
60
|
+
.map((version) => ({ version, notes: notes[version]! }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function lastShownVersion(directory: string): string | undefined {
|
|
64
|
+
try {
|
|
65
|
+
return readdirSync(directory)
|
|
66
|
+
.filter((name) => VERSION.test(name))
|
|
67
|
+
.sort(compareVersions)
|
|
68
|
+
.at(-1);
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pi's chat, the transcript document's last child, reached the same way in regular and fullscreen mode. */
|
|
75
|
+
function chatOf(tui: unknown): Chat | undefined {
|
|
76
|
+
const chat = (tui as { children?: Array<{ children?: unknown[] }> }).children?.[0]?.children?.at(-1) as Chat | undefined;
|
|
77
|
+
return Array.isArray(chat?.children) && typeof chat.addChild === "function" ? chat : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Drawn like Pi's own "What's New": a bordered block in the chat, never saved to the session or sent to the model. */
|
|
81
|
+
function addReleaseBlock(chat: Chat, theme: Theme, releases: ReturnType<typeof releasesToShow>): void {
|
|
82
|
+
const border = (line: string) => theme.fg("border", line);
|
|
83
|
+
if (chat.children.length > 0) chat.addChild(new Spacer(1));
|
|
84
|
+
chat.addChild(new DynamicBorder(border));
|
|
85
|
+
releases.forEach(({ version, notes }, index) => {
|
|
86
|
+
if (index > 0) chat.addChild(new Spacer(1));
|
|
87
|
+
chat.addChild(new Text(theme.bold(theme.fg("accent", `What's new in pi-compact-tools v${version}`)), 1, 0));
|
|
88
|
+
chat.addChild(new Spacer(1));
|
|
89
|
+
chat.addChild(new Text(notes.map((note) => theme.fg("dim", `• ${note}`)).join("\n"), 1, 0));
|
|
90
|
+
});
|
|
91
|
+
chat.addChild(new DynamicBorder(border));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Show what changed since the version last shown, once, the way Pi shows its own
|
|
96
|
+
* changelog: only when a conversation starts, as a bordered block in the chat.
|
|
97
|
+
* A resumed session keeps the notes for the next new one.
|
|
98
|
+
*/
|
|
99
|
+
export function showReleaseNotice(
|
|
100
|
+
ctx: ExtensionContext,
|
|
101
|
+
version = installedVersion(),
|
|
102
|
+
agentDir = getAgentDir(),
|
|
103
|
+
notes: ReleaseNotes | undefined = readReleaseNotes(),
|
|
104
|
+
): boolean {
|
|
105
|
+
if (ctx.mode !== "tui" || !VERSION.test(version) || !notes) return false;
|
|
106
|
+
const resumed = ctx.sessionManager?.getEntries?.().some((entry) => entry.type === "message") ?? false;
|
|
107
|
+
if (resumed) return false;
|
|
108
|
+
const directory = join(agentDir, NOTICE_DIRECTORY);
|
|
109
|
+
const releases = releasesToShow(version, notes, lastShownVersion(directory));
|
|
110
|
+
if (releases.length === 0) return false;
|
|
111
|
+
try {
|
|
112
|
+
mkdirSync(directory, { recursive: true });
|
|
113
|
+
// Exclusive creation: of two Pi processes starting together, only one shows it.
|
|
114
|
+
closeSync(openSync(join(directory, version), "wx"));
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
let shown = false;
|
|
119
|
+
// An invisible widget is the extension API's way to reach Pi's TUI and its chat.
|
|
120
|
+
// Pi builds the widget at once, so it can be removed right after.
|
|
121
|
+
ctx.ui.setWidget(WIDGET_KEY, (tui) => {
|
|
122
|
+
const chat = chatOf(tui);
|
|
123
|
+
if (chat) {
|
|
124
|
+
addReleaseBlock(chat, ctx.ui.theme, releases);
|
|
125
|
+
shown = true;
|
|
126
|
+
}
|
|
127
|
+
return { render: () => [], invalidate() {} };
|
|
128
|
+
}, { placement: "belowEditor" });
|
|
129
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
|
|
130
|
+
if (!shown) {
|
|
131
|
+
const text = releases.map(({ version: shownVersion, notes: shownNotes }) =>
|
|
132
|
+
`pi-compact-tools v${shownVersion}\n${shownNotes.map((note) => ` • ${note}`).join("\n")}`).join("\n");
|
|
133
|
+
ctx.ui.notify(text, "info");
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DEFAULT_CONFIG } from "./compact-tools-config.ts";
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
import { isSilent } from "./compact-tools-silent.ts";
|
|
4
|
+
import {
|
|
5
|
+
SUPPORTED_TOOL_SET,
|
|
6
|
+
type BuiltInDefinition,
|
|
7
|
+
type CompactToolName,
|
|
8
|
+
type CompactToolsConfig,
|
|
9
|
+
type RenderContext,
|
|
10
|
+
type RowState,
|
|
9
11
|
} from "./compact-tools-types.ts";
|
|
10
12
|
|
|
11
13
|
const MAX_TRACKED_ROWS = 2_000;
|
|
@@ -59,6 +61,8 @@ export class ToolRuntime {
|
|
|
59
61
|
if (!this.indicatorTimer) {
|
|
60
62
|
this.indicatorTimer = setInterval(() => {
|
|
61
63
|
this.indicatorFrame++;
|
|
64
|
+
// Silent mode hides tool rows; repainting the screen for them would be pure cost.
|
|
65
|
+
if (isSilent()) return;
|
|
62
66
|
for (const requestRender of this.indicatorInvalidators.values()) requestRender();
|
|
63
67
|
}, INDICATOR_INTERVAL_MS);
|
|
64
68
|
this.indicatorTimer.unref?.();
|
|
@@ -66,7 +70,7 @@ export class ToolRuntime {
|
|
|
66
70
|
return this.indicatorFrame;
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
syncExpansion(state: RowState, hostExpanded: boolean, name:
|
|
73
|
+
syncExpansion(state: RowState, hostExpanded: boolean, name: string): boolean {
|
|
70
74
|
const initialized = this.initializeExpansion(state, name, hostExpanded);
|
|
71
75
|
if (!initialized && state.lastHostExpanded !== hostExpanded) {
|
|
72
76
|
state.lastHostExpanded = hostExpanded;
|
|
@@ -74,12 +78,12 @@ export class ToolRuntime {
|
|
|
74
78
|
// A non-auto-compacted row collapses back to its bounded preview. Hiding it
|
|
75
79
|
// here removes the clicked row from under the pointer, so the next click can
|
|
76
80
|
// accidentally hit the Thinking block that moved into the same coordinates.
|
|
77
|
-
state.preview = !hostExpanded && !this.
|
|
81
|
+
state.preview = !hostExpanded && !this.autoCompact(name);
|
|
78
82
|
}
|
|
79
83
|
return state.expanded ?? false;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
setResultAvailable(state: RowState, name:
|
|
86
|
+
setResultAvailable(state: RowState, name: string, available: boolean): void {
|
|
83
87
|
this.initializeExpansion(state, name);
|
|
84
88
|
state.hasResult = available;
|
|
85
89
|
}
|
|
@@ -99,6 +103,22 @@ export class ToolRuntime {
|
|
|
99
103
|
return state;
|
|
100
104
|
}
|
|
101
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Record when a tool this extension does not wrap starts and ends. Pi reports
|
|
108
|
+
* both to extensions before it redraws the row, and the row draws its status
|
|
109
|
+
* dot before its result, so without this a finished custom tool kept the
|
|
110
|
+
* running dot. Wrapped built-ins already have timings, which are kept.
|
|
111
|
+
*/
|
|
112
|
+
noteExecutionStart(toolCallId: string): void {
|
|
113
|
+
if (!this.executionTimings.has(toolCallId)) this.executionTimings.set(toolCallId, { startedAt: Date.now() });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
noteExecutionEnd(toolCallId: string): void {
|
|
117
|
+
const timing = this.executionTimings.get(toolCallId) ?? { startedAt: Date.now() };
|
|
118
|
+
timing.endedAt ??= Date.now();
|
|
119
|
+
this.executionTimings.set(toolCallId, timing);
|
|
120
|
+
}
|
|
121
|
+
|
|
102
122
|
createTimedExecute(definition: BuiltInDefinition): TimedExecute {
|
|
103
123
|
const execute = definition.execute as TimedExecute;
|
|
104
124
|
return async (...args: any[]) => {
|
|
@@ -131,12 +151,19 @@ export class ToolRuntime {
|
|
|
131
151
|
this.indicatorFrame = 0;
|
|
132
152
|
}
|
|
133
153
|
|
|
134
|
-
|
|
154
|
+
/** Built-ins follow their own auto_compact entry; a custom tool its own entry if it has one, else the custom_tools policy. */
|
|
155
|
+
private autoCompact(name: string): boolean {
|
|
156
|
+
return SUPPORTED_TOOL_SET.has(name)
|
|
157
|
+
? this.configValue.auto_compact[name as CompactToolName]
|
|
158
|
+
: this.configValue.auto_compact[name] ?? this.configValue.custom_tools.auto_compact;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private initializeExpansion(state: RowState, name: string, hostExpanded = false): boolean {
|
|
135
162
|
if (state.configRevision === this.configRevision) return false;
|
|
136
163
|
state.configRevision = this.configRevision;
|
|
137
164
|
state.lastHostExpanded = hostExpanded;
|
|
138
165
|
state.expanded = hostExpanded;
|
|
139
|
-
state.preview = !hostExpanded && !this.
|
|
166
|
+
state.preview = !hostExpanded && !this.autoCompact(name);
|
|
140
167
|
return true;
|
|
141
168
|
}
|
|
142
169
|
|