pi-codex-tools 0.2.1 → 0.2.3
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/CHANGELOG.md +16 -0
- package/README.md +2 -2
- package/SECURITY.md +2 -2
- package/extensions/index.ts +1 -12
- package/package.json +8 -8
- package/src/apply-patch.ts +32 -40
- package/src/patch-preview.ts +13 -4
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.3] - 2026-08-11
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Accept relative paths outside the current working directory and absolute paths, matching Pi's native `edit` and `write` tools while preserving no-follow filesystem operations.
|
|
14
|
+
|
|
15
|
+
## [0.2.2] - 2026-08-10
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Keep provider-side parallel tool calls enabled while preserving sequential `apply_patch` execution.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Strip untrusted terminal control sequences from `apply_patch` previews before rendering them in the Pi TUI.
|
|
24
|
+
|
|
9
25
|
## [0.2.1] - 2026-08-08
|
|
10
26
|
|
|
11
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@ Give grammar-capable OpenAI/Codex models the Codex `apply_patch` tool in Pi with
|
|
|
6
6
|
|
|
7
7
|
- **Raw `apply_patch`** — sends Codex's Lark grammar as an OpenAI custom tool, so patches are not JSON-wrapped.
|
|
8
8
|
- **Capability-based activation** — requires `openai-codex-responses` or `openai-responses` plus `model.compat.supportsOpenAIGrammarTools === true`; model names alone are never enough.
|
|
9
|
-
- **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB,
|
|
9
|
+
- **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, accept relative or absolute paths like Pi's native file tools, reject symlink paths, use descriptor-anchored no-follow operations on Linux and macOS, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
|
|
10
10
|
- **Model switching** — supported models replace Pi's `edit` and `write` tools with `apply_patch`; other active tools are preserved. Switching back restores only the file tools that were active before the switch.
|
|
11
|
-
- **Sequential patch calls** — the extension marks patch execution sequential
|
|
11
|
+
- **Sequential patch calls** — the extension marks patch execution sequential while leaving provider-side parallel tool calls enabled.
|
|
12
12
|
- **Streaming progress** — while a patch is generated, the TUI shows a live, color-coded glimpse of the content being written (new-file content, or `+`/`-` lines for updates) plus a running `+added -removed` tally and a per-file roster for multi-file patches. It reuses Pi's shared diff rendering and mirrors the built-in `write`/`edit` previews; patch execution is unchanged.
|
|
13
13
|
|
|
14
14
|
## Installation
|
package/SECURITY.md
CHANGED
|
@@ -19,8 +19,8 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
|
|
|
19
19
|
|
|
20
20
|
Pi extensions execute with the same permissions as the local user running Pi. Review installed extensions and only install packages from sources you trust.
|
|
21
21
|
|
|
22
|
-
`apply_patch` does not access the network or credential APIs. It can read credential-containing files when a patch targets them. It
|
|
22
|
+
`apply_patch` does not access the network or credential APIs. Like Pi's native `edit` and `write` tools, it accepts relative or absolute paths and can modify files outside the current working directory with the local user's permissions. It can read credential-containing files when a patch targets them. It rejects symlink paths and symlinked parents, limits patch input to 1 MiB and target-file reads to 64 MiB, preflights file changes before writing, and performs a filesystem-root-anchored descriptor-based no-follow directory walk so a path component swapped to a symlink between check and use cannot redirect a mutation. On Linux the walk re-opens each component via `/proc/self/fd`; on macOS it uses a bundled `openat`/`mkdirat`/`unlinkat` N-API binding (committed prebuilds for Apple silicon and Intel, loaded via `node-gyp-build`) because Node does not expose `openat` and macOS lacks procfs. The binding is darwin-only, exposes only those POSIX calls, and is loaded best-effort: on platforms without it `apply_patch` fails closed and Pi keeps its native `edit`/`write` tools. A failure during a multi-file write can still leave earlier files changed; callers should use version control and review the resulting diff.
|
|
23
23
|
|
|
24
|
-
The package reads the current provider/model capability flags only to select tools. It does not log prompts, patches, file contents, credentials, auth headers, or provider responses.
|
|
24
|
+
The package strips untrusted C0/C1 control bytes and terminal escape sequences from `apply_patch` preview text and paths before handing them to the TUI. The package reads the current provider/model capability flags only to select tools. It does not log prompts, patches, file contents, credentials, auth headers, or provider responses.
|
|
25
25
|
|
|
26
26
|
Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, `PI_TELEMETRY=false`, CI detection, or Pi's `enableInstallTelemetry: false` setting. Through `@mocito/install-telemetry`, it sends the package name and version as HTTPS URL query parameters and adds `process.platform`, the runtime name/version, and `process.arch` to the `User-Agent`. These fields are not intended to identify a host, user, repository, or path; no prompts, patches, file contents, credentials, auth headers, or provider responses are sent.
|
package/extensions/index.ts
CHANGED
|
@@ -44,7 +44,7 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
44
44
|
promptGuidelines: [
|
|
45
45
|
"Use apply_patch for file changes when it is available.",
|
|
46
46
|
"Send the patch body directly; do not wrap it in JSON or add a shell heredoc.",
|
|
47
|
-
`Patch paths
|
|
47
|
+
`Patch paths may be relative to the current working directory or absolute, and patches are limited to ${MAX_PATCH_BYTES} bytes.`,
|
|
48
48
|
],
|
|
49
49
|
parameters: APPLY_PATCH_PARAMETERS,
|
|
50
50
|
constrainedSampling: createOpenAILarkSampling(APPLY_PATCH_GRAMMAR),
|
|
@@ -122,15 +122,4 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
122
122
|
pi.on("model_select", (_event, ctx) => {
|
|
123
123
|
synchronizeTools(ctx);
|
|
124
124
|
});
|
|
125
|
-
|
|
126
|
-
pi.on("before_provider_request", (event, ctx) => {
|
|
127
|
-
if (!supportsOpenAIGrammarTools(ctx.model)) return;
|
|
128
|
-
if (typeof pi.getActiveTools !== "function" || !pi.getActiveTools().includes(APPLY_PATCH)) return;
|
|
129
|
-
if (!isRecord(event.payload) || event.payload.parallel_tool_calls !== true) return;
|
|
130
|
-
return { ...event.payload, parallel_tool_calls: false };
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
135
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
125
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Codex-compatible apply_patch tooling for Pi's grammar-capable OpenAI models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
"CONTRIBUTING.md",
|
|
46
46
|
"CODE_OF_CONDUCT.md"
|
|
47
47
|
],
|
|
48
|
-
"gypfile": false,
|
|
49
48
|
"scripts": {
|
|
50
49
|
"check": "tsc --noEmit",
|
|
51
50
|
"typecheck": "tsc --noEmit",
|
|
@@ -61,13 +60,13 @@
|
|
|
61
60
|
"typebox": "*"
|
|
62
61
|
},
|
|
63
62
|
"devDependencies": {
|
|
64
|
-
"@earendil-works/pi-ai": "^0.
|
|
65
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
66
|
-
"@earendil-works/pi-tui": "^0.
|
|
63
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
64
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
65
|
+
"@earendil-works/pi-tui": "^0.84.1",
|
|
67
66
|
"@types/node": "^26.1.2",
|
|
68
67
|
"prebuildify": "^6.0.1",
|
|
69
|
-
"tsx": "^4.23.
|
|
70
|
-
"typebox": "^1.3.
|
|
68
|
+
"tsx": "^4.23.5",
|
|
69
|
+
"typebox": "^1.3.10",
|
|
71
70
|
"typescript": "^7.0.2"
|
|
72
71
|
},
|
|
73
72
|
"publishConfig": {
|
|
@@ -79,5 +78,6 @@
|
|
|
79
78
|
"dependencies": {
|
|
80
79
|
"@mocito/install-telemetry": "0.1.1",
|
|
81
80
|
"node-gyp-build": "^4.8.4"
|
|
82
|
-
}
|
|
81
|
+
},
|
|
82
|
+
"gypfile": false
|
|
83
83
|
}
|
package/src/apply-patch.ts
CHANGED
|
@@ -298,17 +298,18 @@ type VirtualFile = Omit<SafePath, "absolute"> & { content?: string };
|
|
|
298
298
|
export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
|
|
299
299
|
requireSecureFilesystem();
|
|
300
300
|
const hunks = parseApplyPatch(input);
|
|
301
|
-
const
|
|
301
|
+
const cwd = await realpath(resolve(options.cwd));
|
|
302
|
+
const root = sep;
|
|
302
303
|
const lockPaths = hunks.flatMap((hunk) => {
|
|
303
|
-
const paths = [resolvePatchPath(hunk.path,
|
|
304
|
-
if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo,
|
|
304
|
+
const paths = [resolvePatchPath(hunk.path, cwd)];
|
|
305
|
+
if (hunk.kind === "update" && hunk.moveTo) paths.push(resolvePatchPath(hunk.moveTo, cwd));
|
|
305
306
|
return paths;
|
|
306
307
|
});
|
|
307
308
|
|
|
308
309
|
return withMutationLocks(lockPaths, async () => {
|
|
309
310
|
const rootFd = await openSecureRoot(root, options.signal);
|
|
310
311
|
try {
|
|
311
|
-
const operations = await planOperations(hunks, root, rootFd, options.signal);
|
|
312
|
+
const operations = await planOperations(hunks, cwd, root, rootFd, options.signal);
|
|
312
313
|
throwIfAborted(options.signal);
|
|
313
314
|
// ponytail: preflight catches parse/match errors before writes; cross-process failures can still leave a partial multi-file patch.
|
|
314
315
|
for (const operation of operations) {
|
|
@@ -338,15 +339,21 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
|
|
|
338
339
|
});
|
|
339
340
|
}
|
|
340
341
|
|
|
341
|
-
async function planOperations(
|
|
342
|
+
async function planOperations(
|
|
343
|
+
hunks: ApplyPatchHunk[],
|
|
344
|
+
cwd: string,
|
|
345
|
+
root: string,
|
|
346
|
+
rootFd: number,
|
|
347
|
+
signal?: AbortSignal,
|
|
348
|
+
): Promise<PlannedOperation[]> {
|
|
342
349
|
const operations: PlannedOperation[] = [];
|
|
343
350
|
const virtualFiles = new Map<string, VirtualFile>();
|
|
344
351
|
|
|
345
352
|
const getVirtualFile = async (rawPath: string): Promise<{ absolute: string; file: VirtualFile }> => {
|
|
346
|
-
const absolute = resolvePatchPath(rawPath,
|
|
353
|
+
const absolute = resolvePatchPath(rawPath, cwd);
|
|
347
354
|
const existing = virtualFiles.get(absolute);
|
|
348
355
|
if (existing) return { absolute, file: existing };
|
|
349
|
-
const safe = await safePath(rawPath,
|
|
356
|
+
const safe = await safePath(rawPath, cwd, signal);
|
|
350
357
|
const file: VirtualFile = {
|
|
351
358
|
exists: safe.exists,
|
|
352
359
|
isDirectory: safe.isDirectory,
|
|
@@ -377,7 +384,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
377
384
|
operations.push({
|
|
378
385
|
kind: "add",
|
|
379
386
|
path: source.absolute,
|
|
380
|
-
displayPath: displayPath(
|
|
387
|
+
displayPath: displayPath(cwd, source.absolute, hunk.path),
|
|
381
388
|
content: hunk.content,
|
|
382
389
|
});
|
|
383
390
|
continue;
|
|
@@ -386,7 +393,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
386
393
|
if (hunk.kind === "delete") {
|
|
387
394
|
if (!source.file.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
|
|
388
395
|
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
|
|
389
|
-
operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(
|
|
396
|
+
operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(cwd, source.absolute, hunk.path) });
|
|
390
397
|
source.file.exists = false;
|
|
391
398
|
source.file.content = undefined;
|
|
392
399
|
continue;
|
|
@@ -395,7 +402,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
395
402
|
if (!source.file.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
|
|
396
403
|
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
|
|
397
404
|
const original = await getVirtualContent(source.absolute, source.file);
|
|
398
|
-
const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo,
|
|
405
|
+
const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, cwd) : undefined;
|
|
399
406
|
if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
|
|
400
407
|
|
|
401
408
|
let destination: { absolute: string; file: VirtualFile } | undefined;
|
|
@@ -411,9 +418,9 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
411
418
|
operations.push({
|
|
412
419
|
kind: "update",
|
|
413
420
|
path: source.absolute,
|
|
414
|
-
displayPath: displayPath(
|
|
421
|
+
displayPath: displayPath(cwd, source.absolute, hunk.path),
|
|
415
422
|
moveTo: moveTo!,
|
|
416
|
-
moveDisplayPath: displayPath(
|
|
423
|
+
moveDisplayPath: displayPath(cwd, moveTo!, hunk.moveTo!),
|
|
417
424
|
chunkGroups: [[...hunk.chunks]],
|
|
418
425
|
content,
|
|
419
426
|
});
|
|
@@ -434,7 +441,7 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
434
441
|
operations.push({
|
|
435
442
|
kind: "update",
|
|
436
443
|
path: source.absolute,
|
|
437
|
-
displayPath: displayPath(
|
|
444
|
+
displayPath: displayPath(cwd, source.absolute, hunk.path),
|
|
438
445
|
chunkGroups: [[...hunk.chunks]],
|
|
439
446
|
content,
|
|
440
447
|
});
|
|
@@ -445,19 +452,16 @@ async function planOperations(hunks: ApplyPatchHunk[], root: string, rootFd: num
|
|
|
445
452
|
return operations;
|
|
446
453
|
}
|
|
447
454
|
|
|
448
|
-
function resolvePatchPath(rawPath: string,
|
|
449
|
-
|
|
450
|
-
if (!isWithin(root, absolute) || absolute === root) {
|
|
451
|
-
throw new Error(`Patch path must stay inside the current working directory: ${rawPath}`);
|
|
452
|
-
}
|
|
453
|
-
return absolute;
|
|
455
|
+
function resolvePatchPath(rawPath: string, cwd: string): string {
|
|
456
|
+
return isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwd, rawPath);
|
|
454
457
|
}
|
|
455
458
|
|
|
456
|
-
async function safePath(rawPath: string,
|
|
459
|
+
async function safePath(rawPath: string, cwd: string, signal?: AbortSignal): Promise<SafePath> {
|
|
457
460
|
throwIfAborted(signal);
|
|
458
|
-
const absolute = resolvePatchPath(rawPath,
|
|
461
|
+
const absolute = resolvePatchPath(rawPath, cwd);
|
|
459
462
|
|
|
460
463
|
let current = absolute;
|
|
464
|
+
let target: Omit<SafePath, "absolute"> = { exists: false, isDirectory: false, isFile: false };
|
|
461
465
|
while (true) {
|
|
462
466
|
throwIfAborted(signal);
|
|
463
467
|
try {
|
|
@@ -465,25 +469,18 @@ async function safePath(rawPath: string, root: string, signal?: AbortSignal): Pr
|
|
|
465
469
|
if (stats.isSymbolicLink()) {
|
|
466
470
|
throw new Error(`Symlink paths are not allowed in apply_patch: ${rawPath}`);
|
|
467
471
|
}
|
|
468
|
-
const resolved = await realpath(current);
|
|
469
|
-
if (!isWithin(root, resolved)) {
|
|
470
|
-
throw new Error(`Patch path escapes the current working directory: ${rawPath}`);
|
|
471
|
-
}
|
|
472
472
|
if (current !== absolute && !stats.isDirectory()) {
|
|
473
473
|
throw new Error(`Parent path is not a directory: ${rawPath}`);
|
|
474
474
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
isDirectory: current === absolute && stats.isDirectory(),
|
|
479
|
-
isFile: current === absolute && stats.isFile(),
|
|
480
|
-
};
|
|
475
|
+
if (current === absolute) {
|
|
476
|
+
target = { exists: true, isDirectory: stats.isDirectory(), isFile: stats.isFile() };
|
|
477
|
+
}
|
|
481
478
|
} catch (error) {
|
|
482
479
|
if (!isMissingPathError(error)) throw error;
|
|
483
|
-
const parent = dirname(current);
|
|
484
|
-
if (parent === current) throw new Error(`Cannot resolve patch path: ${rawPath}`);
|
|
485
|
-
current = parent;
|
|
486
480
|
}
|
|
481
|
+
const parent = dirname(current);
|
|
482
|
+
if (parent === current) return { absolute, ...target };
|
|
483
|
+
current = parent;
|
|
487
484
|
}
|
|
488
485
|
}
|
|
489
486
|
|
|
@@ -528,7 +525,7 @@ async function openSecureParentDirectory(rootFd: number, root: string, absolute:
|
|
|
528
525
|
const relativeParent = relative(root, parentPath);
|
|
529
526
|
const components = relativeParent ? relativeParent.split(sep) : [];
|
|
530
527
|
if (components.some((component) => !component || component === "." || component === "..")) {
|
|
531
|
-
throw new Error(`Patch path
|
|
528
|
+
throw new Error(`Patch path cannot be traversed securely: ${absolute}`);
|
|
532
529
|
}
|
|
533
530
|
|
|
534
531
|
let current = rootFd;
|
|
@@ -727,11 +724,6 @@ function displayPath(root: string, absolute: string, fallback: string): string {
|
|
|
727
724
|
return relativePath && !relativePath.startsWith("..") ? relativePath : fallback;
|
|
728
725
|
}
|
|
729
726
|
|
|
730
|
-
function isWithin(root: string, candidate: string): boolean {
|
|
731
|
-
const path = relative(root, candidate);
|
|
732
|
-
return path === "" || (!path.startsWith("..") && !isAbsolute(path));
|
|
733
|
-
}
|
|
734
|
-
|
|
735
727
|
function isMissingPathError(error: unknown): boolean {
|
|
736
728
|
return typeof error === "object" && error !== null && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR");
|
|
737
729
|
}
|
package/src/patch-preview.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// (it never throws) to drive a live, write-style glimpse of the content plus a running
|
|
6
6
|
// added/removed tally. It reuses Pi's shared `renderDiff` primitive for +/- coloring, so the
|
|
7
7
|
// preview stays consistent with the built-in `edit` tool's diff preview.
|
|
8
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
8
9
|
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
9
10
|
|
|
10
11
|
const FILE_ADD = "*** Add File: ";
|
|
@@ -26,6 +27,11 @@ const MAX_PREVIEW_BYTES = 256 * 1024;
|
|
|
26
27
|
const MAX_PREVIEW_FILES = 500;
|
|
27
28
|
/** Cap a single rendered glimpse line so minified/generated content cannot flood the TUI. */
|
|
28
29
|
const PREVIEW_LINE_CHARS = 200;
|
|
30
|
+
const TERMINAL_CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
|
31
|
+
|
|
32
|
+
function sanitizeTerminalText(text: string): string {
|
|
33
|
+
return stripTerminalSequences(text).replace(TERMINAL_CONTROL_CHARS, "").replace(/\t/g, " ");
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
export type PatchLineType = "add" | "del" | "ctx";
|
|
31
37
|
|
|
@@ -156,8 +162,10 @@ function fileMarkLabel(file: PatchPreviewFile, theme: Theme): string {
|
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
function filePathLabel(file: PatchPreviewFile, theme: Theme): string {
|
|
159
|
-
const path = theme.fg("accent", truncatePath(file.path));
|
|
160
|
-
return file.moveTo
|
|
165
|
+
const path = theme.fg("accent", truncatePath(sanitizeTerminalText(file.path)));
|
|
166
|
+
return file.moveTo
|
|
167
|
+
? `${path} ${theme.fg("muted", "->")} ${theme.fg("accent", truncatePath(sanitizeTerminalText(file.moveTo)))}`
|
|
168
|
+
: path;
|
|
161
169
|
}
|
|
162
170
|
|
|
163
171
|
function formatFileRosterLine(file: PatchPreviewFile, theme: Theme): string {
|
|
@@ -177,7 +185,8 @@ function countLines(file: PatchPreviewFile, type: PatchLineType): number {
|
|
|
177
185
|
*/
|
|
178
186
|
function renderGlimpseLine(line: PatchPreviewLine, theme: Theme): string {
|
|
179
187
|
const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
|
|
180
|
-
const
|
|
188
|
+
const text = sanitizeTerminalText(line.text);
|
|
189
|
+
const body = text.length > PREVIEW_LINE_CHARS ? `${text.slice(0, PREVIEW_LINE_CHARS)}…` : text;
|
|
181
190
|
const styled = `${sign}${body}`;
|
|
182
191
|
if (line.type === "add") return theme.fg("toolDiffAdded", styled);
|
|
183
192
|
if (line.type === "del") return theme.fg("toolDiffRemoved", styled);
|
|
@@ -263,5 +272,5 @@ export function formatApplyPatchResultText(
|
|
|
263
272
|
.map((part) => part.text ?? "")
|
|
264
273
|
.join("\n")
|
|
265
274
|
.trim();
|
|
266
|
-
return output ? theme.fg("error", output) : undefined;
|
|
275
|
+
return output ? theme.fg("error", sanitizeTerminalText(output)) : undefined;
|
|
267
276
|
}
|