@oh-my-pi/pi-coding-agent 14.2.1 → 14.3.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/CHANGELOG.md +59 -0
- package/package.json +19 -19
- package/src/cli/args.ts +10 -1
- package/src/cli/shell-cli.ts +15 -3
- package/src/config/settings-schema.ts +60 -1
- package/src/debug/system-info.ts +6 -2
- package/src/discovery/claude.ts +58 -36
- package/src/discovery/opencode.ts +20 -2
- package/src/edit/index.ts +2 -1
- package/src/edit/modes/chunk.ts +132 -56
- package/src/edit/modes/hashline.ts +36 -11
- package/src/edit/renderer.ts +98 -133
- package/src/edit/streaming.ts +351 -0
- package/src/exec/bash-executor.ts +60 -5
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/pi-protocol.ts +0 -2
- package/src/lsp/client.ts +8 -1
- package/src/lsp/defaults.json +2 -1
- package/src/modes/acp/acp-agent.ts +76 -2
- package/src/modes/components/assistant-message.ts +1 -34
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/tool-execution.ts +111 -101
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +0 -2
- package/src/modes/theme/mermaid-cache.ts +13 -52
- package/src/modes/theme/theme.ts +2 -2
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/chunk-edit.md +25 -22
- package/src/prompts/tools/gh-pr-push.md +2 -1
- package/src/prompts/tools/grep.md +4 -3
- package/src/prompts/tools/lsp.md +6 -0
- package/src/prompts/tools/read-chunk.md +46 -7
- package/src/prompts/tools/read.md +7 -4
- package/src/sdk.ts +8 -5
- package/src/session/agent-session.ts +36 -20
- package/src/session/session-manager.ts +228 -57
- package/src/session/streaming-output.ts +11 -0
- package/src/system-prompt.ts +7 -2
- package/src/task/executor.ts +1 -0
- package/src/tools/bash.ts +13 -0
- package/src/tools/gh.ts +6 -16
- package/src/tools/sqlite-reader.ts +116 -3
- package/src/web/search/providers/codex.ts +129 -6
package/src/edit/renderer.ts
CHANGED
|
@@ -24,12 +24,12 @@ import {
|
|
|
24
24
|
} from "../tools/render-utils";
|
|
25
25
|
import { type VimRenderArgs, vimToolRenderer } from "../tools/vim";
|
|
26
26
|
import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui";
|
|
27
|
+
import type { EditMode } from "../utils/edit-mode";
|
|
27
28
|
import type { VimToolDetails } from "../vim/types";
|
|
28
29
|
import type { DiffError, DiffResult } from "./diff";
|
|
29
30
|
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
|
30
|
-
import { type ChunkToolEdit, parseChunkEditPath } from "./modes/chunk";
|
|
31
|
-
import type { HashlineToolEdit } from "./modes/hashline";
|
|
32
31
|
import type { Operation, PatchEditEntry } from "./modes/patch";
|
|
32
|
+
import type { PerFileDiffPreview } from "./streaming";
|
|
33
33
|
|
|
34
34
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
35
35
|
// LSP Batching
|
|
@@ -90,6 +90,7 @@ interface EditRenderArgs {
|
|
|
90
90
|
* Computed preview diff (used when tool args don't include a diff, e.g. hashline mode).
|
|
91
91
|
*/
|
|
92
92
|
previewDiff?: string;
|
|
93
|
+
__partialJson?: string;
|
|
93
94
|
// Hashline / chunk mode fields
|
|
94
95
|
edits?: EditRenderEntry[];
|
|
95
96
|
}
|
|
@@ -133,8 +134,12 @@ function isVimToolDetails(details: unknown): details is VimToolDetails {
|
|
|
133
134
|
|
|
134
135
|
/** Extended context for edit tool rendering */
|
|
135
136
|
export interface EditRenderContext {
|
|
137
|
+
/** Edit mode resolved by the caller; lets the renderer dispatch without shape-sniffing */
|
|
138
|
+
editMode?: EditMode;
|
|
136
139
|
/** Pre-computed diff preview (computed before tool executes) */
|
|
137
140
|
editDiffPreview?: DiffResult | DiffError;
|
|
141
|
+
/** Multi-file streaming diff preview (chunk edits spanning several files) */
|
|
142
|
+
perFileDiffPreview?: PerFileDiffPreview[];
|
|
138
143
|
/** Function to render diff text with syntax highlighting */
|
|
139
144
|
renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
|
|
140
145
|
}
|
|
@@ -142,14 +147,6 @@ export interface EditRenderContext {
|
|
|
142
147
|
const EDIT_STREAMING_PREVIEW_LINES = 12;
|
|
143
148
|
const CALL_TEXT_PREVIEW_LINES = 6;
|
|
144
149
|
const CALL_TEXT_PREVIEW_WIDTH = 80;
|
|
145
|
-
const STREAMING_EDIT_PREVIEW_WIDTH = 120;
|
|
146
|
-
const STREAMING_EDIT_PREVIEW_LIMIT = 4;
|
|
147
|
-
const STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT = 8;
|
|
148
|
-
|
|
149
|
-
interface FormattedStreamingEdit {
|
|
150
|
-
srcLabel: string;
|
|
151
|
-
dst: string;
|
|
152
|
-
}
|
|
153
150
|
|
|
154
151
|
/** Extract file path from an edit entry's path (handles chunk's file:selector format). */
|
|
155
152
|
function filePathFromEditEntry(p: string | undefined): string | undefined {
|
|
@@ -158,6 +155,48 @@ function filePathFromEditEntry(p: string | undefined): string | undefined {
|
|
|
158
155
|
return ci === -1 ? p : p.slice(0, ci);
|
|
159
156
|
}
|
|
160
157
|
|
|
158
|
+
function decodePartialJsonStringFragment(fragment: string): string {
|
|
159
|
+
let text = fragment;
|
|
160
|
+
const trailingBackslashes = text.match(/\\+$/)?.[0].length ?? 0;
|
|
161
|
+
if (trailingBackslashes % 2 === 1) {
|
|
162
|
+
text = text.slice(0, -1);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(`"${text}"`) as string;
|
|
166
|
+
} catch {
|
|
167
|
+
return text
|
|
168
|
+
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
169
|
+
.replace(/\\(["\\/bfnrt])/g, (_, ch: string) => {
|
|
170
|
+
switch (ch) {
|
|
171
|
+
case "b":
|
|
172
|
+
return "\b";
|
|
173
|
+
case "f":
|
|
174
|
+
return "\f";
|
|
175
|
+
case "n":
|
|
176
|
+
return "\n";
|
|
177
|
+
case "r":
|
|
178
|
+
return "\r";
|
|
179
|
+
case "t":
|
|
180
|
+
return "\t";
|
|
181
|
+
default:
|
|
182
|
+
return ch;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function extractPartialJsonString(partialJson: string | undefined, key: string): string | undefined {
|
|
189
|
+
if (!partialJson) return undefined;
|
|
190
|
+
const pattern = new RegExp(`"${key}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)`, "u");
|
|
191
|
+
const match = pattern.exec(partialJson);
|
|
192
|
+
if (!match) return undefined;
|
|
193
|
+
return decodePartialJsonStringFragment(match[1]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getPartialJsonEditPath(args: EditRenderArgs): string | undefined {
|
|
197
|
+
return filePathFromEditEntry(extractPartialJsonString(args.__partialJson, "path"));
|
|
198
|
+
}
|
|
199
|
+
|
|
161
200
|
/** Count distinct file paths in an edits array. */
|
|
162
201
|
function countEditFiles(edits: EditRenderEntry[]): number {
|
|
163
202
|
return new Set(edits.map(edit => filePathFromEditEntry(edit.path)).filter(Boolean)).size;
|
|
@@ -230,133 +269,46 @@ function formatStreamingDiff(diff: string, rawPath: string, uiTheme: Theme, labe
|
|
|
230
269
|
return text;
|
|
231
270
|
}
|
|
232
271
|
|
|
233
|
-
function
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
"path" in edit &&
|
|
238
|
-
("write" in edit || "replace" in edit || "insert" in edit)
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function getStreamingEditContent(content: unknown): string {
|
|
243
|
-
if (Array.isArray(content)) {
|
|
244
|
-
return content.join("\n");
|
|
245
|
-
}
|
|
246
|
-
return typeof content === "string" ? content : "";
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function formatHashlineStreamingEdit(edit: Partial<HashlineToolEdit>): FormattedStreamingEdit {
|
|
250
|
-
if (typeof edit !== "object" || !edit) {
|
|
251
|
-
return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const contentLines = getStreamingEditContent(edit.content);
|
|
255
|
-
const loc = edit.loc;
|
|
256
|
-
|
|
257
|
-
if (loc === "append" || loc === "prepend") {
|
|
258
|
-
return { srcLabel: `\u2022 ${loc} (file-level)`, dst: contentLines };
|
|
259
|
-
}
|
|
260
|
-
if (typeof loc === "object" && loc) {
|
|
261
|
-
if ("range" in loc && typeof loc.range === "object" && loc.range) {
|
|
262
|
-
return { srcLabel: `\u2022 range ${loc.range.pos ?? "?"}\u2026${loc.range.end ?? "?"}`, dst: contentLines };
|
|
263
|
-
}
|
|
264
|
-
if ("line" in loc) {
|
|
265
|
-
return { srcLabel: `\u2022 line ${(loc as { line: string }).line}`, dst: contentLines };
|
|
266
|
-
}
|
|
267
|
-
if ("append" in loc) {
|
|
268
|
-
return { srcLabel: `\u2022 append ${(loc as { append: string }).append}`, dst: contentLines };
|
|
269
|
-
}
|
|
270
|
-
if ("prepend" in loc) {
|
|
271
|
-
return { srcLabel: `\u2022 prepend ${(loc as { prepend: string }).prepend}`, dst: contentLines };
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
return { srcLabel: "\u2022 (unknown edit)", dst: contentLines };
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function formatChunkStreamingEdit(edit: Partial<ChunkToolEdit>): FormattedStreamingEdit {
|
|
278
|
-
if (typeof edit !== "object" || !edit) {
|
|
279
|
-
return { srcLabel: "\u2022 (incomplete edit)", dst: "" };
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const target = edit.path ? (parseChunkEditPath(edit.path).selector ?? edit.path) : "?";
|
|
283
|
-
if (edit.write === null) {
|
|
284
|
-
return { srcLabel: `\u2022 remove ${target}`, dst: "" };
|
|
285
|
-
}
|
|
286
|
-
if (typeof edit.write === "string") {
|
|
287
|
-
return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.write) };
|
|
288
|
-
}
|
|
289
|
-
if (typeof edit.replace === "object" && edit.replace) {
|
|
290
|
-
return { srcLabel: `\u2022 replace ${target}`, dst: getStreamingEditContent(edit.replace.new) };
|
|
291
|
-
}
|
|
292
|
-
if (typeof edit.insert === "object" && edit.insert) {
|
|
293
|
-
return { srcLabel: `\u2022 ${edit.insert.loc} ${target}`, dst: getStreamingEditContent(edit.insert.body) };
|
|
272
|
+
function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
|
|
273
|
+
const icon = uiTheme.getLangIcon(language);
|
|
274
|
+
if (lineCount !== null) {
|
|
275
|
+
return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
|
|
294
276
|
}
|
|
295
|
-
return
|
|
277
|
+
return uiTheme.fg("dim", `${icon}`);
|
|
296
278
|
}
|
|
297
279
|
|
|
298
|
-
function
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
text += uiTheme.fg("dim", `[${edits.length} ${label}${edits.length === 1 ? "" : "s"}]`);
|
|
306
|
-
text += "\n";
|
|
307
|
-
let shownEdits = 0;
|
|
308
|
-
let shownDstLines = 0;
|
|
309
|
-
for (const edit of edits) {
|
|
310
|
-
shownEdits++;
|
|
311
|
-
if (shownEdits > STREAMING_EDIT_PREVIEW_LIMIT) break;
|
|
312
|
-
const formatted = formatEdit(edit as never);
|
|
313
|
-
text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(formatted.srcLabel), STREAMING_EDIT_PREVIEW_WIDTH));
|
|
314
|
-
text += "\n";
|
|
315
|
-
if (formatted.dst === "") {
|
|
316
|
-
text += uiTheme.fg("dim", truncateToWidth(" (delete)", STREAMING_EDIT_PREVIEW_WIDTH));
|
|
317
|
-
text += "\n";
|
|
280
|
+
function formatMultiFileStreamingDiff(previews: PerFileDiffPreview[], uiTheme: Theme): string {
|
|
281
|
+
const parts: string[] = [];
|
|
282
|
+
for (const preview of previews) {
|
|
283
|
+
if (!preview.diff && !preview.error) continue;
|
|
284
|
+
const header = uiTheme.fg("dim", `\n\n── ${shortenPath(preview.path)} ──`);
|
|
285
|
+
if (preview.error) {
|
|
286
|
+
parts.push(`${header}\n${uiTheme.fg("error", replaceTabs(preview.error))}`);
|
|
318
287
|
continue;
|
|
319
288
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
|
|
323
|
-
text += uiTheme.fg("toolOutput", truncateToWidth(replaceTabs(`+ ${dstLine}`), STREAMING_EDIT_PREVIEW_WIDTH));
|
|
324
|
-
text += "\n";
|
|
289
|
+
if (preview.diff) {
|
|
290
|
+
parts.push(`${header}${formatStreamingDiff(preview.diff, preview.path, uiTheme, "preview")}`);
|
|
325
291
|
}
|
|
326
|
-
if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) break;
|
|
327
292
|
}
|
|
328
|
-
|
|
329
|
-
text += uiTheme.fg("dim", `\u2026 (${edits.length - STREAMING_EDIT_PREVIEW_LIMIT} more edits)`);
|
|
330
|
-
}
|
|
331
|
-
if (shownDstLines > STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT) {
|
|
332
|
-
text += uiTheme.fg("dim", `\n\u2026 (${shownDstLines - STREAMING_EDIT_PREVIEW_DST_LINE_LIMIT} more dst lines)`);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
return text.trimEnd();
|
|
293
|
+
return parts.join("");
|
|
336
294
|
}
|
|
337
295
|
|
|
338
|
-
function
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
296
|
+
function getCallPreview(
|
|
297
|
+
args: EditRenderArgs,
|
|
298
|
+
rawPath: string,
|
|
299
|
+
uiTheme: Theme,
|
|
300
|
+
renderContext: EditRenderContext | undefined,
|
|
301
|
+
): string {
|
|
302
|
+
const multi = renderContext?.perFileDiffPreview;
|
|
303
|
+
if (multi && multi.length > 0 && multi.some(p => p.diff || p.error)) {
|
|
304
|
+
return formatMultiFileStreamingDiff(multi, uiTheme);
|
|
342
305
|
}
|
|
343
|
-
return uiTheme.fg("dim", `${icon}`);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function getCallPreview(args: EditRenderArgs, rawPath: string, uiTheme: Theme): string {
|
|
347
306
|
if (args.previewDiff) {
|
|
348
307
|
return formatStreamingDiff(args.previewDiff, rawPath, uiTheme, "preview");
|
|
349
308
|
}
|
|
350
309
|
if (args.diff && args.op) {
|
|
351
310
|
return formatStreamingDiff(args.diff, rawPath, uiTheme);
|
|
352
311
|
}
|
|
353
|
-
if (args.edits && args.edits.length > 0) {
|
|
354
|
-
// Only show hashline/chunk streaming edits — replace/patch use previewDiff above
|
|
355
|
-
const first = args.edits[0];
|
|
356
|
-
if (first && typeof first === "object" && ("loc" in first || isChunkStreamingEdit(first))) {
|
|
357
|
-
return formatStreamingHashlineEdits(args.edits, uiTheme);
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
312
|
if (args.diff) {
|
|
361
313
|
return renderPlainTextPreview(args.diff, uiTheme);
|
|
362
314
|
}
|
|
@@ -446,31 +398,44 @@ function wrapEditRendererLine(line: string, width: number): string[] {
|
|
|
446
398
|
export const editToolRenderer = {
|
|
447
399
|
mergeCallAndResult: true,
|
|
448
400
|
|
|
449
|
-
renderCall(
|
|
450
|
-
|
|
451
|
-
|
|
401
|
+
renderCall(
|
|
402
|
+
args: EditRenderArgs | VimRenderArgs,
|
|
403
|
+
options: RenderResultOptions & { renderContext?: EditRenderContext },
|
|
404
|
+
uiTheme: Theme,
|
|
405
|
+
): Component {
|
|
406
|
+
const renderContext = options.renderContext;
|
|
407
|
+
// Dispatch on the explicit editMode when available; fall back to the
|
|
408
|
+
// shape probe for legacy call sites that don't thread renderContext.
|
|
409
|
+
if (renderContext?.editMode === "vim" || isVimRenderArgs(args)) {
|
|
410
|
+
return vimToolRenderer.renderCall(args as VimRenderArgs, options, uiTheme);
|
|
452
411
|
}
|
|
453
412
|
|
|
454
|
-
const
|
|
413
|
+
const editArgs = args as EditRenderArgs;
|
|
414
|
+
const applyPatchSummary = getApplyPatchRenderSummary(editArgs, options.isPartial);
|
|
455
415
|
const firstApplyPatchEntry = applyPatchSummary?.entries[0];
|
|
456
416
|
// Extract path from first edit entry when top-level path is absent (new schema)
|
|
457
|
-
const firstEdit = Array.isArray(
|
|
417
|
+
const firstEdit = Array.isArray(editArgs.edits) && editArgs.edits.length > 0 ? editArgs.edits[0] : undefined;
|
|
458
418
|
const rawPath =
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
419
|
+
editArgs.file_path ||
|
|
420
|
+
editArgs.path ||
|
|
421
|
+
filePathFromEditEntry(firstEdit?.path) ||
|
|
422
|
+
getPartialJsonEditPath(editArgs) ||
|
|
423
|
+
firstApplyPatchEntry?.path ||
|
|
424
|
+
"";
|
|
425
|
+
const rename = editArgs.rename || firstEdit?.rename || firstEdit?.move || firstApplyPatchEntry?.rename;
|
|
426
|
+
const op = editArgs.op || firstEdit?.op || firstApplyPatchEntry?.op;
|
|
462
427
|
const { description } = formatEditDescription(rawPath, uiTheme, { rename });
|
|
463
428
|
const spinner =
|
|
464
429
|
options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
|
|
465
430
|
let text = `${formatTitle(getOperationTitle(op), uiTheme)} ${spinner ? `${spinner} ` : ""}${description}`;
|
|
466
431
|
// Show file count hint for multi-file edits
|
|
467
|
-
const fileCount = Array.isArray(
|
|
468
|
-
? countEditFiles(
|
|
432
|
+
const fileCount = Array.isArray(editArgs.edits)
|
|
433
|
+
? countEditFiles(editArgs.edits)
|
|
469
434
|
: (applyPatchSummary?.entries.length ?? 0);
|
|
470
435
|
if (fileCount > 1) {
|
|
471
436
|
text += uiTheme.fg("dim", ` (+${fileCount - 1} more)`);
|
|
472
437
|
}
|
|
473
|
-
text += getCallPreview(
|
|
438
|
+
text += getCallPreview(editArgs, rawPath, uiTheme, renderContext);
|
|
474
439
|
if (applyPatchSummary?.error) {
|
|
475
440
|
text += `\n\n${uiTheme.fg("error", truncateToWidth(replaceTabs(applyPatchSummary.error), CALL_TEXT_PREVIEW_WIDTH))}`;
|
|
476
441
|
}
|
|
@@ -484,7 +449,7 @@ export const editToolRenderer = {
|
|
|
484
449
|
uiTheme: Theme,
|
|
485
450
|
args?: EditRenderArgs,
|
|
486
451
|
): Component {
|
|
487
|
-
if (isVimToolDetails(result.details)) {
|
|
452
|
+
if (options.renderContext?.editMode === "vim" || isVimToolDetails(result.details)) {
|
|
488
453
|
return vimToolRenderer.renderResult(
|
|
489
454
|
result as { content: Array<{ type: string; text?: string }>; details?: VimToolDetails; isError?: boolean },
|
|
490
455
|
options,
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming edit preview strategies.
|
|
3
|
+
*
|
|
4
|
+
* Each edit mode owns a strategy that knows how to:
|
|
5
|
+
* - collapse partial-JSON args to the subset safe to preview
|
|
6
|
+
* (`extractCompleteEdits`),
|
|
7
|
+
* - compute unified diff previews for the in-flight args
|
|
8
|
+
* (`computeDiffPreview`), and
|
|
9
|
+
* - render a text placeholder while no diff exists yet
|
|
10
|
+
* (`renderStreamingFallback`).
|
|
11
|
+
*
|
|
12
|
+
* The shared renderer / `ToolExecutionComponent` consult the strategy via
|
|
13
|
+
* the injected `editMode` rather than probing argument shape.
|
|
14
|
+
*/
|
|
15
|
+
import type { Theme } from "../modes/theme/theme";
|
|
16
|
+
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
|
|
17
|
+
import { computeEditDiff, type DiffError, type DiffResult } from "./diff";
|
|
18
|
+
import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./modes/apply-patch";
|
|
19
|
+
import { type ChunkToolEdit, computeChunkDiff, parseChunkEditPath } from "./modes/chunk";
|
|
20
|
+
import { computeHashlineDiff, type HashlineToolEdit } from "./modes/hashline";
|
|
21
|
+
import { computePatchDiff, type PatchEditEntry } from "./modes/patch";
|
|
22
|
+
import type { ReplaceEditEntry } from "./modes/replace";
|
|
23
|
+
|
|
24
|
+
export interface PerFileDiffPreview {
|
|
25
|
+
path: string;
|
|
26
|
+
diff?: string;
|
|
27
|
+
firstChangedLine?: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StreamingDiffContext {
|
|
32
|
+
cwd: string;
|
|
33
|
+
signal: AbortSignal;
|
|
34
|
+
fuzzyThreshold?: number;
|
|
35
|
+
allowFuzzy?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EditStreamingStrategy<Args = unknown> {
|
|
39
|
+
/**
|
|
40
|
+
* Return the args restricted to edits that are "complete enough" to
|
|
41
|
+
* compute a diff against. Strategies drop the trailing incomplete entry
|
|
42
|
+
* when `partialJson` indicates its closing `}` hasn't arrived yet.
|
|
43
|
+
*/
|
|
44
|
+
extractCompleteEdits(args: Args, partialJson: string | undefined): Args;
|
|
45
|
+
/**
|
|
46
|
+
* Compute diff(s) for the given partial args. Returns `null` when args
|
|
47
|
+
* do not yet carry enough structure to compute anything.
|
|
48
|
+
*/
|
|
49
|
+
computeDiffPreview(args: Args, ctx: StreamingDiffContext): Promise<PerFileDiffPreview[] | null>;
|
|
50
|
+
/**
|
|
51
|
+
* Rendered inline while the diff hasn't been computed yet (or when the
|
|
52
|
+
* compute returned `null` because args are still too partial).
|
|
53
|
+
*/
|
|
54
|
+
renderStreamingFallback(args: Args, uiTheme: Theme): string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// -----------------------------------------------------------------------------
|
|
58
|
+
// Partial-JSON handling
|
|
59
|
+
// -----------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Given an edits array parsed from partial JSON, drop the last entry when the
|
|
63
|
+
* corresponding object in `partialJson` does not yet end with a closed `}`.
|
|
64
|
+
*
|
|
65
|
+
* This guards against `partial-json` silently coercing truncated tails like
|
|
66
|
+
* `"write":nu` / `"write":nul` into `{ write: null }`, which would make the
|
|
67
|
+
* last entry render a spurious null-write error until the value finishes
|
|
68
|
+
* streaming.
|
|
69
|
+
*/
|
|
70
|
+
export function dropIncompleteLastEdit<T>(edits: readonly T[], partialJson: string | undefined, listKey: string): T[] {
|
|
71
|
+
if (!Array.isArray(edits) || edits.length === 0) return [...(edits ?? [])];
|
|
72
|
+
if (!partialJson) return [...edits];
|
|
73
|
+
|
|
74
|
+
const keyMarker = `"${listKey}"`;
|
|
75
|
+
const keyIdx = partialJson.indexOf(keyMarker);
|
|
76
|
+
if (keyIdx === -1) return [...edits];
|
|
77
|
+
|
|
78
|
+
// Find the `[` that opens the list value.
|
|
79
|
+
let i = partialJson.indexOf("[", keyIdx + keyMarker.length);
|
|
80
|
+
if (i === -1) return [...edits];
|
|
81
|
+
i++;
|
|
82
|
+
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let inString = false;
|
|
85
|
+
let escaped = false;
|
|
86
|
+
let lastClose = -1;
|
|
87
|
+
for (; i < partialJson.length; i++) {
|
|
88
|
+
const ch = partialJson[i];
|
|
89
|
+
if (escaped) {
|
|
90
|
+
escaped = false;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (ch === "\\") {
|
|
94
|
+
if (inString) escaped = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === '"') {
|
|
98
|
+
inString = !inString;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (inString) continue;
|
|
102
|
+
if (ch === "{" || ch === "[") {
|
|
103
|
+
depth++;
|
|
104
|
+
} else if (ch === "}" || ch === "]") {
|
|
105
|
+
depth--;
|
|
106
|
+
if (ch === "}" && depth === 0) {
|
|
107
|
+
lastClose = i;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "]" && depth === -1) {
|
|
110
|
+
// End of list reached.
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If we're still inside the list and saw no closing `}` for the last entry,
|
|
117
|
+
// or there is trailing non-whitespace after the last `}` before the list
|
|
118
|
+
// ended (i.e. a new object has opened), drop the trailing entry.
|
|
119
|
+
const tail = lastClose === -1 ? partialJson.slice(i) : partialJson.slice(lastClose + 1);
|
|
120
|
+
const sawNewObjectAfterLastClose = /\{/.test(tail);
|
|
121
|
+
const listIsStillOpen = depth >= 0;
|
|
122
|
+
|
|
123
|
+
if (lastClose === -1 || (listIsStillOpen && sawNewObjectAfterLastClose)) {
|
|
124
|
+
return edits.slice(0, -1);
|
|
125
|
+
}
|
|
126
|
+
return [...edits];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// -----------------------------------------------------------------------------
|
|
130
|
+
// Strategies
|
|
131
|
+
// -----------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
interface ReplaceArgs {
|
|
134
|
+
edits?: ReplaceEditEntry[];
|
|
135
|
+
__partialJson?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const replaceStrategy: EditStreamingStrategy<ReplaceArgs> = {
|
|
139
|
+
extractCompleteEdits(args, partialJson) {
|
|
140
|
+
if (!args?.edits) return args;
|
|
141
|
+
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
142
|
+
},
|
|
143
|
+
async computeDiffPreview(args, ctx) {
|
|
144
|
+
const first = args.edits?.[0];
|
|
145
|
+
if (!first?.path || first.old_text === undefined || first.new_text === undefined) return null;
|
|
146
|
+
ctx.signal.throwIfAborted();
|
|
147
|
+
const result = await computeEditDiff(
|
|
148
|
+
first.path,
|
|
149
|
+
first.old_text,
|
|
150
|
+
first.new_text,
|
|
151
|
+
ctx.cwd,
|
|
152
|
+
ctx.allowFuzzy ?? true,
|
|
153
|
+
first.all,
|
|
154
|
+
ctx.fuzzyThreshold,
|
|
155
|
+
);
|
|
156
|
+
ctx.signal.throwIfAborted();
|
|
157
|
+
return [toPerFilePreview(first.path, result)];
|
|
158
|
+
},
|
|
159
|
+
renderStreamingFallback() {
|
|
160
|
+
return "";
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
interface PatchArgs {
|
|
165
|
+
edits?: PatchEditEntry[];
|
|
166
|
+
__partialJson?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const patchStrategy: EditStreamingStrategy<PatchArgs> = {
|
|
170
|
+
extractCompleteEdits(args, partialJson) {
|
|
171
|
+
if (!args?.edits) return args;
|
|
172
|
+
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
173
|
+
},
|
|
174
|
+
async computeDiffPreview(args, ctx) {
|
|
175
|
+
const first = args.edits?.[0];
|
|
176
|
+
if (!first?.path) return null;
|
|
177
|
+
ctx.signal.throwIfAborted();
|
|
178
|
+
const result = await computePatchDiff(
|
|
179
|
+
{ path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
|
|
180
|
+
ctx.cwd,
|
|
181
|
+
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
|
|
182
|
+
);
|
|
183
|
+
ctx.signal.throwIfAborted();
|
|
184
|
+
return [toPerFilePreview(first.path, result)];
|
|
185
|
+
},
|
|
186
|
+
renderStreamingFallback() {
|
|
187
|
+
return "";
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
interface HashlineArgs {
|
|
192
|
+
edits?: HashlineToolEdit[];
|
|
193
|
+
move?: string;
|
|
194
|
+
__partialJson?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
198
|
+
extractCompleteEdits(args, partialJson) {
|
|
199
|
+
if (!args?.edits) return args;
|
|
200
|
+
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
|
|
201
|
+
},
|
|
202
|
+
async computeDiffPreview(args, ctx) {
|
|
203
|
+
const first = args.edits?.[0] as (HashlineToolEdit & { path?: string }) | undefined;
|
|
204
|
+
if (!first?.path) return null;
|
|
205
|
+
const path = first.path;
|
|
206
|
+
const fileEdits = (args.edits ?? []).filter((e): e is HashlineToolEdit & { path: string } => {
|
|
207
|
+
return !!e && typeof e === "object" && (e as { path?: string }).path === path;
|
|
208
|
+
});
|
|
209
|
+
ctx.signal.throwIfAborted();
|
|
210
|
+
const result = await computeHashlineDiff({ path, edits: fileEdits, move: args.move }, ctx.cwd);
|
|
211
|
+
ctx.signal.throwIfAborted();
|
|
212
|
+
return [toPerFilePreview(path, result)];
|
|
213
|
+
},
|
|
214
|
+
renderStreamingFallback() {
|
|
215
|
+
return "";
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
interface ChunkArgs {
|
|
220
|
+
edits?: ChunkToolEdit[];
|
|
221
|
+
__partialJson?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const chunkStrategy: EditStreamingStrategy<ChunkArgs> = {
|
|
225
|
+
extractCompleteEdits(args, partialJson) {
|
|
226
|
+
if (!args?.edits) return args;
|
|
227
|
+
let edits = dropIncompleteLastEdit(args.edits, partialJson, "edits");
|
|
228
|
+
// Extra guard: if partial JSON still contains `":nu` / `":nul` (partial
|
|
229
|
+
// `null` literals), `partial-json` may have already surfaced the last
|
|
230
|
+
// entry with `write === null`. When that entry's `}` hasn't closed
|
|
231
|
+
// yet, it has already been dropped above. But if dropping was not
|
|
232
|
+
// triggered (e.g. list still open and no new `{` after), also drop the
|
|
233
|
+
// trailing null-write entry so the preview does not flicker with an
|
|
234
|
+
// error for an incomplete string/null literal.
|
|
235
|
+
if (partialJson && edits.length > 0) {
|
|
236
|
+
const last = edits[edits.length - 1] as Partial<ChunkToolEdit> | undefined;
|
|
237
|
+
const endsInPartialNull = /:\s*nu?l?\s*$/.test(partialJson.trimEnd());
|
|
238
|
+
if (last && endsInPartialNull && last.write === null) {
|
|
239
|
+
edits = edits.slice(0, -1);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { ...args, edits };
|
|
243
|
+
},
|
|
244
|
+
async computeDiffPreview(args, ctx) {
|
|
245
|
+
const edits = args.edits ?? [];
|
|
246
|
+
if (edits.length === 0) return null;
|
|
247
|
+
// Group edits by file path
|
|
248
|
+
const groups = new Map<string, ChunkToolEdit[]>();
|
|
249
|
+
const fileOrder: string[] = [];
|
|
250
|
+
for (const edit of edits) {
|
|
251
|
+
if (!edit?.path) continue;
|
|
252
|
+
const { filePath } = parseChunkEditPath(edit.path);
|
|
253
|
+
if (!filePath) continue;
|
|
254
|
+
let bucket = groups.get(filePath);
|
|
255
|
+
if (!bucket) {
|
|
256
|
+
bucket = [];
|
|
257
|
+
groups.set(filePath, bucket);
|
|
258
|
+
fileOrder.push(filePath);
|
|
259
|
+
}
|
|
260
|
+
bucket.push(edit);
|
|
261
|
+
}
|
|
262
|
+
if (fileOrder.length === 0) return null;
|
|
263
|
+
|
|
264
|
+
const MAX_FILES = 5;
|
|
265
|
+
const selected = fileOrder.slice(0, MAX_FILES);
|
|
266
|
+
const previews: PerFileDiffPreview[] = [];
|
|
267
|
+
for (const filePath of selected) {
|
|
268
|
+
ctx.signal.throwIfAborted();
|
|
269
|
+
const fileEdits = groups.get(filePath) ?? [];
|
|
270
|
+
const result = await computeChunkDiff({ path: filePath, edits: fileEdits }, ctx.cwd, { signal: ctx.signal });
|
|
271
|
+
previews.push(toPerFilePreview(filePath, result));
|
|
272
|
+
}
|
|
273
|
+
return previews;
|
|
274
|
+
},
|
|
275
|
+
renderStreamingFallback() {
|
|
276
|
+
return "";
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
interface ApplyPatchArgs {
|
|
281
|
+
input?: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
|
|
285
|
+
extractCompleteEdits(args) {
|
|
286
|
+
// Apply_patch payload is plain text, not an edits array. Nothing to trim.
|
|
287
|
+
return args;
|
|
288
|
+
},
|
|
289
|
+
async computeDiffPreview(args, ctx) {
|
|
290
|
+
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
291
|
+
let entries: PatchEditEntry[];
|
|
292
|
+
try {
|
|
293
|
+
entries = expandApplyPatchToEntries({ input: args.input });
|
|
294
|
+
} catch {
|
|
295
|
+
try {
|
|
296
|
+
entries = expandApplyPatchToPreviewEntries({ input: args.input });
|
|
297
|
+
} catch (err) {
|
|
298
|
+
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const first = entries[0];
|
|
302
|
+
if (!first?.path) return null;
|
|
303
|
+
ctx.signal.throwIfAborted();
|
|
304
|
+
const result = await computePatchDiff(
|
|
305
|
+
{ path: first.path, op: first.op ?? "update", rename: first.rename, diff: first.diff },
|
|
306
|
+
ctx.cwd,
|
|
307
|
+
{ fuzzyThreshold: ctx.fuzzyThreshold, allowFuzzy: ctx.allowFuzzy },
|
|
308
|
+
);
|
|
309
|
+
ctx.signal.throwIfAborted();
|
|
310
|
+
return [toPerFilePreview(first.path, result)];
|
|
311
|
+
},
|
|
312
|
+
renderStreamingFallback() {
|
|
313
|
+
return "";
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
// Vim streaming preview is handled by the existing vimToolRenderer inside
|
|
318
|
+
// edit/renderer.ts. The strategy here is a no-op so the registry is total.
|
|
319
|
+
const vimStrategy: EditStreamingStrategy<unknown> = {
|
|
320
|
+
extractCompleteEdits(args) {
|
|
321
|
+
return args;
|
|
322
|
+
},
|
|
323
|
+
async computeDiffPreview() {
|
|
324
|
+
return null;
|
|
325
|
+
},
|
|
326
|
+
renderStreamingFallback() {
|
|
327
|
+
return "";
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
|
|
332
|
+
replace: replaceStrategy as EditStreamingStrategy<unknown>,
|
|
333
|
+
patch: patchStrategy as EditStreamingStrategy<unknown>,
|
|
334
|
+
hashline: hashlineStrategy as EditStreamingStrategy<unknown>,
|
|
335
|
+
chunk: chunkStrategy as EditStreamingStrategy<unknown>,
|
|
336
|
+
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
|
|
337
|
+
vim: vimStrategy,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
export { resolveEditMode };
|
|
341
|
+
|
|
342
|
+
// -----------------------------------------------------------------------------
|
|
343
|
+
// Helpers
|
|
344
|
+
// -----------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
function toPerFilePreview(path: string, result: DiffResult | DiffError): PerFileDiffPreview {
|
|
347
|
+
if ("error" in result) {
|
|
348
|
+
return { path, error: result.error };
|
|
349
|
+
}
|
|
350
|
+
return { path, diff: result.diff, firstChangedLine: result.firstChangedLine };
|
|
351
|
+
}
|