@howaboua/pi-codex-conversion 1.0.9 → 1.0.11
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 +7 -1
- package/package.json +3 -3
- package/src/index.ts +26 -4
- package/src/patch/core.ts +69 -24
- package/src/patch/types.ts +22 -0
- package/src/prompt/build-system-prompt.ts +3 -0
- package/src/shell/parse-command.ts +0 -2
- package/src/shell/parse.ts +0 -6
- package/src/shell/tokenize.ts +12 -1
- package/src/tools/apply-patch-rendering.ts +352 -0
- package/src/tools/apply-patch-tool.ts +204 -28
- package/src/tools/codex-rendering.ts +57 -10
- package/src/tools/exec-command-state.ts +188 -16
- package/src/tools/exec-command-tool.ts +69 -25
- package/src/tools/exec-session-manager.ts +60 -4
- package/src/tools/web-search-tool.ts +6 -2
- package/src/tools/write-stdin-tool.ts +10 -2
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ This package replaces Pi's default Codex/GPT experience with a narrower Codex-li
|
|
|
7
7
|
- swaps active tools to `exec_command`, `write_stdin`, `apply_patch`, `view_image`, and native OpenAI Codex Responses `web_search` on `openai-codex`
|
|
8
8
|
- preserves Pi's composed system prompt and applies a narrow Codex-oriented delta on top
|
|
9
9
|
- renders exec activity with Codex-style command and background-terminal labels
|
|
10
|
+
- renders `apply_patch` calls with Codex-style `Added` / `Edited` / `Deleted` diff blocks and Pi-style colored diff lines
|
|
10
11
|
|
|
11
12
|

