pi-compact-tools 0.6.3 → 0.7.8

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,158 @@
1
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { stripTerminalSequences, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { colorizeRgb, interpolateRgb, themeColorRgb, type Rgb } from "./compact-tools-color.ts";
4
+ import type { ToolArgs } from "./compact-tools-types.ts";
5
+
6
+ const GLOW_INTERVAL_MS = 80;
7
+ const WHITE: Rgb = { r: 255, g: 255, b: 255 };
8
+
9
+ const TOOL_PROGRESS_MESSAGES: Readonly<Record<string, string>> = {
10
+ read: "Reading file…",
11
+ write: "Writing file…",
12
+ edit: "Editing file…",
13
+ bash: "Running command…",
14
+ powershell: "Running command…",
15
+ grep: "Searching text…",
16
+ find: "Finding files…",
17
+ ls: "Listing files…",
18
+ };
19
+
20
+ function humanizeToolName(toolName: string, args: ToolArgs): string {
21
+ const routedName = (toolName === "mcp" || toolName.startsWith("mcp__")) && typeof args.tool === "string"
22
+ ? args.tool
23
+ : toolName;
24
+ const readableName = stripTerminalSequences(routedName)
25
+ .replace(/[\u0000-\u001f\u007f]/gu, " ")
26
+ .replace(/^mcp__/iu, "")
27
+ .replace(/[_-]mcp$/iu, "")
28
+ .replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2")
29
+ .replace(/([a-z\d])([A-Z])/gu, "$1 $2")
30
+ .replace(/[_-]+/gu, " ")
31
+ .replace(/\s+/gu, " ")
32
+ .trim();
33
+ return truncateToWidth(readableName, 40, "…");
34
+ }
35
+
36
+ /** Keep the working row calm and private; invocation details remain in the tool card. */
37
+ export function formatToolProgress(toolName: string, args: ToolArgs): string {
38
+ const builtInMessage = TOOL_PROGRESS_MESSAGES[toolName];
39
+ if (builtInMessage) return builtInMessage;
40
+ const displayName = humanizeToolName(toolName, args);
41
+ return displayName ? `Using ${displayName}…` : "Running tool…";
42
+ }
43
+
44
+ function glowRadius(length: number): number {
45
+ return Math.max(2, Math.min(8, Math.ceil(length / 4)));
46
+ }
47
+
48
+ /** Render a thinking-summary-colored label with a proportional white highlight sweeping left to right. */
49
+ export function glowProgressMessage(message: string, frame: number, theme: Theme): string {
50
+ const characters = [...message];
51
+ if (characters.length === 0) return "";
52
+ const radius = glowRadius(characters.length);
53
+ const center = frame % (characters.length + radius * 2) - radius;
54
+ const base = themeColorRgb(theme, "thinkingMax");
55
+ if (!base) {
56
+ return characters.map((character, index) =>
57
+ theme.fg(Math.abs(index - center) < radius ? "mdHeading" : "thinkingMax", character)).join("");
58
+ }
59
+ return characters.map((character, index) => {
60
+ const distance = Math.abs(index - center);
61
+ const strength = Math.max(0, 1 - distance / radius);
62
+ return colorizeRgb(theme, interpolateRgb(base, WHITE, strength), character);
63
+ }).join("");
64
+ }
65
+
66
+ function createGlowFrames(message: string, theme: Theme): string[] {
67
+ const length = [...message].length;
68
+ if (length === 0) return [""];
69
+ return Array.from({ length: length + glowRadius(length) * 2 }, (_, frame) =>
70
+ glowProgressMessage(message, frame, theme));
71
+ }
72
+
73
+ /** Owns Pi's single working row so animation remains continuous across thinking and tool execution. */
74
+ export class ProgressController {
75
+ private context: ExtensionContext | undefined;
76
+ private readonly activeTools = new Map<string, string>();
77
+ private message: string | undefined;
78
+ private frames: string[] = [];
79
+ private frame = 0;
80
+ private timer: ReturnType<typeof setInterval> | undefined;
81
+
82
+ constructor(pi: ExtensionAPI) {
83
+ pi.on("agent_start", () => this.setMessage("Thinking…"));
84
+ pi.on("message_update", (event) => {
85
+ const type = event.assistantMessageEvent.type;
86
+ if (type === "thinking_start" || type === "thinking_delta") this.setMessage("Thinking…");
87
+ else if (type === "text_start" || type === "text_delta") this.setMessage("Responding…");
88
+ else if (type === "toolcall_start" || type === "toolcall_delta") this.setMessage("Preparing tools…");
89
+ });
90
+ pi.on("tool_execution_start", (event) => {
91
+ const message = formatToolProgress(event.toolName, event.args ?? {});
92
+ this.activeTools.set(event.toolCallId, message);
93
+ this.setMessage(message);
94
+ });
95
+ pi.on("tool_execution_end", (event) => {
96
+ this.activeTools.delete(event.toolCallId);
97
+ const remaining = [...this.activeTools.values()].at(-1);
98
+ this.setMessage(remaining ?? "Processing results…");
99
+ });
100
+ pi.on("agent_end", () => {
101
+ this.activeTools.clear();
102
+ this.clearMessage();
103
+ });
104
+ }
105
+
106
+ bind(ctx: ExtensionContext): void {
107
+ this.stopAnimation();
108
+ this.context = ctx;
109
+ this.activeTools.clear();
110
+ this.message = undefined;
111
+ this.frames = [];
112
+ this.frame = 0;
113
+ ctx.ui.setWorkingVisible(true);
114
+ ctx.ui.setWorkingIndicator({ frames: [] });
115
+ }
116
+
117
+ dispose(): void {
118
+ this.stopAnimation();
119
+ this.activeTools.clear();
120
+ this.context?.ui.setWorkingMessage();
121
+ this.context?.ui.setWorkingIndicator();
122
+ this.message = undefined;
123
+ this.frames = [];
124
+ this.context = undefined;
125
+ }
126
+
127
+ private setMessage(message: string): void {
128
+ if (!this.context || (this.message === message && this.timer)) return;
129
+ this.message = message;
130
+ this.frames = createGlowFrames(message, this.context.ui.theme);
131
+ this.frame = 0;
132
+ this.renderMessage();
133
+ if (this.timer) return;
134
+ this.timer = setInterval(() => {
135
+ this.frame++;
136
+ this.renderMessage();
137
+ }, GLOW_INTERVAL_MS);
138
+ this.timer.unref?.();
139
+ }
140
+
141
+ private renderMessage(): void {
142
+ if (!this.message || !this.context || this.frames.length === 0) return;
143
+ this.context.ui.setWorkingMessage(this.frames[this.frame % this.frames.length]);
144
+ }
145
+
146
+ private clearMessage(): void {
147
+ this.stopAnimation();
148
+ this.message = undefined;
149
+ this.frames = [];
150
+ this.frame = 0;
151
+ this.context?.ui.setWorkingMessage();
152
+ }
153
+
154
+ private stopAnimation(): void {
155
+ if (this.timer) clearInterval(this.timer);
156
+ this.timer = undefined;
157
+ }
158
+ }
@@ -9,32 +9,26 @@ import type {
9
9
  } from "./compact-tools-types.ts";
10
10
 
11
11
  const MAX_TRACKED_ROWS = 2_000;
12
- const SPINNING_ROWS_KEY = Symbol.for("pi.compact-tools.spinning-rows");
12
+ const INDICATOR_INTERVAL_MS = 45;
13
13
  const EXECUTION_TIMINGS_KEY = Symbol.for("pi.compact-tools.execution-timings");
14
14
 
15
15
  type ExecutionTiming = { startedAt: number; endedAt?: number };
16
- type TrackedRow = { state: RowState; name: CompactToolName; invalidate: () => void };
17
16
  type TimedExecute = (...args: any[]) => Promise<AgentToolResult<unknown>>;
18
17
  type SharedState = typeof globalThis & {
19
- [SPINNING_ROWS_KEY]?: Set<RowState>;
20
18
  [EXECUTION_TIMINGS_KEY]?: Map<string, ExecutionTiming>;
21
19
  };
22
20
 
23
21
  export class ToolRuntime {
24
22
  private configValue = DEFAULT_CONFIG;
25
23
  private configRevision: object = {};
26
- private animateRows = false;
27
- private readonly spinningRows = new Set<RowState>();
28
24
  private readonly executionTimings: Map<string, ExecutionTiming>;
29
- private readonly trackedRows = new Map<string, TrackedRow>();
25
+ private readonly indicatorInvalidators = new Map<string, () => void>();
26
+ private indicatorFrame = 0;
27
+ private indicatorTimer: ReturnType<typeof setInterval> | undefined;
30
28
 
31
29
  constructor() {
32
30
  const shared = globalThis as SharedState;
33
- for (const state of shared[SPINNING_ROWS_KEY] ?? []) {
34
- if (state.timer) clearInterval(state.timer);
35
- }
36
31
  this.executionTimings = shared[EXECUTION_TIMINGS_KEY] ?? new Map<string, ExecutionTiming>();
37
- shared[SPINNING_ROWS_KEY] = this.spinningRows;
38
32
  shared[EXECUTION_TIMINGS_KEY] = this.executionTimings;
39
33
  }
40
34
 
@@ -42,14 +36,8 @@ export class ToolRuntime {
42
36
  return this.configValue;
43
37
  }
44
38
 
45
- get animatesRows(): boolean {
46
- return this.animateRows;
47
- }
48
-
49
- configure(config: CompactToolsConfig, animateRows: boolean): void {
50
- this.reset(false);
39
+ configure(config: CompactToolsConfig): void {
51
40
  this.configValue = config;
52
- this.animateRows = animateRows;
53
41
  this.configRevision = {};
54
42
  }
55
43
 
@@ -58,40 +46,35 @@ export class ToolRuntime {
58
46
  }
59
47
 
60
48
  reset(clearTimings: boolean): void {
61
- for (const state of this.spinningRows) this.stopSpinner(state);
62
- this.trackedRows.clear();
49
+ this.stopIndicators();
63
50
  if (clearTimings) this.clearTimings();
64
51
  }
65
52
 
66
- track(ctx: RenderContext, name: CompactToolName, state: RowState): void {
67
- this.trackedRows.delete(ctx.toolCallId);
68
- this.trackedRows.set(ctx.toolCallId, { state, name, invalidate: ctx.invalidate });
69
- if (this.trackedRows.size <= MAX_TRACKED_ROWS) return;
70
- const oldest = this.trackedRows.keys().next().value;
71
- if (oldest !== undefined) this.trackedRows.delete(oldest);
72
- }
73
-
74
- toggleTrackedRows(): "expanded" | "collapsed" | undefined {
75
- const rows: TrackedRow[] = [];
76
- for (const row of this.trackedRows.values()) {
77
- if (this.configValue.auto_compact[row.name] && row.state.hasResult) rows.push(row);
53
+ syncIndicator(toolCallId: string, running: boolean, invalidate: () => void): number {
54
+ if (!running) {
55
+ this.removeIndicator(toolCallId);
56
+ return 0;
78
57
  }
79
- if (rows.length === 0) return undefined;
80
- const expand = rows.some(({ state }) => !state.expanded);
81
- for (const { state, invalidate } of rows) {
82
- state.expanded = expand;
83
- invalidate();
58
+ this.indicatorInvalidators.set(toolCallId, invalidate);
59
+ if (!this.indicatorTimer) {
60
+ this.indicatorTimer = setInterval(() => {
61
+ this.indicatorFrame++;
62
+ for (const requestRender of this.indicatorInvalidators.values()) requestRender();
63
+ }, INDICATOR_INTERVAL_MS);
64
+ this.indicatorTimer.unref?.();
84
65
  }
85
- return expand ? "expanded" : "collapsed";
66
+ return this.indicatorFrame;
86
67
  }
87
68
 
88
69
  syncExpansion(state: RowState, hostExpanded: boolean, name: CompactToolName): boolean {
89
- const initialized = this.initializeExpansion(state, name);
90
- if (initialized || state.lastHostExpanded === undefined) {
91
- state.lastHostExpanded = hostExpanded;
92
- } else if (state.lastHostExpanded !== hostExpanded) {
70
+ const initialized = this.initializeExpansion(state, name, hostExpanded);
71
+ if (!initialized && state.lastHostExpanded !== hostExpanded) {
93
72
  state.lastHostExpanded = hostExpanded;
94
- state.expanded = !state.expanded;
73
+ state.expanded = hostExpanded;
74
+ // A non-auto-compacted row collapses back to its bounded preview. Hiding it
75
+ // here removes the clicked row from under the pointer, so the next click can
76
+ // accidentally hit the Thinking block that moved into the same coordinates.
77
+ state.preview = !hostExpanded && !this.configValue.auto_compact[name];
95
78
  }
96
79
  return state.expanded ?? false;
97
80
  }
@@ -111,8 +94,8 @@ export class ToolRuntime {
111
94
  const started = !ctx.argsComplete || ctx.executionStarted || running;
112
95
  if (started && state.startedAt === undefined) state.startedAt = Date.now();
113
96
  if (finished && state.startedAt !== undefined && state.endedAt === undefined) state.endedAt = Date.now();
97
+ if (finished) this.removeIndicator(ctx.toolCallId);
114
98
  this.persistTiming(state, ctx.toolCallId);
115
- this.syncSpinner(state, running, ctx.invalidate);
116
99
  return state;
117
100
  }
118
101
 
@@ -133,37 +116,28 @@ export class ToolRuntime {
133
116
  };
134
117
  }
135
118
 
136
- private initializeExpansion(state: RowState, name: CompactToolName): boolean {
137
- if (state.configRevision === this.configRevision) return false;
138
- state.configRevision = this.configRevision;
139
- state.expanded = !this.configValue.auto_compact[name];
140
- return true;
119
+ private removeIndicator(toolCallId: string): void {
120
+ this.indicatorInvalidators.delete(toolCallId);
121
+ if (this.indicatorInvalidators.size > 0 || !this.indicatorTimer) return;
122
+ clearInterval(this.indicatorTimer);
123
+ this.indicatorTimer = undefined;
124
+ this.indicatorFrame = 0;
141
125
  }
142
126
 
143
- private stopSpinner(state: RowState): void {
144
- if (state.timer) clearInterval(state.timer);
145
- state.timer = undefined;
146
- this.spinningRows.delete(state);
127
+ private stopIndicators(): void {
128
+ if (this.indicatorTimer) clearInterval(this.indicatorTimer);
129
+ this.indicatorTimer = undefined;
130
+ this.indicatorInvalidators.clear();
131
+ this.indicatorFrame = 0;
147
132
  }
148
133
 
149
- private syncSpinner(state: RowState, running: boolean, invalidate: () => void): void {
150
- const spinner = this.configValue.spinner;
151
- if (!running || !this.animateRows || spinner.frames.length < 2) {
152
- this.stopSpinner(state);
153
- return;
154
- }
155
- if (state.timer) return;
156
- state.frame ??= 0;
157
- state.timer = setInterval(() => {
158
- state.frame = ((state.frame ?? 0) + 1) % spinner.frames.length;
159
- try {
160
- invalidate();
161
- } catch {
162
- this.stopSpinner(state);
163
- }
164
- }, spinner.intervalMs);
165
- state.timer.unref?.();
166
- this.spinningRows.add(state);
134
+ private initializeExpansion(state: RowState, name: CompactToolName, hostExpanded = false): boolean {
135
+ if (state.configRevision === this.configRevision) return false;
136
+ state.configRevision = this.configRevision;
137
+ state.lastHostExpanded = hostExpanded;
138
+ state.expanded = hostExpanded;
139
+ state.preview = !hostExpanded && !this.configValue.auto_compact[name];
140
+ return true;
167
141
  }
168
142
 
169
143
  private restoreTiming(state: RowState, toolCallId: string): boolean {