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.
- 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 +402 -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,402 @@
|
|
|
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
|
-
|
|
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
|
-
|
|
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 shellWords(command: string, powershell = false): string[] {
|
|
81
|
+
const words: string[] = [];
|
|
82
|
+
let current = "";
|
|
83
|
+
let quote: "'" | '"' | undefined;
|
|
84
|
+
const input = command.trim();
|
|
85
|
+
for (let index = 0; index < input.length; index++) {
|
|
86
|
+
const character = input[index]!;
|
|
87
|
+
if (powershell && character === "`" && quote !== "'") {
|
|
88
|
+
const next = input[index + 1];
|
|
89
|
+
if (next !== undefined) {
|
|
90
|
+
current += next;
|
|
91
|
+
index++;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!powershell && character === "\\" && quote !== "'") {
|
|
96
|
+
const next = input[index + 1];
|
|
97
|
+
if (next === undefined) {
|
|
98
|
+
current += "\\";
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// In double quotes, Bash preserves backslashes before ordinary characters.
|
|
102
|
+
if (quote === '"' && !['$', '`', '"', "\\", "\n"].includes(next)) {
|
|
103
|
+
current += "\\";
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
current += next;
|
|
107
|
+
index++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (quote) {
|
|
111
|
+
if (character === quote) quote = undefined;
|
|
112
|
+
else current += character;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (character === "'" || character === '"') {
|
|
116
|
+
quote = character;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (/\s/u.test(character)) {
|
|
120
|
+
if (current) words.push(current);
|
|
121
|
+
current = "";
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
current += character;
|
|
125
|
+
}
|
|
126
|
+
if (current) words.push(current);
|
|
127
|
+
return words;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const SEARCH_OPTIONS_WITH_VALUES = new Set([
|
|
131
|
+
"-a", "--after-context", "-b", "--before-context", "-c", "--context", "--context-separator",
|
|
132
|
+
"-d", "--max-depth", "--dfa-size-limit", "-f", "--file", "-g", "--glob", "--iglob",
|
|
133
|
+
"-m", "--max-count", "--max-columns", "--path-separator", "--pre", "--pre-glob",
|
|
134
|
+
"--regex-size-limit", "--replace", "--sort", "--sortr", "-t", "--type", "--type-not",
|
|
135
|
+
"--encoding", "--engine", "--field-context-separator", "--field-match-separator", "--hostname-bin",
|
|
136
|
+
"--binary-files", "--exclude", "--exclude-from", "--exclude-dir", "--include", "--label",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
function searchPattern(executable: string, words: string[]): string | undefined {
|
|
140
|
+
const args = words.slice(1);
|
|
141
|
+
for (let index = 0; index < args.length; index++) {
|
|
142
|
+
const argument = args[index] ?? "";
|
|
143
|
+
const lower = argument.toLowerCase();
|
|
144
|
+
if (executable === "select-string") {
|
|
145
|
+
if (lower === "-pattern") return args[index + 1];
|
|
146
|
+
if (lower.startsWith("-pattern:")) return argument.slice(argument.indexOf(":") + 1);
|
|
147
|
+
if (!argument.startsWith("-")) return argument;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (lower === "-e" || lower === "--regexp") return args[index + 1];
|
|
151
|
+
if (lower.startsWith("--regexp=")) return argument.slice(argument.indexOf("=") + 1);
|
|
152
|
+
if (/^-e.+/u.test(argument)) return argument.slice(2);
|
|
153
|
+
if (argument === "--") return args[index + 1];
|
|
154
|
+
const optionName = lower.split("=", 1)[0] ?? lower;
|
|
155
|
+
if (SEARCH_OPTIONS_WITH_VALUES.has(optionName) && !argument.includes("=")) {
|
|
156
|
+
index++;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (argument.startsWith("-")) continue;
|
|
160
|
+
return argument;
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function summarizeTextSearch(executable: string, words: string[]): string {
|
|
166
|
+
const pattern = searchPattern(executable, words);
|
|
167
|
+
return pattern === undefined ? "Search text" : `Search text ${JSON.stringify(pattern)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatTargets(targets: string[]): string {
|
|
171
|
+
const visible = targets.slice(0, 2).map((target) => JSON.stringify(target)).join(", ");
|
|
172
|
+
const remaining = targets.length - 2;
|
|
173
|
+
return remaining > 0 ? `${visible} + ${remaining} more` : visible;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function plainOperands(words: string[]): string[] {
|
|
177
|
+
const separator = words.indexOf("--");
|
|
178
|
+
if (separator >= 0) return words.slice(separator + 1);
|
|
179
|
+
return words.slice(1).filter((word) => !word.startsWith("-"));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function optionValue(words: string[], names: string[]): string | undefined {
|
|
183
|
+
for (let index = 1; index < words.length; index++) {
|
|
184
|
+
const word = words[index] ?? "";
|
|
185
|
+
const lower = word.toLowerCase();
|
|
186
|
+
if (names.includes(lower)) return words[index + 1];
|
|
187
|
+
const prefix = names.find((name) => lower.startsWith(`${name}=`) || lower.startsWith(`${name}:`));
|
|
188
|
+
if (prefix) return word.slice(prefix.length + 1);
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const FD_OPTIONS_WITH_VALUES = new Set([
|
|
194
|
+
"-d", "--max-depth", "-e", "--extension", "-E", "--exclude", "-t", "--type",
|
|
195
|
+
"-x", "--exec", "-X", "--exec-batch", "-j", "--threads", "--base-directory",
|
|
196
|
+
"--changed-before", "--changed-within", "--format", "--glob", "--path-separator",
|
|
197
|
+
"--search-path", "--size", "--strip-cwd-prefix",
|
|
198
|
+
].map((option) => option.toLowerCase()));
|
|
199
|
+
|
|
200
|
+
function fdOperands(words: string[]): string[] {
|
|
201
|
+
const operands: string[] = [];
|
|
202
|
+
for (let index = 1; index < words.length; index++) {
|
|
203
|
+
const word = words[index] ?? "";
|
|
204
|
+
if (word === "--") return [...operands, ...words.slice(index + 1)];
|
|
205
|
+
const optionName = word.toLowerCase().split("=", 1)[0] ?? word.toLowerCase();
|
|
206
|
+
if (FD_OPTIONS_WITH_VALUES.has(optionName) && !word.includes("=")) {
|
|
207
|
+
index++;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!word.startsWith("-")) operands.push(word);
|
|
211
|
+
}
|
|
212
|
+
return operands;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function summarizeFileDiscovery(executable: string, words: string[]): string {
|
|
216
|
+
if (executable === "find") {
|
|
217
|
+
const paths: string[] = [];
|
|
218
|
+
for (let index = 1; index < words.length; index++) {
|
|
219
|
+
const word = words[index] ?? "";
|
|
220
|
+
if (word.startsWith("-") || word === "!" || word === "(") break;
|
|
221
|
+
paths.push(word);
|
|
222
|
+
}
|
|
223
|
+
const pattern = optionValue(words, ["-name", "-iname", "-path", "-ipath", "-regex"]);
|
|
224
|
+
const location = formatTargets(paths.length > 0 ? paths : ["."]);
|
|
225
|
+
return pattern ? `Find files ${JSON.stringify(pattern)} in ${location}` : `Find files in ${location}`;
|
|
226
|
+
}
|
|
227
|
+
if (executable === "fd") {
|
|
228
|
+
const operands = fdOperands(words);
|
|
229
|
+
const pattern = operands[0];
|
|
230
|
+
const locations = operands.slice(1);
|
|
231
|
+
if (!pattern) return "Find files";
|
|
232
|
+
return locations.length > 0
|
|
233
|
+
? `Find files ${JSON.stringify(pattern)} in ${formatTargets(locations)}`
|
|
234
|
+
: `Find files ${JSON.stringify(pattern)}`;
|
|
235
|
+
}
|
|
236
|
+
const explicitPath = optionValue(words, ["-path", "-literalpath"]);
|
|
237
|
+
const targets = explicitPath ? [explicitPath] : plainOperands(words);
|
|
238
|
+
return targets.length > 0 ? `List files ${formatTargets(targets)}` : "List files";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function summarizeFileOperation(executable: string, words: string[]): string | undefined {
|
|
242
|
+
const targets = plainOperands(words);
|
|
243
|
+
if (targets.length === 0) return undefined;
|
|
244
|
+
if (["rm", "del", "erase", "remove-item"].includes(executable)) return `Run ${executable} ${formatTargets(targets)}`;
|
|
245
|
+
if (["cat", "type", "get-content"].includes(executable)) return `Read file ${formatTargets(targets)}`;
|
|
246
|
+
if (["mkdir", "md", "new-item"].includes(executable)) return `Create ${formatTargets(targets)}`;
|
|
247
|
+
if (["rmdir", "rd"].includes(executable)) return `Remove directory ${formatTargets(targets)}`;
|
|
248
|
+
if (["touch"].includes(executable)) return `Touch ${formatTargets(targets)}`;
|
|
249
|
+
if (["cp", "copy", "copy-item"].includes(executable) && targets.length >= 2) {
|
|
250
|
+
return `Copy ${JSON.stringify(targets[0])} to ${JSON.stringify(targets.at(-1))}`;
|
|
251
|
+
}
|
|
252
|
+
if (["mv", "move", "move-item", "ren", "rename-item"].includes(executable) && targets.length >= 2) {
|
|
253
|
+
return `Move ${JSON.stringify(targets[0])} to ${JSON.stringify(targets.at(-1))}`;
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A calm collapsed label that describes intent while retaining primary command targets. */
|
|
259
|
+
export function summarizeShellCommand(name: string, command: string): string {
|
|
260
|
+
const allSegments = shellSegments(normalizeLineEndings(command));
|
|
261
|
+
const segments = allSegments.filter((segment) => !/^\s*(?:cd|pushd|popd)\b/iu.test(segment));
|
|
262
|
+
const primary = segments[0] ?? allSegments[0] ?? "";
|
|
263
|
+
if (!primary) return name === "powershell" ? "Prepare PowerShell command" : "Prepare shell command";
|
|
264
|
+
|
|
265
|
+
const words = shellWords(primary, name === "powershell");
|
|
266
|
+
while (words.length > 0) {
|
|
267
|
+
if (/^[A-Za-z_][A-Za-z\d_]*=.*/u.test(words[0] ?? "")
|
|
268
|
+
|| ["command", "env", "sudo", "time"].includes((words[0] ?? "").toLowerCase())) {
|
|
269
|
+
words.shift();
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
const executable = (words[0] ?? "").replace(/^.*[\\/]/u, "").replace(/\.(?:cmd|exe|ps1)$/iu, "").toLowerCase();
|
|
275
|
+
const action = (words[1] ?? "").toLowerCase();
|
|
276
|
+
const subject = words[2] ?? "";
|
|
277
|
+
|
|
278
|
+
let summary: string;
|
|
279
|
+
if (["npm", "pnpm", "yarn", "bun"].includes(executable) && action === "run" && subject) {
|
|
280
|
+
summary = `Run ${subject} task`;
|
|
281
|
+
} else if (["npm", "pnpm", "yarn", "bun"].includes(executable) && ["test", "check"].includes(action)) {
|
|
282
|
+
summary = action === "test" ? "Run tests" : "Run project checks";
|
|
283
|
+
} else if (["npm", "pnpm", "yarn", "bun"].includes(executable) && ["install", "i", "ci"].includes(action)) {
|
|
284
|
+
summary = "Install dependencies";
|
|
285
|
+
} else if (executable === "git" && action === "status") {
|
|
286
|
+
summary = "Check repository status";
|
|
287
|
+
} else if (executable === "git" && action === "diff") {
|
|
288
|
+
summary = "Review repository changes";
|
|
289
|
+
} else if (executable === "git" && action === "log") {
|
|
290
|
+
summary = "Review commit history";
|
|
291
|
+
} else if (executable === "git" && ["fetch", "pull", "push", "clone"].includes(action)) {
|
|
292
|
+
summary = `${action[0]!.toUpperCase()}${action.slice(1)} repository`;
|
|
293
|
+
} else if (["rg", "grep", "select-string"].includes(executable)) {
|
|
294
|
+
summary = summarizeTextSearch(executable, words);
|
|
295
|
+
} else if (["find", "fd", "get-childitem", "ls", "dir"].includes(executable)) {
|
|
296
|
+
summary = summarizeFileDiscovery(executable, words);
|
|
297
|
+
} else if (summarizeFileOperation(executable, words)) {
|
|
298
|
+
summary = summarizeFileOperation(executable, words)!;
|
|
299
|
+
} else if (["tsc", "mypy", "pyright"].includes(executable)) {
|
|
300
|
+
summary = "Check types";
|
|
301
|
+
} else if (["jest", "vitest", "pytest", "invoke-pester"].includes(executable)) {
|
|
302
|
+
summary = "Run tests";
|
|
303
|
+
} else if (executable) {
|
|
304
|
+
const display = executable.replace(/[-_]+/gu, " ");
|
|
305
|
+
summary = `Run ${display}`;
|
|
306
|
+
} else {
|
|
307
|
+
summary = name === "powershell" ? "Run PowerShell command" : "Run shell command";
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const remainingSteps = Math.max(0, segments.length - 1);
|
|
311
|
+
return remainingSteps > 0 ? `${summary} + ${remainingSteps} more ${remainingSteps === 1 ? "step" : "steps"}` : summary;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function getTextResult(result: AgentToolResult<unknown>): string {
|
|
315
|
+
const parts: string[] = [];
|
|
316
|
+
for (const item of result.content) {
|
|
317
|
+
if (item.type === "text") parts.push(item.text);
|
|
318
|
+
}
|
|
319
|
+
return normalizeLineEndings(parts.join("\n")).trimEnd();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
type ResultDetails = {
|
|
323
|
+
diff?: unknown;
|
|
324
|
+
truncation?: {
|
|
325
|
+
firstLineExceedsLimit?: boolean;
|
|
326
|
+
outputLines?: number;
|
|
327
|
+
};
|
|
328
|
+
matchLimitReached?: unknown;
|
|
329
|
+
resultLimitReached?: unknown;
|
|
330
|
+
entryLimitReached?: unknown;
|
|
331
|
+
linesTruncated?: unknown;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
function countTextLines(text: string): number {
|
|
335
|
+
const normalized = normalizeLineEndings(text).trimEnd();
|
|
336
|
+
if (!normalized) return 0;
|
|
337
|
+
let count = 1;
|
|
338
|
+
for (let index = 0; index < normalized.length; index++) {
|
|
339
|
+
if (normalized.charCodeAt(index) === 10) count++;
|
|
340
|
+
}
|
|
341
|
+
return count;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Keep only the lines that the edit actually removed or added; discard context and ellipses. */
|
|
345
|
+
export function getEditChanges(result: AgentToolResult<unknown>): string {
|
|
346
|
+
const diff = (result.details as ResultDetails | undefined)?.diff;
|
|
347
|
+
if (typeof diff !== "string") return "";
|
|
348
|
+
return normalizeLineEndings(diff)
|
|
349
|
+
.split("\n")
|
|
350
|
+
.filter((line) => line.startsWith("-") || line.startsWith("+"))
|
|
351
|
+
.join("\n")
|
|
352
|
+
.trimEnd();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function stripGeneratedFooter(name: string, text: string, details: ResultDetails | undefined): string {
|
|
356
|
+
if (name === "read") {
|
|
357
|
+
return text.replace(/\n\n\[(?:Showing lines |\d+ more lines in file\.)[^\n]*\]$/u, "");
|
|
358
|
+
}
|
|
359
|
+
const hasGeneratedFooter = details?.truncation?.outputLines !== undefined
|
|
360
|
+
|| details?.matchLimitReached !== undefined
|
|
361
|
+
|| details?.resultLimitReached !== undefined
|
|
362
|
+
|| details?.entryLimitReached !== undefined
|
|
363
|
+
|| details?.linesTruncated !== undefined;
|
|
364
|
+
return hasGeneratedFooter ? text.replace(/\n\n\[[^\n]*\]$/u, "") : text;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function formatResultLineSummary(
|
|
368
|
+
name: string,
|
|
369
|
+
args: ToolArgs,
|
|
370
|
+
result: AgentToolResult<unknown>,
|
|
371
|
+
output?: string,
|
|
372
|
+
): string | undefined {
|
|
373
|
+
const details = result.details as ResultDetails | undefined;
|
|
374
|
+
const text = name === "edit"
|
|
375
|
+
? getEditChanges(result)
|
|
376
|
+
: output ?? (name === "write"
|
|
377
|
+
? String(args.content ?? "")
|
|
378
|
+
: result.content.some((item) => item.type === "text")
|
|
379
|
+
? getTextResult(result)
|
|
380
|
+
: undefined);
|
|
381
|
+
if (text === undefined) return undefined;
|
|
382
|
+
const truncationLines = details?.truncation?.outputLines;
|
|
383
|
+
const lineCount = !details?.truncation?.firstLineExceedsLimit
|
|
384
|
+
&& typeof truncationLines === "number"
|
|
385
|
+
&& Number.isInteger(truncationLines)
|
|
386
|
+
&& truncationLines >= 0
|
|
387
|
+
? truncationLines
|
|
388
|
+
: countTextLines(stripGeneratedFooter(name, text, details));
|
|
389
|
+
return `${lineCount} ${lineCount === 1 ? "line" : "lines"}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function getFileOutput(
|
|
393
|
+
name: string,
|
|
394
|
+
args: ToolArgs,
|
|
395
|
+
result: AgentToolResult<unknown>,
|
|
396
|
+
isError: boolean,
|
|
397
|
+
): string {
|
|
398
|
+
if (isError || name === "read" || name === "grep" || name === "find" || name === "ls") {
|
|
399
|
+
return getTextResult(result);
|
|
400
|
+
}
|
|
401
|
+
return name === "write" ? String(args.content ?? "") : "";
|
|
402
|
+
}
|
|
@@ -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));
|