@howaboua/pi-codex-conversion 1.0.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/LICENSE +21 -0
- package/README.md +99 -0
- package/available-tools.png +0 -0
- package/package.json +62 -0
- package/src/adapter/codex-model.ts +22 -0
- package/src/adapter/tool-set.ts +7 -0
- package/src/index.ts +112 -0
- package/src/patch/core.ts +220 -0
- package/src/patch/parser.ts +422 -0
- package/src/patch/paths.ts +56 -0
- package/src/patch/types.ts +44 -0
- package/src/prompt/build-system-prompt.ts +111 -0
- package/src/shell/parse.ts +297 -0
- package/src/shell/summary.ts +62 -0
- package/src/shell/tokenize.ts +125 -0
- package/src/shell/types.ts +10 -0
- package/src/tools/apply-patch-tool.ts +84 -0
- package/src/tools/codex-rendering.ts +95 -0
- package/src/tools/exec-command-state.ts +43 -0
- package/src/tools/exec-command-tool.ts +107 -0
- package/src/tools/exec-session-manager.ts +478 -0
- package/src/tools/unified-exec-format.ts +28 -0
- package/src/tools/view-image-tool.ts +171 -0
- package/src/tools/write-stdin-tool.ts +145 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import type { ShellAction } from "./types.ts";
|
|
2
|
+
import { isAbsoluteLike, joinPaths, shortDisplayPath } from "./tokenize.ts";
|
|
3
|
+
|
|
4
|
+
export function parseShellPart(tokens: string[], cwd?: string): ShellAction | null {
|
|
5
|
+
if (tokens.length === 0) return null;
|
|
6
|
+
|
|
7
|
+
if (tokens[0] === "cd") {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const parsed = parseMainTokens(tokens);
|
|
12
|
+
if (parsed === null) return null;
|
|
13
|
+
if (parsed.kind === "run") return parsed;
|
|
14
|
+
|
|
15
|
+
if (parsed.kind === "read" && cwd && !isAbsoluteLike(parsed.path)) {
|
|
16
|
+
return {
|
|
17
|
+
...parsed,
|
|
18
|
+
path: joinPaths(cwd, parsed.path),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function nextCwd(currentCwd: string | undefined, tokens: string[]): string | undefined {
|
|
25
|
+
if (tokens[0] !== "cd") return currentCwd;
|
|
26
|
+
const target = cdTarget(tokens.slice(1));
|
|
27
|
+
if (!target) return currentCwd;
|
|
28
|
+
return currentCwd ? joinPaths(currentCwd, target) : target;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseMainTokens(tokens: string[]): ShellAction | null {
|
|
32
|
+
const [head, ...tail] = tokens;
|
|
33
|
+
if (!head) return null;
|
|
34
|
+
|
|
35
|
+
if (head === "echo" || head === "true") {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (head === "ls" || head === "eza" || head === "exa" || head === "tree" || head === "du") {
|
|
40
|
+
const flagsWithValues =
|
|
41
|
+
head === "tree"
|
|
42
|
+
? ["-L", "-P", "-I", "--charset", "--filelimit", "--sort"]
|
|
43
|
+
: head === "du"
|
|
44
|
+
? ["-d", "--max-depth", "-B", "--block-size", "--exclude", "--time-style"]
|
|
45
|
+
: head === "ls"
|
|
46
|
+
? ["-I", "--ignore", "-w", "--block-size", "--format", "--time-style", "--color"]
|
|
47
|
+
: ["-I", "--ignore-glob", "--color", "--sort", "--time-style", "--time"];
|
|
48
|
+
const path = firstNonFlagOperand(tail, flagsWithValues);
|
|
49
|
+
return { kind: "list", command: tokens.join(" "), path: path ? shortDisplayPath(path) : undefined };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (head === "rg" || head === "rga" || head === "ripgrep-all") {
|
|
53
|
+
const hasFilesFlag = tail.includes("--files");
|
|
54
|
+
const candidates = skipFlagValues(tail, [
|
|
55
|
+
"-g",
|
|
56
|
+
"--glob",
|
|
57
|
+
"--iglob",
|
|
58
|
+
"-t",
|
|
59
|
+
"--type",
|
|
60
|
+
"--type-add",
|
|
61
|
+
"--type-not",
|
|
62
|
+
"-m",
|
|
63
|
+
"--max-count",
|
|
64
|
+
"-A",
|
|
65
|
+
"-B",
|
|
66
|
+
"-C",
|
|
67
|
+
"--context",
|
|
68
|
+
"--max-depth",
|
|
69
|
+
]);
|
|
70
|
+
const nonFlags = candidates.filter((token) => !token.startsWith("-"));
|
|
71
|
+
if (hasFilesFlag) {
|
|
72
|
+
const path = nonFlags[0];
|
|
73
|
+
return { kind: "list", command: tokens.join(" "), path: path ? shortDisplayPath(path) : undefined };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
kind: "search",
|
|
77
|
+
command: tokens.join(" "),
|
|
78
|
+
query: nonFlags[0],
|
|
79
|
+
path: nonFlags[1] ? shortDisplayPath(nonFlags[1]) : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (head === "git" && tail[0] === "grep") {
|
|
84
|
+
return parseGrepLike(tokens.join(" "), tail.slice(1));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (head === "git" && tail[0] === "ls-files") {
|
|
88
|
+
const path = firstNonFlagOperand(tail.slice(1), ["--exclude", "--exclude-from", "--pathspec-from-file"]);
|
|
89
|
+
return { kind: "list", command: tokens.join(" "), path: path ? shortDisplayPath(path) : undefined };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (head === "fd") {
|
|
93
|
+
const nonFlags = skipFlagValues(tail, ["-g", "--glob", "-e", "--extension", "-E", "--exclude"]).filter(
|
|
94
|
+
(token) => !token.startsWith("-"),
|
|
95
|
+
);
|
|
96
|
+
if (nonFlags.length === 0) {
|
|
97
|
+
return { kind: "list", command: tokens.join(" ") };
|
|
98
|
+
}
|
|
99
|
+
if (nonFlags.length === 1) {
|
|
100
|
+
return { kind: "list", command: tokens.join(" "), path: shortDisplayPath(nonFlags[0]) };
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
kind: "search",
|
|
104
|
+
command: tokens.join(" "),
|
|
105
|
+
query: nonFlags[0],
|
|
106
|
+
path: shortDisplayPath(nonFlags[1]),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (head === "find") {
|
|
111
|
+
const path = tail.find((token) => !token.startsWith("-"));
|
|
112
|
+
const nameIndex = tail.findIndex((token) => token === "-name" || token === "-iname");
|
|
113
|
+
const query = nameIndex !== -1 ? tail[nameIndex + 1] : undefined;
|
|
114
|
+
if (query) {
|
|
115
|
+
return {
|
|
116
|
+
kind: "search",
|
|
117
|
+
command: tokens.join(" "),
|
|
118
|
+
query,
|
|
119
|
+
path: path ? shortDisplayPath(path) : undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { kind: "list", command: tokens.join(" "), path: path ? shortDisplayPath(path) : undefined };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (head === "grep" || head === "egrep" || head === "fgrep" || head === "ag" || head === "ack" || head === "pt") {
|
|
126
|
+
return parseGrepLike(tokens.join(" "), tail);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (head === "cat" || head === "bat" || head === "batcat" || head === "less" || head === "more") {
|
|
130
|
+
const path = singleNonFlagOperand(tail, [
|
|
131
|
+
"--theme",
|
|
132
|
+
"--language",
|
|
133
|
+
"--style",
|
|
134
|
+
"--terminal-width",
|
|
135
|
+
"--tabs",
|
|
136
|
+
"--line-range",
|
|
137
|
+
"--map-syntax",
|
|
138
|
+
"-p",
|
|
139
|
+
"-P",
|
|
140
|
+
"-x",
|
|
141
|
+
"-y",
|
|
142
|
+
"-z",
|
|
143
|
+
"-j",
|
|
144
|
+
"--pattern",
|
|
145
|
+
"--prompt",
|
|
146
|
+
"--shift",
|
|
147
|
+
"--jump-target",
|
|
148
|
+
]);
|
|
149
|
+
return path ? readAction(tokens.join(" "), path) : { kind: "run", command: tokens.join(" ") };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (head === "head") {
|
|
153
|
+
const path = readPathFromHeadTail(tail, "head");
|
|
154
|
+
return path ? readAction(tokens.join(" "), path) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (head === "tail") {
|
|
158
|
+
const path = readPathFromHeadTail(tail, "tail");
|
|
159
|
+
return path ? readAction(tokens.join(" "), path) : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (head === "nl") {
|
|
163
|
+
const candidates = skipFlagValues(tail, ["-s", "-w", "-v", "-i", "-b"]);
|
|
164
|
+
const path = candidates.find((token) => !token.startsWith("-"));
|
|
165
|
+
return path ? readAction(tokens.join(" "), path) : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (head === "sed") {
|
|
169
|
+
const path = sedReadPath(tail);
|
|
170
|
+
return path ? readAction(tokens.join(" "), path) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { kind: "run", command: tokens.join(" ") };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function parseGrepLike(command: string, tail: string[]): ShellAction {
|
|
177
|
+
const candidates = skipFlagValues(tail, [
|
|
178
|
+
"-e",
|
|
179
|
+
"-f",
|
|
180
|
+
"-g",
|
|
181
|
+
"-G",
|
|
182
|
+
"--glob",
|
|
183
|
+
"--include",
|
|
184
|
+
"--exclude",
|
|
185
|
+
"--exclude-dir",
|
|
186
|
+
"--exclude-from",
|
|
187
|
+
"--ignore-dir",
|
|
188
|
+
]);
|
|
189
|
+
const nonFlags = candidates.filter((token) => !token.startsWith("-"));
|
|
190
|
+
return {
|
|
191
|
+
kind: "search",
|
|
192
|
+
command,
|
|
193
|
+
query: nonFlags[0],
|
|
194
|
+
path: nonFlags[1] ? shortDisplayPath(nonFlags[1]) : undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readAction(command: string, path: string): ShellAction {
|
|
199
|
+
return {
|
|
200
|
+
kind: "read",
|
|
201
|
+
command,
|
|
202
|
+
name: shortDisplayPath(path),
|
|
203
|
+
path,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function readPathFromHeadTail(args: string[], tool: "head" | "tail"): string | undefined {
|
|
208
|
+
if (args.length === 1 && !args[0].startsWith("-")) {
|
|
209
|
+
return args[0];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const tokens = [...args];
|
|
213
|
+
let index = 0;
|
|
214
|
+
while (index < tokens.length) {
|
|
215
|
+
const token = tokens[index];
|
|
216
|
+
if (!token) break;
|
|
217
|
+
if (!token.startsWith("-")) {
|
|
218
|
+
return token;
|
|
219
|
+
}
|
|
220
|
+
if ((token === "-n" || token === "-c") && index + 1 < tokens.length) {
|
|
221
|
+
index += 2;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if ((tool === "head" || tool === "tail") && /^-[nc].+/.test(token)) {
|
|
225
|
+
index += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
index += 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sedReadPath(args: string[]): string | undefined {
|
|
235
|
+
if (!args.includes("-n")) return undefined;
|
|
236
|
+
|
|
237
|
+
let hasRangeScript = false;
|
|
238
|
+
for (let index = 0; index < args.length; index++) {
|
|
239
|
+
const token = args[index];
|
|
240
|
+
if ((token === "-e" || token === "--expression") && isValidSedRange(args[index + 1])) {
|
|
241
|
+
hasRangeScript = true;
|
|
242
|
+
}
|
|
243
|
+
if (!token.startsWith("-") && isValidSedRange(token)) {
|
|
244
|
+
hasRangeScript = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!hasRangeScript) return undefined;
|
|
248
|
+
|
|
249
|
+
const candidates = skipFlagValues(args, ["-e", "-f", "--expression", "--file"]);
|
|
250
|
+
const nonFlags = candidates.filter((token) => !token.startsWith("-"));
|
|
251
|
+
if (nonFlags.length === 0) return undefined;
|
|
252
|
+
if (isValidSedRange(nonFlags[0])) {
|
|
253
|
+
return nonFlags[1];
|
|
254
|
+
}
|
|
255
|
+
return nonFlags[0];
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isValidSedRange(value: string | undefined): boolean {
|
|
259
|
+
if (!value || !value.endsWith("p")) return false;
|
|
260
|
+
const core = value.slice(0, -1);
|
|
261
|
+
const parts = core.split(",");
|
|
262
|
+
return parts.length >= 1 && parts.length <= 2 && parts.every((part) => part.length > 0 && /^\d+$/.test(part));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function firstNonFlagOperand(args: string[], flagsWithValues: string[]): string | undefined {
|
|
266
|
+
return skipFlagValues(args, flagsWithValues).find((token) => !token.startsWith("-"));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function singleNonFlagOperand(args: string[], flagsWithValues: string[]): string | undefined {
|
|
270
|
+
const nonFlags = skipFlagValues(args, flagsWithValues).filter((token) => !token.startsWith("-"));
|
|
271
|
+
return nonFlags.length === 1 ? nonFlags[0] : undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function skipFlagValues(args: string[], flagsWithValues: string[]): string[] {
|
|
275
|
+
const out: string[] = [];
|
|
276
|
+
for (let index = 0; index < args.length; index++) {
|
|
277
|
+
const token = args[index];
|
|
278
|
+
out.push(token);
|
|
279
|
+
if (flagsWithValues.includes(token) && index + 1 < args.length) {
|
|
280
|
+
index += 1;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function cdTarget(args: string[]): string | undefined {
|
|
287
|
+
for (let index = 0; index < args.length; index++) {
|
|
288
|
+
const token = args[index];
|
|
289
|
+
if (token === "--") {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (!token.startsWith("-")) {
|
|
293
|
+
return token;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { parseShellPart, nextCwd } from "./parse.ts";
|
|
2
|
+
import { splitOnConnectors, normalizeTokens, shellSplit } from "./tokenize.ts";
|
|
3
|
+
import type { CommandSummary, ShellAction } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type { CommandSummary, ShellAction } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
// The adapter only masks commands when every parsed segment still looks like
|
|
8
|
+
// repository exploration. The moment we see an actual side-effectful run, we
|
|
9
|
+
// fall back to raw command rendering so the UI does not hide meaningful work.
|
|
10
|
+
export function summarizeShellCommand(command: string): CommandSummary {
|
|
11
|
+
const normalized = normalizeTokens(shellSplit(command));
|
|
12
|
+
const parts = splitOnConnectors(normalized);
|
|
13
|
+
const fallback = runSummary(command);
|
|
14
|
+
|
|
15
|
+
if (parts.length === 0) {
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const actions: ShellAction[] = [];
|
|
20
|
+
let cwd: string | undefined;
|
|
21
|
+
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
if (part.length === 0) continue;
|
|
24
|
+
|
|
25
|
+
cwd = nextCwd(cwd, part);
|
|
26
|
+
const parsed = parseShellPart(part, cwd);
|
|
27
|
+
if (parsed === null) continue;
|
|
28
|
+
if (parsed.kind === "run") {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
actions.push(parsed);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const deduped = dedupeActions(actions);
|
|
35
|
+
if (deduped.length === 0) {
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
maskAsExplored: deduped.every((action) => action.kind !== "run"),
|
|
41
|
+
actions: deduped,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function runSummary(command: string): CommandSummary {
|
|
46
|
+
return {
|
|
47
|
+
maskAsExplored: false,
|
|
48
|
+
actions: [{ kind: "run", command: command.trim() || command }],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function dedupeActions(actions: ShellAction[]): ShellAction[] {
|
|
53
|
+
const deduped: ShellAction[] = [];
|
|
54
|
+
for (const action of actions) {
|
|
55
|
+
const previous = deduped[deduped.length - 1];
|
|
56
|
+
if (previous && JSON.stringify(previous) === JSON.stringify(action)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
deduped.push(action);
|
|
60
|
+
}
|
|
61
|
+
return deduped;
|
|
62
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Shell tokenization is intentionally lightweight: it only needs enough fidelity
|
|
2
|
+
// to classify obvious read/list/search commands for compact Codex-style rendering.
|
|
3
|
+
export function shellSplit(input: string): string[] {
|
|
4
|
+
const tokens: string[] = [];
|
|
5
|
+
let current = "";
|
|
6
|
+
let quote: "'" | '"' | undefined;
|
|
7
|
+
let escaping = false;
|
|
8
|
+
|
|
9
|
+
const pushCurrent = () => {
|
|
10
|
+
if (current.length > 0) {
|
|
11
|
+
tokens.push(current);
|
|
12
|
+
current = "";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
for (let index = 0; index < input.length; index++) {
|
|
17
|
+
const char = input[index];
|
|
18
|
+
const next = input[index + 1];
|
|
19
|
+
|
|
20
|
+
if (escaping) {
|
|
21
|
+
current += char;
|
|
22
|
+
escaping = false;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (char === "\\" && quote !== "'") {
|
|
27
|
+
escaping = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (quote) {
|
|
32
|
+
if (char === quote) {
|
|
33
|
+
quote = undefined;
|
|
34
|
+
} else {
|
|
35
|
+
current += char;
|
|
36
|
+
}
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (char === "'" || char === '"') {
|
|
41
|
+
quote = char;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (char === "&" && next === "&") {
|
|
46
|
+
pushCurrent();
|
|
47
|
+
tokens.push("&&");
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (char === "|" && next === "|") {
|
|
52
|
+
pushCurrent();
|
|
53
|
+
tokens.push("||");
|
|
54
|
+
index += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (char === "|" || char === ";") {
|
|
58
|
+
pushCurrent();
|
|
59
|
+
tokens.push(char);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (/\s/.test(char)) {
|
|
64
|
+
pushCurrent();
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
current += char;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pushCurrent();
|
|
72
|
+
return tokens;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeTokens(tokens: string[]): string[] {
|
|
76
|
+
if (tokens.length >= 3 && (tokens[0] === "yes" || tokens[0] === "y" || tokens[0] === "no" || tokens[0] === "n") && tokens[1] === "|") {
|
|
77
|
+
return normalizeTokens(tokens.slice(2));
|
|
78
|
+
}
|
|
79
|
+
if (
|
|
80
|
+
tokens.length === 3 &&
|
|
81
|
+
(tokens[0] === "bash" || tokens[0] === "zsh" || tokens[0].endsWith("/bash") || tokens[0].endsWith("/zsh")) &&
|
|
82
|
+
(tokens[1] === "-c" || tokens[1] === "-lc")
|
|
83
|
+
) {
|
|
84
|
+
return normalizeTokens(shellSplit(tokens[2]));
|
|
85
|
+
}
|
|
86
|
+
return tokens;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function splitOnConnectors(tokens: string[]): string[][] {
|
|
90
|
+
const parts: string[][] = [];
|
|
91
|
+
let current: string[] = [];
|
|
92
|
+
for (const token of tokens) {
|
|
93
|
+
if (token === "&&" || token === "||" || token === "|" || token === ";") {
|
|
94
|
+
if (current.length > 0) {
|
|
95
|
+
parts.push(current);
|
|
96
|
+
current = [];
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
current.push(token);
|
|
101
|
+
}
|
|
102
|
+
if (current.length > 0) {
|
|
103
|
+
parts.push(current);
|
|
104
|
+
}
|
|
105
|
+
return parts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function shortDisplayPath(path: string): string {
|
|
109
|
+
const normalized = path.replace(/\\/g, "/").replace(/\/$/, "");
|
|
110
|
+
const parts = normalized
|
|
111
|
+
.split("/")
|
|
112
|
+
.filter((part) => part.length > 0 && part !== "." && part !== "src" && part !== "dist" && part !== "build" && part !== "node_modules");
|
|
113
|
+
return parts[parts.length - 1] ?? normalized;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function joinPaths(base: string, extra: string): string {
|
|
117
|
+
if (isAbsoluteLike(extra)) return extra;
|
|
118
|
+
const left = base.replace(/\\/g, "/").replace(/\/$/, "");
|
|
119
|
+
const right = extra.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
120
|
+
return `${left}/${right}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function isAbsoluteLike(path: string): boolean {
|
|
124
|
+
return path.startsWith("/") || /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
|
|
125
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ShellAction =
|
|
2
|
+
| { kind: "read"; command: string; name: string; path: string }
|
|
3
|
+
| { kind: "list"; command: string; path?: string }
|
|
4
|
+
| { kind: "search"; command: string; query?: string; path?: string }
|
|
5
|
+
| { kind: "run"; command: string };
|
|
6
|
+
|
|
7
|
+
export interface CommandSummary {
|
|
8
|
+
maskAsExplored: boolean;
|
|
9
|
+
actions: ShellAction[];
|
|
10
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
4
|
+
import { executePatch } from "../patch/core.ts";
|
|
5
|
+
import type { ExecutePatchResult } from "../patch/types.ts";
|
|
6
|
+
|
|
7
|
+
const APPLY_PATCH_PARAMETERS = Type.Object({
|
|
8
|
+
input: Type.String({
|
|
9
|
+
description: "Full patch text. Use *** Begin Patch / *** End Patch with Add/Update/Delete File sections.",
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function parseApplyPatchParams(params: unknown): { patchText: string } {
|
|
14
|
+
if (!params || typeof params !== "object" || !("input" in params) || typeof params.input !== "string") {
|
|
15
|
+
throw new Error("apply_patch requires a string 'input' parameter");
|
|
16
|
+
}
|
|
17
|
+
return { patchText: params.input };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isExecutePatchResult(details: unknown): details is ExecutePatchResult {
|
|
21
|
+
return typeof details === "object" && details !== null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type { ExecutePatchResult } from "../patch/types.ts";
|
|
25
|
+
|
|
26
|
+
export function registerApplyPatchTool(pi: ExtensionAPI): void {
|
|
27
|
+
pi.registerTool({
|
|
28
|
+
name: "apply_patch",
|
|
29
|
+
label: "apply_patch",
|
|
30
|
+
description: "Use `apply_patch` to edit files. Send the full patch in `input`.",
|
|
31
|
+
promptSnippet: "Edit files with a patch.",
|
|
32
|
+
promptGuidelines: ["Prefer apply_patch for focused textual edits instead of rewriting whole files."],
|
|
33
|
+
parameters: APPLY_PATCH_PARAMETERS,
|
|
34
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
throw new Error("apply_patch aborted");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const typedParams = parseApplyPatchParams(params);
|
|
40
|
+
const result = executePatch({ cwd: ctx.cwd, patchText: typedParams.patchText });
|
|
41
|
+
const summary = [
|
|
42
|
+
"Applied patch successfully.",
|
|
43
|
+
`Changed files: ${result.changedFiles.length}`,
|
|
44
|
+
`Created files: ${result.createdFiles.length}`,
|
|
45
|
+
`Deleted files: ${result.deletedFiles.length}`,
|
|
46
|
+
`Moved files: ${result.movedFiles.length}`,
|
|
47
|
+
`Fuzz: ${result.fuzz}`,
|
|
48
|
+
].join("\n");
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: summary }],
|
|
52
|
+
details: result,
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
renderCall(_args, theme) {
|
|
56
|
+
return new Text(`${theme.fg("toolTitle", theme.bold("apply_patch"))} ${theme.fg("muted", "patch")}`, 0, 0);
|
|
57
|
+
},
|
|
58
|
+
renderResult(result, { isPartial, expanded }, theme) {
|
|
59
|
+
if (isPartial) {
|
|
60
|
+
return new Text(theme.fg("warning", "Applying patch..."), 0, 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const details = isExecutePatchResult(result.details) ? result.details : undefined;
|
|
64
|
+
if (!details) {
|
|
65
|
+
return new Text(theme.fg("success", "Patch applied"), 0, 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let text = theme.fg("success", "Patch applied");
|
|
69
|
+
text += theme.fg(
|
|
70
|
+
"dim",
|
|
71
|
+
` (${details.changedFiles.length} changed, ${details.createdFiles.length} created, ${details.deletedFiles.length} deleted)`,
|
|
72
|
+
);
|
|
73
|
+
if (expanded) {
|
|
74
|
+
for (const file of details.changedFiles) {
|
|
75
|
+
text += `\n${theme.fg("dim", file)}`;
|
|
76
|
+
}
|
|
77
|
+
for (const move of details.movedFiles) {
|
|
78
|
+
text += `\n${theme.fg("accent", move)}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return new Text(text, 0, 0);
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { summarizeShellCommand, type ShellAction } from "../shell/summary.ts";
|
|
2
|
+
import type { ExecCommandStatus } from "./exec-command-state.ts";
|
|
3
|
+
|
|
4
|
+
export interface RenderTheme {
|
|
5
|
+
fg(role: string, text: string): string;
|
|
6
|
+
bold(text: string): string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderExecCommandCall(command: string, state: ExecCommandStatus, theme: RenderTheme): string {
|
|
10
|
+
const summary = summarizeShellCommand(command);
|
|
11
|
+
return summary.maskAsExplored ? renderExplorationText(summary.actions, state, theme) : renderCommandText(command, state, theme);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderWriteStdinCall(
|
|
15
|
+
sessionId: number | string,
|
|
16
|
+
input: string | undefined,
|
|
17
|
+
command: string | undefined,
|
|
18
|
+
theme: RenderTheme,
|
|
19
|
+
): string {
|
|
20
|
+
const interacted = typeof input === "string" && input.length > 0;
|
|
21
|
+
const marker = interacted ? "↳ " : "• ";
|
|
22
|
+
const title = interacted ? "Interacted with background terminal" : "Waited for background terminal";
|
|
23
|
+
let text = `${theme.fg("dim", marker)}${theme.bold(title)}`;
|
|
24
|
+
const commandPreview = formatCommandPreview(command);
|
|
25
|
+
if (commandPreview) {
|
|
26
|
+
text += `${theme.fg("dim", " · ")}${theme.fg("muted", commandPreview)}`;
|
|
27
|
+
}
|
|
28
|
+
// Keep the session fallback only when we do not have a stable command display.
|
|
29
|
+
if (!commandPreview) {
|
|
30
|
+
text += `${theme.fg("dim", " ")}${theme.fg("muted", `#${sessionId}`)}`;
|
|
31
|
+
}
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderExplorationText(actions: ShellAction[], state: ExecCommandStatus, theme: RenderTheme): string {
|
|
36
|
+
const header = state === "running" ? "Exploring" : "Explored";
|
|
37
|
+
let text = `${theme.fg("dim", "•")} ${theme.bold(header)}`;
|
|
38
|
+
|
|
39
|
+
for (const [index, line] of coalesceReads(actions).map(formatActionLine).entries()) {
|
|
40
|
+
const prefix = index === 0 ? " └ " : " ";
|
|
41
|
+
text += `\n${theme.fg("dim", prefix)}${theme.fg("accent", line.title)} ${theme.fg("muted", line.body)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderCommandText(command: string, state: ExecCommandStatus, theme: RenderTheme): string {
|
|
48
|
+
const verb = state === "running" ? "Running" : "Ran";
|
|
49
|
+
let text = `${theme.fg("dim", "•")} ${theme.bold(verb)}`;
|
|
50
|
+
text += `\n${theme.fg("dim", " └ ")}${theme.fg("accent", shortenCommand(command))}`;
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function shortenCommand(command: string, max = 100): string {
|
|
55
|
+
const trimmed = command.trim();
|
|
56
|
+
if (trimmed.length <= max) return trimmed;
|
|
57
|
+
return `${trimmed.slice(0, max - 3)}...`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatCommandPreview(command: string | undefined): string | undefined {
|
|
61
|
+
if (!command) return undefined;
|
|
62
|
+
const singleLine = command.replace(/\s+/g, " ").trim();
|
|
63
|
+
if (singleLine.length === 0) return undefined;
|
|
64
|
+
return shortenCommand(singleLine, 80);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatActionLine(action: ShellAction): { title: string; body: string } {
|
|
68
|
+
if (action.kind === "read") {
|
|
69
|
+
return { title: "Read", body: action.name };
|
|
70
|
+
}
|
|
71
|
+
if (action.kind === "list") {
|
|
72
|
+
return { title: "List", body: action.path ?? action.command };
|
|
73
|
+
}
|
|
74
|
+
if (action.kind === "search") {
|
|
75
|
+
if (action.query && action.path) {
|
|
76
|
+
return { title: "Search", body: `${action.query} in ${action.path}` };
|
|
77
|
+
}
|
|
78
|
+
if (action.query) {
|
|
79
|
+
return { title: "Search", body: action.query };
|
|
80
|
+
}
|
|
81
|
+
return { title: "Search", body: action.command };
|
|
82
|
+
}
|
|
83
|
+
return { title: "Run", body: action.command };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function coalesceReads(actions: ShellAction[]): ShellAction[] {
|
|
87
|
+
if (!actions.every((action) => action.kind !== "run")) return actions;
|
|
88
|
+
const reads = actions.filter((action): action is Extract<ShellAction, { kind: "read" }> => action.kind === "read");
|
|
89
|
+
if (reads.length !== actions.length) {
|
|
90
|
+
return actions;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const names = [...new Set(reads.map((action) => action.name))];
|
|
94
|
+
return [{ kind: "read", command: reads.map((action) => action.command).join(" && "), name: names.join(", "), path: reads[0].path }];
|
|
95
|
+
}
|