|
|
12
13
|
|
|
@@ -54,11 +55,13 @@ npm run check
|
|
|
54
55
|
- `rg --files src | head -n 50` -> `Explored / List src`
|
|
55
56
|
- `cat README.md` -> `Explored / Read README.md`
|
|
56
57
|
- `exec_command({ cmd: "npm test", yield_time_ms: 1000 })` may return `session_id`, then continue with `write_stdin`
|
|
57
|
-
-
|
|
58
|
+
- for short or non-interactive commands, omitting `yield_time_ms` is preferred; tiny non-interactive waits are clamped upward to avoid unnecessary follow-up calls
|
|
59
|
+
- `write_stdin({ session_id, chars: "" })` renders like `Waited for background terminal` and is meant for occasional polling, not tight repoll loops
|
|
58
60
|
- `write_stdin({ session_id, chars: "y\\n" })` renders like `Interacted with background terminal`
|
|
59
61
|
- `view_image({ path: "/absolute/path/to/screenshot.png" })` is available on image-capable models
|
|
60
62
|
- `web_search` is surfaced only on `openai-codex`, and the adapter rewrites it into the native OpenAI Responses `type: "web_search"` payload instead of executing a local function tool
|
|
61
63
|
- when native web search is available, the adapter shows a one-time session notice; individual searches are not surfaced because Pi does not expose native web-search execution events to extensions
|
|
64
|
+
- `apply_patch` partial failures stay inline in the patch row so successful and failed file entries can be seen together
|
|
62
65
|
|
|
63
66
|
Raw command output is still available by expanding the tool result.
|
|
64
67
|
|
|
@@ -135,7 +138,10 @@ That keeps the prompt much closer to `pi-mono` while still steering the model to
|
|
|
135
138
|
- `view_image` resolves paths against the active session cwd and only exposes `detail: "original"` for Codex-family image-capable models.
|
|
136
139
|
- `web_search` is exposed only for the `openai-codex` provider and is forwarded as the native OpenAI Codex Responses web search tool.
|
|
137
140
|
- `apply_patch` paths stay restricted to the current working directory.
|
|
141
|
+
- partial `apply_patch` failures stay in the original patch block and highlight the failed entry instead of adding a second warning row.
|
|
138
142
|
- `exec_command` / `write_stdin` use a custom PTY-backed session manager via `node-pty` for interactive sessions.
|
|
143
|
+
- tiny `exec_command` waits are clamped for non-interactive commands so short runs do not burn an avoidable follow-up tool call.
|
|
144
|
+
- empty `write_stdin` polls are clamped to a meaningful minimum wait so long-running processes are not repolled too aggressively.
|
|
139
145
|
- PTY output handling applies basic terminal rewrite semantics (`\r`, `\b`, erase-in-line, and common escape cleanup) so interactive redraws replay sensibly.
|
|
140
146
|
- Skills inventory is reintroduced in a Codex-style section when Pi's composed prompt already exposed the underlying Pi skills inventory.
|
|
141
147
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@howaboua/pi-codex-conversion",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
4
4
|
"description": "Codex-oriented tool and prompt adapter for pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"access": "public"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@mariozechner/pi-coding-agent": "
|
|
55
|
-
"@mariozechner/pi-tui": "
|
|
54
|
+
"@mariozechner/pi-coding-agent": "^0.62.0",
|
|
55
|
+
"@mariozechner/pi-tui": "^0.62.0",
|
|
56
56
|
"@sinclair/typebox": "*"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { getCodexRuntimeShell } from "./adapter/runtime-shell.ts";
|
|
3
3
|
import { CORE_ADAPTER_TOOL_NAMES, DEFAULT_TOOL_NAMES, STATUS_KEY, STATUS_TEXT, VIEW_IMAGE_TOOL_NAME, WEB_SEARCH_TOOL_NAME } from "./adapter/tool-set.ts";
|
|
4
|
-
import { registerApplyPatchTool } from "./tools/apply-patch-tool.ts";
|
|
4
|
+
import { clearApplyPatchRenderState, registerApplyPatchTool } from "./tools/apply-patch-tool.ts";
|
|
5
5
|
import { isCodexLikeContext, isOpenAICodexContext } from "./adapter/codex-model.ts";
|
|
6
6
|
import { createExecCommandTracker } from "./tools/exec-command-state.ts";
|
|
7
7
|
import { registerExecCommandTool } from "./tools/exec-command-tool.ts";
|
|
@@ -35,6 +35,16 @@ function getCommandArg(args: unknown): string | undefined {
|
|
|
35
35
|
return args.cmd;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
function isToolCallOnlyAssistantMessage(message: unknown): boolean {
|
|
39
|
+
if (!message || typeof message !== "object" || !("role" in message) || message.role !== "assistant") {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
if (!("content" in message) || !Array.isArray(message.content) || message.content.length === 0) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return message.content.every((item) => typeof item === "object" && item !== null && "type" in item && item.type === "toolCall");
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
export default function codexConversion(pi: ExtensionAPI) {
|
|
39
49
|
const tracker = createExecCommandTracker();
|
|
40
50
|
const state: AdapterState = { enabled: false, promptSkills: [], webSearchNoticeShown: false };
|
|
@@ -46,12 +56,14 @@ export default function codexConversion(pi: ExtensionAPI) {
|
|
|
46
56
|
registerWebSearchTool(pi);
|
|
47
57
|
registerWebSearchSessionNoteRenderer(pi);
|
|
48
58
|
|
|
49
|
-
sessions.onSessionExit((
|
|
50
|
-
tracker.
|
|
59
|
+
sessions.onSessionExit((sessionId) => {
|
|
60
|
+
tracker.recordSessionFinished(sessionId);
|
|
51
61
|
});
|
|
52
62
|
|
|
53
63
|
pi.on("session_start", async (_event, ctx) => {
|
|
54
64
|
state.webSearchNoticeShown = false;
|
|
65
|
+
clearApplyPatchRenderState();
|
|
66
|
+
tracker.clear();
|
|
55
67
|
syncAdapter(pi, ctx, state);
|
|
56
68
|
});
|
|
57
69
|
|
|
@@ -59,8 +71,17 @@ export default function codexConversion(pi: ExtensionAPI) {
|
|
|
59
71
|
syncAdapter(pi, ctx, state);
|
|
60
72
|
});
|
|
61
73
|
|
|
74
|
+
pi.on("message_start", async (event) => {
|
|
75
|
+
if (event.message.role === "toolResult") return;
|
|
76
|
+
if (isToolCallOnlyAssistantMessage(event.message)) return;
|
|
77
|
+
tracker.resetExplorationGroup();
|
|
78
|
+
});
|
|
79
|
+
|
|
62
80
|
pi.on("tool_execution_start", async (event) => {
|
|
63
|
-
if (event.toolName !== "exec_command")
|
|
81
|
+
if (event.toolName !== "exec_command") {
|
|
82
|
+
tracker.resetExplorationGroup();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
64
85
|
const command = getCommandArg(event.args);
|
|
65
86
|
if (!command) return;
|
|
66
87
|
tracker.recordStart(event.toolCallId, command);
|
|
@@ -72,6 +93,7 @@ export default function codexConversion(pi: ExtensionAPI) {
|
|
|
72
93
|
});
|
|
73
94
|
|
|
74
95
|
pi.on("session_shutdown", async () => {
|
|
96
|
+
clearApplyPatchRenderState();
|
|
75
97
|
sessions.shutdown();
|
|
76
98
|
});
|
|
77
99
|
|
package/src/patch/core.ts
CHANGED
|
@@ -1,8 +1,36 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { parsePatchActions, parseUpdateFile } from "./parser.ts";
|
|
4
4
|
import { openFileAtPath, pathExists, removeFileAtPath, resolvePatchPath, writeFileAtPath } from "./paths.ts";
|
|
5
|
-
import { DiffError, type ExecutePatchResult, type ParsedPatchAction, type ParserState, type PatchAction } from "./types.ts";
|
|
5
|
+
import { DiffError, ExecutePatchError, type ExecutePatchResult, type ParsedPatchAction, type ParserState, type PatchAction } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const patchFsOps = {
|
|
8
|
+
mkdirSync: fs.mkdirSync,
|
|
9
|
+
writeFileSync: fs.writeFileSync,
|
|
10
|
+
unlinkSync: fs.unlinkSync,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function buildExecutePatchResult({
|
|
14
|
+
changedFiles,
|
|
15
|
+
createdFiles,
|
|
16
|
+
deletedFiles,
|
|
17
|
+
movedFiles,
|
|
18
|
+
fuzz,
|
|
19
|
+
}: {
|
|
20
|
+
changedFiles: Set<string>;
|
|
21
|
+
createdFiles: Set<string>;
|
|
22
|
+
deletedFiles: Set<string>;
|
|
23
|
+
movedFiles: Set<string>;
|
|
24
|
+
fuzz: number;
|
|
25
|
+
}): ExecutePatchResult {
|
|
26
|
+
return {
|
|
27
|
+
changedFiles: [...changedFiles],
|
|
28
|
+
createdFiles: [...createdFiles],
|
|
29
|
+
deletedFiles: [...deletedFiles],
|
|
30
|
+
movedFiles: [...movedFiles],
|
|
31
|
+
fuzz,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
6
34
|
|
|
7
35
|
function splitFileLines(text: string): string[] {
|
|
8
36
|
const lines = text.split("\n");
|
|
@@ -103,21 +131,23 @@ function applyMove({
|
|
|
103
131
|
const toAbsolutePath = resolvePatchPath({ cwd, patchPath: movePath });
|
|
104
132
|
const destinationExisted = pathExists({ cwd, path: movePath });
|
|
105
133
|
|
|
106
|
-
mkdirSync(dirname(toAbsolutePath), { recursive: true });
|
|
107
|
-
writeFileSync(toAbsolutePath, content, "utf8");
|
|
108
|
-
if (fromAbsolutePath !== toAbsolutePath) {
|
|
109
|
-
unlinkSync(fromAbsolutePath);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
changedFiles.add(path);
|
|
134
|
+
patchFsOps.mkdirSync(dirname(toAbsolutePath), { recursive: true });
|
|
135
|
+
patchFsOps.writeFileSync(toAbsolutePath, content, "utf8");
|
|
113
136
|
changedFiles.add(movePath);
|
|
114
|
-
movedFiles.add(`${path} -> ${movePath}`);
|
|
115
137
|
if (!destinationExisted) {
|
|
116
138
|
createdFiles.add(movePath);
|
|
117
139
|
}
|
|
140
|
+
|
|
118
141
|
if (fromAbsolutePath !== toAbsolutePath) {
|
|
142
|
+
patchFsOps.unlinkSync(fromAbsolutePath);
|
|
143
|
+
changedFiles.add(path);
|
|
144
|
+
movedFiles.add(`${path} -> ${movePath}`);
|
|
119
145
|
deletedFiles.add(path);
|
|
146
|
+
return;
|
|
120
147
|
}
|
|
148
|
+
|
|
149
|
+
changedFiles.add(path);
|
|
150
|
+
movedFiles.add(`${path} -> ${movePath}`);
|
|
121
151
|
}
|
|
122
152
|
|
|
123
153
|
function applyAction({
|
|
@@ -200,21 +230,36 @@ export function executePatch({ cwd, patchText }: { cwd: string; patchText: strin
|
|
|
200
230
|
let fuzz = 0;
|
|
201
231
|
|
|
202
232
|
for (const action of actions) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
233
|
+
try {
|
|
234
|
+
fuzz += applyAction({
|
|
235
|
+
cwd,
|
|
236
|
+
action,
|
|
237
|
+
changedFiles,
|
|
238
|
+
createdFiles,
|
|
239
|
+
deletedFiles,
|
|
240
|
+
movedFiles,
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
244
|
+
throw new ExecutePatchError(
|
|
245
|
+
message,
|
|
246
|
+
buildExecutePatchResult({
|
|
247
|
+
changedFiles,
|
|
248
|
+
createdFiles,
|
|
249
|
+
deletedFiles,
|
|
250
|
+
movedFiles,
|
|
251
|
+
fuzz,
|
|
252
|
+
}),
|
|
253
|
+
action,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
211
256
|
}
|
|
212
257
|
|
|
213
|
-
return {
|
|
214
|
-
changedFiles
|
|
215
|
-
createdFiles
|
|
216
|
-
deletedFiles
|
|
217
|
-
movedFiles
|
|
258
|
+
return buildExecutePatchResult({
|
|
259
|
+
changedFiles,
|
|
260
|
+
createdFiles,
|
|
261
|
+
deletedFiles,
|
|
262
|
+
movedFiles,
|
|
218
263
|
fuzz,
|
|
219
|
-
};
|
|
264
|
+
});
|
|
220
265
|
}
|
package/src/patch/types.ts
CHANGED
|
@@ -42,3 +42,25 @@ export class DiffError extends Error {
|
|
|
42
42
|
this.name = "DiffError";
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
export class ExecutePatchError extends DiffError {
|
|
47
|
+
result: ExecutePatchResult;
|
|
48
|
+
failedAction?: ParsedPatchAction;
|
|
49
|
+
|
|
50
|
+
constructor(message: string, result: ExecutePatchResult, failedAction?: ParsedPatchAction) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "ExecutePatchError";
|
|
53
|
+
this.result = result;
|
|
54
|
+
this.failedAction = failedAction;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hasPartialSuccess(): boolean {
|
|
58
|
+
return (
|
|
59
|
+
this.result.changedFiles.length > 0 ||
|
|
60
|
+
this.result.createdFiles.length > 0 ||
|
|
61
|
+
this.result.deletedFiles.length > 0 ||
|
|
62
|
+
this.result.movedFiles.length > 0 ||
|
|
63
|
+
this.result.fuzz > 0
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -6,9 +6,12 @@ export interface PromptSkill {
|
|
|
6
6
|
|
|
7
7
|
const CODEX_GUIDELINES = [
|
|
8
8
|
"Prefer a single `apply_patch` call that updates all related files together when one coherent patch will do.",
|
|
9
|
+
"When making coordinated edits across multiple files, include them in one `apply_patch` call instead of splitting them into separate patches.",
|
|
9
10
|
"When multiple tool calls are independent, emit them together so they can execute in parallel instead of serializing them.",
|
|
10
11
|
"Use `parallel` only when tool calls are independent and can safely run at the same time.",
|
|
11
12
|
"Use `write_stdin` when an exec session returns `session_id`, and continue until `exit_code` is present.",
|
|
13
|
+
"For short or non-interactive commands, prefer the default `exec_command` wait instead of a tiny `yield_time_ms` that forces an extra follow-up call.",
|
|
14
|
+
"When polling a running exec session with empty `chars`, wait meaningfully between polls and do not repeatedly poll by reflex.",
|
|
12
15
|
"Do not request `tty` unless interactive terminal behavior is required.",
|
|
13
16
|
];
|
|
14
17
|
|
|
@@ -61,8 +61,6 @@ function parseCommandImpl(command: string[]): ParsedShellCommand[] {
|
|
|
61
61
|
const parsed = summarizeMainTokens(tokens);
|
|
62
62
|
if (parsed.kind === "read" && cwd) {
|
|
63
63
|
commands.push({ ...parsed, path: joinPaths(cwd, parsed.path) });
|
|
64
|
-
} else if (parsed.kind === "list" && cwd && !parsed.path) {
|
|
65
|
-
commands.push({ ...parsed, path: shortDisplayPath(cwd) });
|
|
66
64
|
} else {
|
|
67
65
|
commands.push(parsed);
|
|
68
66
|
}
|
package/src/shell/parse.ts
CHANGED
|
@@ -18,12 +18,6 @@ export function parseShellPart(tokens: string[], cwd?: string): ShellAction | nu
|
|
|
18
18
|
path: joinPaths(cwd, parsed.path),
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
-
if (parsed.kind === "list" && cwd && !parsed.path) {
|
|
22
|
-
return {
|
|
23
|
-
...parsed,
|
|
24
|
-
path: shortDisplayPath(cwd),
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
21
|
|
|
28
22
|
return parsed;
|
|
29
23
|
}
|
package/src/shell/tokenize.ts
CHANGED
|
@@ -84,10 +84,21 @@ export function shellSplit(input: string): string[] {
|
|
|
84
84
|
|
|
85
85
|
export function shellQuote(token: string): string {
|
|
86
86
|
if (token.length === 0) return "''";
|
|
87
|
-
if (
|
|
87
|
+
if (canEmitUnquoted(token)) return token;
|
|
88
88
|
return `'${token.replace(/'/g, `'"'"'`)}'`;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function canEmitUnquoted(token: string): boolean {
|
|
92
|
+
for (const char of token) {
|
|
93
|
+
if (!isUnquotedOk(char)) return false;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isUnquotedOk(char: string): boolean {
|
|
99
|
+
return /^[+\-./:@\]_0-9A-Za-z]$/.test(char);
|
|
100
|
+
}
|
|
101
|
+
|
|
91
102
|
export function joinCommandTokens(tokens: string[]): string {
|
|
92
103
|
return tokens
|
|
93
104
|
.map((token) => (token === "&&" || token === "||" || token === "|" || token === ";" ? token : shellQuote(token)))
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path";
|
|
2
|
+
import { renderDiff } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { openFileAtPath } from "../patch/paths.ts";
|
|
4
|
+
import { parsePatchActions } from "../patch/parser.ts";
|
|
5
|
+
import type { ParsedPatchAction } from "../patch/types.ts";
|
|
6
|
+
|
|
7
|
+
interface PreviewLine {
|
|
8
|
+
lineNumber: number;
|
|
9
|
+
marker: " " | "+" | "-";
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface FilePreview {
|
|
14
|
+
verb: "Added" | "Deleted" | "Edited";
|
|
15
|
+
path: string;
|
|
16
|
+
movePath?: string;
|
|
17
|
+
added: number;
|
|
18
|
+
removed: number;
|
|
19
|
+
lines: PreviewLine[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatApplyPatchSummary(patchText: string, cwd = process.cwd()): string {
|
|
23
|
+
let actions: ParsedPatchAction[];
|
|
24
|
+
try {
|
|
25
|
+
actions = parsePatchActions({ text: patchText });
|
|
26
|
+
} catch {
|
|
27
|
+
return "apply_patch patch";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const files = actions.map((action) => buildFilePreview(action, cwd));
|
|
31
|
+
if (files.length === 0) {
|
|
32
|
+
return "apply_patch patch";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const totalAdded = files.reduce((sum, file) => sum + file.added, 0);
|
|
36
|
+
const totalRemoved = files.reduce((sum, file) => sum + file.removed, 0);
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
|
|
39
|
+
if (files.length === 1) {
|
|
40
|
+
const [file] = files;
|
|
41
|
+
lines.push(`${bulletHeader(file.verb, formatPatchTarget(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lines.push(`${bulletHeader("Edited", `${files.length} files`)} ${renderCounts(totalAdded, totalRemoved)}`);
|
|
46
|
+
for (const [index, file] of files.entries()) {
|
|
47
|
+
const prefix = index === 0 ? " └ " : " ";
|
|
48
|
+
lines.push(`${prefix}${formatPatchTarget(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatApplyPatchCall(patchText: string, cwd = process.cwd()): string {
|
|
55
|
+
let actions: ParsedPatchAction[];
|
|
56
|
+
try {
|
|
57
|
+
actions = parsePatchActions({ text: patchText });
|
|
58
|
+
} catch {
|
|
59
|
+
return "apply_patch patch";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const files = actions.map((action) => buildFilePreview(action, cwd));
|
|
63
|
+
if (files.length === 0) {
|
|
64
|
+
return "apply_patch patch";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const totalAdded = files.reduce((sum, file) => sum + file.added, 0);
|
|
68
|
+
const totalRemoved = files.reduce((sum, file) => sum + file.removed, 0);
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
|
|
71
|
+
if (files.length === 1) {
|
|
72
|
+
const [file] = files;
|
|
73
|
+
lines.push(`${bulletHeader(file.verb, formatPatchTarget(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
|
|
74
|
+
lines.push(...file.lines.map((line) => formatPreviewLine(line, file.lines)));
|
|
75
|
+
return lines.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push(`${bulletHeader("Edited", `${files.length} files`)} ${renderCounts(totalAdded, totalRemoved)}`);
|
|
79
|
+
for (const [index, file] of files.entries()) {
|
|
80
|
+
if (index > 0) {
|
|
81
|
+
lines.push("");
|
|
82
|
+
}
|
|
83
|
+
lines.push(` └ ${formatPatchTarget(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
|
|
84
|
+
lines.push(...file.lines.map((line) => formatPreviewLine(line, file.lines)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function renderApplyPatchCall(patchText: string, cwd = process.cwd()): string {
|
|
91
|
+
let actions: ParsedPatchAction[];
|
|
92
|
+
try {
|
|
93
|
+
actions = parsePatchActions({ text: patchText });
|
|
94
|
+
} catch {
|
|
95
|
+
return "apply_patch patch";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const files = actions.map((action) => buildFilePreview(action, cwd));
|
|
99
|
+
if (files.length === 0) {
|
|
100
|
+
return "apply_patch patch";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const totalAdded = files.reduce((sum, file) => sum + file.added, 0);
|
|
104
|
+
const totalRemoved = files.reduce((sum, file) => sum + file.removed, 0);
|
|
105
|
+
const lines: string[] = [];
|
|
106
|
+
|
|
107
|
+
if (files.length === 1) {
|
|
108
|
+
const [file] = files;
|
|
109
|
+
lines.push(`${bulletHeader(file.verb, formatPatchTarget(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
|
|
110
|
+
lines.push(...renderPreviewLines(file.lines));
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
lines.push(`${bulletHeader("Edited", `${files.length} files`)} ${renderCounts(totalAdded, totalRemoved)}`);
|
|
115
|
+
for (const [index, file] of files.entries()) {
|
|
116
|
+
if (index > 0) {
|
|
117
|
+
lines.push("");
|
|
118
|
+
}
|
|
119
|
+
lines.push(` └ ${formatPatchTarget(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
|
|
120
|
+
lines.push(...renderPreviewLines(file.lines));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return lines.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function buildFilePreview(action: ParsedPatchAction, cwd: string): FilePreview {
|
|
127
|
+
if (action.type === "add") {
|
|
128
|
+
const lines = splitFileLines(action.newFile ?? "");
|
|
129
|
+
return {
|
|
130
|
+
verb: "Added",
|
|
131
|
+
path: action.path,
|
|
132
|
+
added: lines.length,
|
|
133
|
+
removed: 0,
|
|
134
|
+
lines: lines.map((text, index) => ({ lineNumber: index + 1, marker: "+", text })),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (action.type === "delete") {
|
|
139
|
+
const deletedLines = readFileLines(action.path, cwd);
|
|
140
|
+
return {
|
|
141
|
+
verb: "Deleted",
|
|
142
|
+
path: action.path,
|
|
143
|
+
added: 0,
|
|
144
|
+
removed: deletedLines.length,
|
|
145
|
+
lines: deletedLines.map((text, index) => ({ lineNumber: index + 1, marker: "-", text })),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const preview = buildUpdatePreview(action, cwd);
|
|
150
|
+
return {
|
|
151
|
+
verb: "Edited",
|
|
152
|
+
path: action.path,
|
|
153
|
+
movePath: action.movePath,
|
|
154
|
+
added: preview.added,
|
|
155
|
+
removed: preview.removed,
|
|
156
|
+
lines: preview.lines,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function buildUpdatePreview(action: ParsedPatchAction, cwd: string): { added: number; removed: number; lines: PreviewLine[] } {
|
|
161
|
+
if (!action.lines) {
|
|
162
|
+
return { added: 0, removed: 0, lines: [] };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const originalLines = readFileLines(action.path, cwd);
|
|
166
|
+
const renderedLines: PreviewLine[] = [];
|
|
167
|
+
let added = 0;
|
|
168
|
+
let removed = 0;
|
|
169
|
+
let searchStart = 0;
|
|
170
|
+
let delta = 0;
|
|
171
|
+
let index = 0;
|
|
172
|
+
|
|
173
|
+
while (index < action.lines.length) {
|
|
174
|
+
const line = action.lines[index];
|
|
175
|
+
if (line === "*** End of File") {
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
if (!line.startsWith("@@")) {
|
|
179
|
+
index += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
index += 1;
|
|
184
|
+
const sectionLines: string[] = [];
|
|
185
|
+
while (index < action.lines.length && !action.lines[index].startsWith("@@") && action.lines[index] !== "*** End of File") {
|
|
186
|
+
sectionLines.push(action.lines[index]);
|
|
187
|
+
index += 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (sectionLines.length === 0) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const oldSequence = sectionLines
|
|
195
|
+
.map(normalizePatchLine)
|
|
196
|
+
.filter((entry) => entry.marker === " " || entry.marker === "-")
|
|
197
|
+
.map((entry) => entry.text);
|
|
198
|
+
const sectionStart = findMatchingSequence(originalLines, oldSequence, searchStart);
|
|
199
|
+
let oldLineNumber = sectionStart + 1;
|
|
200
|
+
let newLineNumber = sectionStart + 1 + delta;
|
|
201
|
+
|
|
202
|
+
for (const rawLine of sectionLines) {
|
|
203
|
+
const entry = normalizePatchLine(rawLine);
|
|
204
|
+
if (entry.marker === "+") {
|
|
205
|
+
added += 1;
|
|
206
|
+
renderedLines.push({ lineNumber: newLineNumber, marker: "+", text: entry.text });
|
|
207
|
+
newLineNumber += 1;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (entry.marker === "-") {
|
|
212
|
+
removed += 1;
|
|
213
|
+
renderedLines.push({ lineNumber: oldLineNumber, marker: "-", text: entry.text });
|
|
214
|
+
oldLineNumber += 1;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
renderedLines.push({ lineNumber: newLineNumber, marker: " ", text: entry.text });
|
|
219
|
+
oldLineNumber += 1;
|
|
220
|
+
newLineNumber += 1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
searchStart = sectionStart + oldSequence.length;
|
|
224
|
+
delta += sectionLines.reduce((sum, rawLine) => {
|
|
225
|
+
const marker = normalizePatchLine(rawLine).marker;
|
|
226
|
+
if (marker === "+") return sum + 1;
|
|
227
|
+
if (marker === "-") return sum - 1;
|
|
228
|
+
return sum;
|
|
229
|
+
}, 0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { added, removed, lines: renderedLines };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function formatPreviewLine(line: PreviewLine, lines: PreviewLine[]): string {
|
|
236
|
+
const numberWidth = Math.max(1, ...lines.map((entry) => String(entry.lineNumber).length));
|
|
237
|
+
return ` ${String(line.lineNumber).padStart(numberWidth, " ")} ${line.marker}${line.text}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderPreviewLines(lines: PreviewLine[]): string[] {
|
|
241
|
+
if (lines.length === 0) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const numberWidth = Math.max(1, ...lines.map((entry) => String(entry.lineNumber).length));
|
|
246
|
+
const diffText = lines
|
|
247
|
+
.map((line) => `${line.marker}${String(line.lineNumber).padStart(numberWidth, " ")} ${line.text}`)
|
|
248
|
+
.join("\n");
|
|
249
|
+
try {
|
|
250
|
+
return renderDiff(diffText)
|
|
251
|
+
.split("\n")
|
|
252
|
+
.map((line) => ` ${line}`);
|
|
253
|
+
} catch {
|
|
254
|
+
return lines.map((line) => formatPreviewLine(line, lines));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizePatchLine(rawLine: string): PreviewLine {
|
|
259
|
+
const normalized = rawLine === "" ? " " : rawLine;
|
|
260
|
+
const marker = normalized[0];
|
|
261
|
+
if (marker !== " " && marker !== "+" && marker !== "-") {
|
|
262
|
+
return { lineNumber: 0, marker: " ", text: rawLine };
|
|
263
|
+
}
|
|
264
|
+
return { lineNumber: 0, marker, text: normalized.slice(1) };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function findMatchingSequence(lines: string[], context: string[], start: number): number {
|
|
268
|
+
if (context.length === 0) {
|
|
269
|
+
return start;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const exact = findSequence(lines, context, start, (value) => value);
|
|
273
|
+
if (exact !== -1) {
|
|
274
|
+
return exact;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const trimEnd = findSequence(lines, context, start, (value) => value.trimEnd());
|
|
278
|
+
if (trimEnd !== -1) {
|
|
279
|
+
return trimEnd;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const trim = findSequence(lines, context, start, (value) => value.trim());
|
|
283
|
+
if (trim !== -1) {
|
|
284
|
+
return trim;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return start;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function findSequence(lines: string[], context: string[], start: number, normalize: (value: string) => string): number {
|
|
291
|
+
for (let lineIndex = start; lineIndex <= lines.length - context.length; lineIndex += 1) {
|
|
292
|
+
let matches = true;
|
|
293
|
+
for (let contextIndex = 0; contextIndex < context.length; contextIndex += 1) {
|
|
294
|
+
if (normalize(lines[lineIndex + contextIndex]) !== normalize(context[contextIndex])) {
|
|
295
|
+
matches = false;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (matches) {
|
|
300
|
+
return lineIndex;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return -1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function formatPatchTarget(path: string, movePath: string | undefined, cwd: string): string {
|
|
307
|
+
const from = displayPath(path, cwd);
|
|
308
|
+
if (!movePath) {
|
|
309
|
+
return from;
|
|
310
|
+
}
|
|
311
|
+
return `${from} → ${displayPath(movePath, cwd)}`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function displayPath(path: string, cwd: string): string {
|
|
315
|
+
if (!isAbsolute(path)) {
|
|
316
|
+
return path;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const relativePath = relative(cwd, path);
|
|
320
|
+
if (relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath)) {
|
|
321
|
+
return relativePath;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return path;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function readFileLines(path: string, cwd: string): string[] {
|
|
328
|
+
try {
|
|
329
|
+
return splitFileLines(openFileAtPath({ cwd, path }));
|
|
330
|
+
} catch {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function splitFileLines(text: string): string[] {
|
|
336
|
+
if (text.length === 0) {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
const lines = text.split("\n");
|
|
340
|
+
if (lines.at(-1) === "") {
|
|
341
|
+
lines.pop();
|
|
342
|
+
}
|
|
343
|
+
return lines;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function bulletHeader(verb: string, label: string): string {
|
|
347
|
+
return `• ${verb} ${label}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function renderCounts(added: number, removed: number): string {
|
|
351
|
+
return `(+${added} -${removed})`;
|
|
352
|
+
}
|