pi-compact-tools 0.6.3 → 0.7.7
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 +151 -183
- package/examples/compact-tools-2.json +1 -4
- package/examples/compact-tools.json +1 -4
- package/extensions/compact-tools-color.ts +65 -0
- package/extensions/compact-tools-config.ts +7 -42
- package/extensions/compact-tools-core.ts +26 -0
- package/extensions/compact-tools-invocation.ts +228 -117
- package/extensions/compact-tools-layout.ts +23 -22
- package/extensions/compact-tools-progress.ts +158 -0
- package/extensions/compact-tools-runtime.ts +44 -70
- package/extensions/compact-tools-thinking.ts +280 -337
- package/extensions/compact-tools-types.ts +9 -8
- package/extensions/compact-tools.ts +350 -322
- package/package.json +64 -64
- package/schemas/compact-tools.schema.json +8 -24
- package/scripts/check-resources.mjs +13 -16
- package/extensions/compact-tools-input.ts +0 -8
|
@@ -1,117 +1,228 @@
|
|
|
1
|
-
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
3
|
-
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
4
|
-
|
|
5
|
-
function pathFrom(args: ToolArgs): string {
|
|
6
|
-
const value = args.path ?? args.file_path;
|
|
7
|
-
return typeof value === "string" ? normalizeLineEndings(value) : "";
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const INLINE_ARGUMENT_TOOLS = new Set(["grep", "find", "ls"]);
|
|
11
|
-
|
|
12
|
-
function omitArgument(name: string, key: string): boolean {
|
|
13
|
-
if (key === "path" || key === "file_path") return true;
|
|
14
|
-
if (name === "read" && (key === "offset" || key === "limit")) return true;
|
|
15
|
-
if (name === "write" && key === "content") return true;
|
|
16
|
-
return (name === "grep" || name === "find") && key === "pattern";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function collectArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
20
|
-
const details: ToolArgs = {};
|
|
21
|
-
for (const [key, value] of Object.entries(args)) {
|
|
22
|
-
if (value !== undefined && !omitArgument(name, key)) details[key] = value;
|
|
23
|
-
}
|
|
24
|
-
return details;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function getCallDetails(name: string, args: ToolArgs): string {
|
|
28
|
-
const path = pathFrom(args) || (INLINE_ARGUMENT_TOOLS.has(name) ? "." : "");
|
|
29
|
-
if (name === "read") return path;
|
|
30
|
-
if (name !== "grep" && name !== "find") return path;
|
|
31
|
-
const pattern = typeof args.pattern === "string" ? normalizeLineEndings(args.pattern) : "…";
|
|
32
|
-
return name === "grep" ? `/${pattern}/ in ${path}` : `${pattern} in ${path}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
36
|
-
if (name === "edit" || INLINE_ARGUMENT_TOOLS.has(name)) return {};
|
|
37
|
-
return collectArgumentDetails(name, args);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
)
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
}
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { normalizeLineEndings } from "./compact-tools-core.ts";
|
|
3
|
+
import type { ToolArgs } from "./compact-tools-types.ts";
|
|
4
|
+
|
|
5
|
+
function pathFrom(args: ToolArgs): string {
|
|
6
|
+
const value = args.path ?? args.file_path;
|
|
7
|
+
return typeof value === "string" ? normalizeLineEndings(value) : "";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const INLINE_ARGUMENT_TOOLS = new Set(["grep", "find", "ls"]);
|
|
11
|
+
|
|
12
|
+
function omitArgument(name: string, key: string): boolean {
|
|
13
|
+
if (key === "path" || key === "file_path") return true;
|
|
14
|
+
if (name === "read" && (key === "offset" || key === "limit")) return true;
|
|
15
|
+
if (name === "write" && key === "content") return true;
|
|
16
|
+
return (name === "grep" || name === "find") && key === "pattern";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function collectArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
20
|
+
const details: ToolArgs = {};
|
|
21
|
+
for (const [key, value] of Object.entries(args)) {
|
|
22
|
+
if (value !== undefined && !omitArgument(name, key)) details[key] = value;
|
|
23
|
+
}
|
|
24
|
+
return details;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getCallDetails(name: string, args: ToolArgs): string {
|
|
28
|
+
const path = pathFrom(args) || (INLINE_ARGUMENT_TOOLS.has(name) ? "." : "");
|
|
29
|
+
if (name === "read") return path;
|
|
30
|
+
if (name !== "grep" && name !== "find") return path;
|
|
31
|
+
const pattern = typeof args.pattern === "string" ? normalizeLineEndings(args.pattern) : "…";
|
|
32
|
+
return name === "grep" ? `/${pattern}/ in ${path}` : `${pattern} in ${path}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getArgumentDetails(name: string, args: ToolArgs): ToolArgs {
|
|
36
|
+
if (name === "edit" || INLINE_ARGUMENT_TOOLS.has(name)) return {};
|
|
37
|
+
return collectArgumentDetails(name, args);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shellSegments(command: string): string[] {
|
|
41
|
+
const segments: string[] = [];
|
|
42
|
+
let current = "";
|
|
43
|
+
let quote: "'" | '"' | "`" | undefined;
|
|
44
|
+
let escaped = false;
|
|
45
|
+
for (let index = 0; index < command.length; index++) {
|
|
46
|
+
const character = command[index]!;
|
|
47
|
+
if (escaped) {
|
|
48
|
+
current += character;
|
|
49
|
+
escaped = false;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (character === "\\" && quote !== "'") {
|
|
53
|
+
current += character;
|
|
54
|
+
escaped = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (quote) {
|
|
58
|
+
current += character;
|
|
59
|
+
if (character === quote) quote = undefined;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (character === "'" || character === '"' || character === "`") {
|
|
63
|
+
quote = character;
|
|
64
|
+
current += character;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const pair = command.slice(index, index + 2);
|
|
68
|
+
if (character === "\n" || character === ";" || pair === "&&" || pair === "||") {
|
|
69
|
+
if (current.trim()) segments.push(current.trim());
|
|
70
|
+
current = "";
|
|
71
|
+
if (pair === "&&" || pair === "||") index++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
current += character;
|
|
75
|
+
}
|
|
76
|
+
if (current.trim()) segments.push(current.trim());
|
|
77
|
+
return segments;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function unquote(value: string): string {
|
|
81
|
+
return value.replace(/^["']|["']$/gu, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A calm collapsed label that describes intent without exposing command arguments. */
|
|
85
|
+
export function summarizeShellCommand(name: string, command: string): string {
|
|
86
|
+
const allSegments = shellSegments(normalizeLineEndings(command));
|
|
87
|
+
const segments = allSegments.filter((segment) => !/^\s*(?:cd|pushd|popd)\b/iu.test(segment));
|
|
88
|
+
const primary = segments[0] ?? allSegments[0] ?? "";
|
|
89
|
+
if (!primary) return name === "powershell" ? "Prepare PowerShell command" : "Prepare shell command";
|
|
90
|
+
|
|
91
|
+
const words = primary.trim().split(/\s+/u).map(unquote);
|
|
92
|
+
while (words.length > 0) {
|
|
93
|
+
if (/^[A-Za-z_][A-Za-z\d_]*=.*/u.test(words[0] ?? "")
|
|
94
|
+
|| ["command", "env", "sudo", "time"].includes((words[0] ?? "").toLowerCase())) {
|
|
95
|
+
words.shift();
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const executable = (words[0] ?? "").replace(/^.*[\\/]/u, "").replace(/\.(?:cmd|exe|ps1)$/iu, "").toLowerCase();
|
|
101
|
+
const action = (words[1] ?? "").toLowerCase();
|
|
102
|
+
const subject = words[2] ?? "";
|
|
103
|
+
|
|
104
|
+
let summary: string;
|
|
105
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executable) && action === "run" && subject) {
|
|
106
|
+
summary = `Run ${subject} task`;
|
|
107
|
+
} else if (["npm", "pnpm", "yarn", "bun"].includes(executable) && ["test", "check"].includes(action)) {
|
|
108
|
+
summary = action === "test" ? "Run tests" : "Run project checks";
|
|
109
|
+
} else if (["npm", "pnpm", "yarn", "bun"].includes(executable) && ["install", "i", "ci"].includes(action)) {
|
|
110
|
+
summary = "Install dependencies";
|
|
111
|
+
} else if (executable === "git" && action === "status") {
|
|
112
|
+
summary = "Check repository status";
|
|
113
|
+
} else if (executable === "git" && action === "diff") {
|
|
114
|
+
summary = "Review repository changes";
|
|
115
|
+
} else if (executable === "git" && action === "log") {
|
|
116
|
+
summary = "Review commit history";
|
|
117
|
+
} else if (executable === "git" && ["fetch", "pull", "push", "clone"].includes(action)) {
|
|
118
|
+
summary = `${action[0]!.toUpperCase()}${action.slice(1)} repository`;
|
|
119
|
+
} else if (["rg", "grep", "select-string"].includes(executable)) {
|
|
120
|
+
summary = "Search text";
|
|
121
|
+
} else if (["find", "fd", "get-childitem", "ls", "dir"].includes(executable)) {
|
|
122
|
+
summary = executable === "ls" || executable === "dir" || executable === "get-childitem" ? "List files" : "Find files";
|
|
123
|
+
} else if (["cat", "type", "get-content"].includes(executable)) {
|
|
124
|
+
summary = "Read file content";
|
|
125
|
+
} else if (["tsc", "mypy", "pyright"].includes(executable)) {
|
|
126
|
+
summary = "Check types";
|
|
127
|
+
} else if (["jest", "vitest", "pytest", "invoke-pester"].includes(executable)) {
|
|
128
|
+
summary = "Run tests";
|
|
129
|
+
} else if (executable) {
|
|
130
|
+
const display = executable.replace(/[-_]+/gu, " ");
|
|
131
|
+
summary = `Run ${display}`;
|
|
132
|
+
} else {
|
|
133
|
+
summary = name === "powershell" ? "Run PowerShell command" : "Run shell command";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const remainingSteps = Math.max(0, segments.length - 1);
|
|
137
|
+
return remainingSteps > 0 ? `${summary} + ${remainingSteps} more ${remainingSteps === 1 ? "step" : "steps"}` : summary;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
141
|
+
const parts: string[] = [];
|
|
142
|
+
for (const item of result.content) {
|
|
143
|
+
if (item.type === "text") parts.push(item.text);
|
|
144
|
+
}
|
|
145
|
+
return normalizeLineEndings(parts.join("\n")).trimEnd();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type ResultDetails = {
|
|
149
|
+
diff?: unknown;
|
|
150
|
+
truncation?: {
|
|
151
|
+
firstLineExceedsLimit?: boolean;
|
|
152
|
+
outputLines?: number;
|
|
153
|
+
};
|
|
154
|
+
matchLimitReached?: unknown;
|
|
155
|
+
resultLimitReached?: unknown;
|
|
156
|
+
entryLimitReached?: unknown;
|
|
157
|
+
linesTruncated?: unknown;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function countTextLines(text: string): number {
|
|
161
|
+
const normalized = normalizeLineEndings(text).trimEnd();
|
|
162
|
+
if (!normalized) return 0;
|
|
163
|
+
let count = 1;
|
|
164
|
+
for (let index = 0; index < normalized.length; index++) {
|
|
165
|
+
if (normalized.charCodeAt(index) === 10) count++;
|
|
166
|
+
}
|
|
167
|
+
return count;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Keep only the lines that the edit actually removed or added; discard context and ellipses. */
|
|
171
|
+
export function getEditChanges(result: AgentToolResult<unknown>): string {
|
|
172
|
+
const diff = (result.details as ResultDetails | undefined)?.diff;
|
|
173
|
+
if (typeof diff !== "string") return "";
|
|
174
|
+
return normalizeLineEndings(diff)
|
|
175
|
+
.split("\n")
|
|
176
|
+
.filter((line) => line.startsWith("-") || line.startsWith("+"))
|
|
177
|
+
.join("\n")
|
|
178
|
+
.trimEnd();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function stripGeneratedFooter(name: string, text: string, details: ResultDetails | undefined): string {
|
|
182
|
+
if (name === "read") {
|
|
183
|
+
return text.replace(/\n\n\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\]$/u, "");
|
|
184
|
+
}
|
|
185
|
+
const hasGeneratedFooter = details?.truncation?.outputLines !== undefined
|
|
186
|
+
|| details?.matchLimitReached !== undefined
|
|
187
|
+
|| details?.resultLimitReached !== undefined
|
|
188
|
+
|| details?.entryLimitReached !== undefined
|
|
189
|
+
|| details?.linesTruncated !== undefined;
|
|
190
|
+
return hasGeneratedFooter ? text.replace(/\n\n\[[^\n]*\]$/u, "") : text;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function formatResultLineSummary(
|
|
194
|
+
name: string,
|
|
195
|
+
args: ToolArgs,
|
|
196
|
+
result: AgentToolResult<unknown>,
|
|
197
|
+
output?: string,
|
|
198
|
+
): string | undefined {
|
|
199
|
+
const details = result.details as ResultDetails | undefined;
|
|
200
|
+
const text = name === "edit"
|
|
201
|
+
? getEditChanges(result)
|
|
202
|
+
: output ?? (name === "write"
|
|
203
|
+
? String(args.content ?? "")
|
|
204
|
+
: result.content.some((item) => item.type === "text")
|
|
205
|
+
? getTextResult(result)
|
|
206
|
+
: undefined);
|
|
207
|
+
if (text === undefined) return undefined;
|
|
208
|
+
const truncationLines = details?.truncation?.outputLines;
|
|
209
|
+
const lineCount = !details?.truncation?.firstLineExceedsLimit
|
|
210
|
+
&& typeof truncationLines === "number"
|
|
211
|
+
&& Number.isInteger(truncationLines)
|
|
212
|
+
&& truncationLines >= 0
|
|
213
|
+
? truncationLines
|
|
214
|
+
: countTextLines(stripGeneratedFooter(name, text, details));
|
|
215
|
+
return `${lineCount} ${lineCount === 1 ? "line" : "lines"}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function getFileOutput(
|
|
219
|
+
name: string,
|
|
220
|
+
args: ToolArgs,
|
|
221
|
+
result: AgentToolResult<unknown>,
|
|
222
|
+
isError: boolean,
|
|
223
|
+
): string {
|
|
224
|
+
if (isError || name === "read" || name === "grep" || name === "find" || name === "ls") {
|
|
225
|
+
return getTextResult(result);
|
|
226
|
+
}
|
|
227
|
+
return name === "write" ? String(args.content ?? "") : "";
|
|
228
|
+
}
|
|
@@ -117,28 +117,6 @@ export function renderToolCall(title: string, details: string | undefined, theme
|
|
|
117
117
|
});
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
export function wrapEditResult(component: Component, theme: Theme): Component {
|
|
121
|
-
return new CachedComponent((width) => {
|
|
122
|
-
const lines = component.render(Math.max(1, width - 3));
|
|
123
|
-
const first = lines.findIndex((line) => visibleWidth(line.trim()) > 0);
|
|
124
|
-
if (first < 0) return [];
|
|
125
|
-
const contentLines = lines.slice(first);
|
|
126
|
-
let commonIndent = Number.POSITIVE_INFINITY;
|
|
127
|
-
for (const line of contentLines) {
|
|
128
|
-
const plain = stripTerminalSequences(line);
|
|
129
|
-
if (plain.trim().length === 0) continue;
|
|
130
|
-
commonIndent = Math.min(commonIndent, plain.match(/^ */)?.[0].length ?? 0);
|
|
131
|
-
if (commonIndent === 0) break;
|
|
132
|
-
}
|
|
133
|
-
if (!Number.isFinite(commonIndent)) commonIndent = 0;
|
|
134
|
-
const prefix = theme.fg("border", " │ ");
|
|
135
|
-
return contentLines.map((line) => {
|
|
136
|
-
const content = sliceByColumn(line, commonIndent, Math.max(0, visibleWidth(line) - commonIndent), true);
|
|
137
|
-
return `${prefix}${content}`;
|
|
138
|
-
});
|
|
139
|
-
}, () => component.invalidate?.());
|
|
140
|
-
}
|
|
141
|
-
|
|
142
120
|
export function styleMultiline(text: string, style: (line: string) => string): string {
|
|
143
121
|
return normalizeLineEndings(text).split("\n").map(style).join("\n");
|
|
144
122
|
}
|
|
@@ -151,6 +129,29 @@ export function renderOutput(output: string, theme: Theme, isError: boolean): Co
|
|
|
151
129
|
return prefixedText(styled, theme.fg("border", " │ "));
|
|
152
130
|
}
|
|
153
131
|
|
|
132
|
+
/** Render an edit's changed lines without unchanged context, hunk metadata, or result prose. */
|
|
133
|
+
export function renderEditChanges(changes: string, theme: Theme): Component | undefined {
|
|
134
|
+
const normalized = normalizeLineEndings(changes).trimEnd();
|
|
135
|
+
if (!normalized) return undefined;
|
|
136
|
+
const styled = normalized.split("\n").map((line) =>
|
|
137
|
+
theme.fg(line.startsWith("+") ? "success" : "error", line)
|
|
138
|
+
).join("\n");
|
|
139
|
+
return prefixedText(styled, theme.fg("border", " │ "));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Limit a result preview by rendered rows while preserving the full source component for expansion. */
|
|
143
|
+
export function limitComponentLines(component: Component, maximumLines: number, theme: Theme): Component {
|
|
144
|
+
return new CachedComponent((width) => {
|
|
145
|
+
const lines = component.render(width);
|
|
146
|
+
if (lines.length <= maximumLines) return lines;
|
|
147
|
+
const omitted = lines.length - maximumLines;
|
|
148
|
+
return [
|
|
149
|
+
...lines.slice(0, maximumLines),
|
|
150
|
+
theme.fg("border", " │ ") + theme.fg("borderAccent", `… ${omitted} more ${omitted === 1 ? "line" : "lines"}`),
|
|
151
|
+
];
|
|
152
|
+
}, () => component.invalidate?.());
|
|
153
|
+
}
|
|
154
|
+
|
|
154
155
|
export function renderArguments(args: ToolArgs, theme: Theme): Component {
|
|
155
156
|
const json = JSON.stringify(args, null, 2) ?? "{}";
|
|
156
157
|
const styled = styleMultiline(json, (line) => theme.fg("toolOutput", line));
|
|
@@ -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
|
+
}
|