pi-compact-tools 0.9.3 → 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.
@@ -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
- private timer: ReturnType<typeof setInterval> | undefined;
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) {
@@ -166,17 +167,16 @@ export class ProgressController {
166
167
  }
167
168
 
168
169
  private setMessage(message: string): void {
169
- if (!this.context || (this.message === message && this.timer)) return;
170
+ if (!this.context || (this.message === message && this.stopTicking)) return;
170
171
  this.message = message;
171
172
  this.frames = createGlowFrames(message, this.context.ui.theme, this.level);
172
173
  this.frame = 0;
173
174
  this.renderMessage();
174
- if (this.timer) return;
175
- this.timer = setInterval(() => {
175
+ if (this.stopTicking) return;
176
+ this.stopTicking = onTick(() => {
176
177
  this.frame++;
177
178
  this.renderMessage();
178
- }, GLOW_INTERVAL_MS);
179
- this.timer.unref?.();
179
+ });
180
180
  }
181
181
 
182
182
  private renderMessage(): void {
@@ -193,7 +193,7 @@ export class ProgressController {
193
193
  }
194
194
 
195
195
  private stopAnimation(): void {
196
- if (this.timer) clearInterval(this.timer);
197
- this.timer = undefined;
196
+ this.stopTicking?.();
197
+ this.stopTicking = undefined;
198
198
  }
199
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 type {
4
- BuiltInDefinition,
5
- CompactToolName,
6
- CompactToolsConfig,
7
- RenderContext,
8
- RowState,
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: CompactToolName): boolean {
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.configValue.auto_compact[name];
81
+ state.preview = !hostExpanded && !this.autoCompact(name);
78
82
  }
79
83
  return state.expanded ?? false;
80
84
  }
81
85
 
82
- setResultAvailable(state: RowState, name: CompactToolName, available: boolean): void {
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
- private initializeExpansion(state: RowState, name: CompactToolName, hostExpanded = false): boolean {
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.configValue.auto_compact[name];
166
+ state.preview = !hostExpanded && !this.autoCompact(name);
140
167
  return true;
141
168
  }
142
169