@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.1
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 +79 -0
- package/package.json +8 -8
- package/src/async/job-manager.ts +43 -10
- package/src/commit/agentic/tools/analyze-file.ts +1 -2
- package/src/config/mcp-schema.json +1 -1
- package/src/config/model-equivalence.ts +1 -0
- package/src/config/model-registry.ts +63 -34
- package/src/config/model-resolver.ts +111 -15
- package/src/config/settings-schema.ts +4 -3
- package/src/config/settings.ts +1 -1
- package/src/cursor.ts +64 -23
- package/src/edit/index.ts +254 -89
- package/src/edit/modes/chunk.ts +336 -57
- package/src/edit/modes/hashline.ts +51 -26
- package/src/edit/modes/patch.ts +16 -10
- package/src/edit/modes/replace.ts +15 -7
- package/src/edit/renderer.ts +248 -94
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +6 -4
- package/src/extensibility/custom-tools/types.ts +0 -3
- package/src/extensibility/extensions/loader.ts +16 -0
- package/src/extensibility/extensions/runner.ts +2 -7
- package/src/extensibility/extensions/types.ts +8 -4
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/ipy/executor.ts +447 -52
- package/src/ipy/kernel.ts +39 -13
- package/src/lsp/client.ts +54 -0
- package/src/lsp/index.ts +8 -0
- package/src/lsp/types.ts +6 -0
- package/src/main.ts +0 -1
- package/src/modes/acp/acp-agent.ts +4 -1
- package/src/modes/components/bash-execution.ts +16 -4
- package/src/modes/components/status-line/presets.ts +17 -6
- package/src/modes/components/status-line/segments.ts +15 -0
- package/src/modes/components/status-line-segment-editor.ts +1 -0
- package/src/modes/components/status-line.ts +7 -1
- package/src/modes/components/tool-execution.ts +145 -75
- package/src/modes/controllers/command-controller.ts +24 -1
- package/src/modes/controllers/event-controller.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +28 -5
- package/src/modes/controllers/input-controller.ts +9 -3
- package/src/modes/controllers/selector-controller.ts +4 -1
- package/src/modes/interactive-mode.ts +19 -3
- package/src/modes/print-mode.ts +13 -4
- package/src/modes/prompt-action-autocomplete.ts +3 -5
- package/src/modes/rpc/rpc-mode.ts +8 -2
- package/src/modes/shared.ts +2 -2
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/chunk-edit.md +191 -163
- package/src/prompts/tools/hashline.md +11 -11
- package/src/prompts/tools/patch.md +10 -5
- package/src/prompts/tools/{await.md → poll.md} +1 -1
- package/src/prompts/tools/read-chunk.md +3 -3
- package/src/prompts/tools/task.md +2 -2
- package/src/prompts/tools/vim.md +98 -0
- package/src/sdk.ts +754 -724
- package/src/session/agent-session.ts +164 -34
- package/src/session/session-manager.ts +50 -4
- package/src/slash-commands/builtin-registry.ts +17 -0
- package/src/task/executor.ts +4 -4
- package/src/task/index.ts +3 -5
- package/src/task/types.ts +2 -2
- package/src/tools/bash.ts +26 -8
- package/src/tools/find.ts +5 -2
- package/src/tools/grep.ts +77 -8
- package/src/tools/index.ts +48 -19
- package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
- package/src/tools/python.ts +293 -278
- package/src/tools/submit-result.ts +5 -2
- package/src/tools/todo-write.ts +8 -2
- package/src/tools/vim.ts +966 -0
- package/src/utils/edit-mode.ts +2 -1
- package/src/utils/session-color.ts +55 -0
- package/src/utils/title-generator.ts +15 -6
- package/src/vim/buffer.ts +309 -0
- package/src/vim/commands.ts +382 -0
- package/src/vim/engine.ts +2426 -0
- package/src/vim/parser.ts +151 -0
- package/src/vim/render.ts +252 -0
- package/src/vim/types.ts +197 -0
package/src/edit/modes/chunk.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ChunkInfo,
|
|
9
9
|
ChunkReadStatus,
|
|
10
10
|
type ChunkReadTarget,
|
|
11
|
+
ChunkRegion,
|
|
11
12
|
ChunkState,
|
|
12
13
|
type EditOperation as NativeEditOperation,
|
|
13
14
|
} from "@oh-my-pi/pi-natives";
|
|
@@ -30,7 +31,9 @@ import type { EditToolDetails, LspBatchRequest } from "../renderer";
|
|
|
30
31
|
export type { ChunkReadTarget };
|
|
31
32
|
|
|
32
33
|
export type ChunkEditOperation =
|
|
33
|
-
| { op: "
|
|
34
|
+
| { op: "put"; sel?: string; content: string }
|
|
35
|
+
| { op: "replace"; sel?: string; content: string; find: string }
|
|
36
|
+
| { op: "delete"; sel?: string }
|
|
34
37
|
| { op: "before"; sel?: string; content: string }
|
|
35
38
|
| { op: "after"; sel?: string; content: string }
|
|
36
39
|
| { op: "prepend"; sel?: string; content: string }
|
|
@@ -158,6 +161,10 @@ async function resolveChunkSourceContext(session: ToolSession, path: string): Pr
|
|
|
158
161
|
};
|
|
159
162
|
}
|
|
160
163
|
|
|
164
|
+
function normalizeChunkRegionSyntax(text: string): string {
|
|
165
|
+
return text.replaceAll("@body", "~").replaceAll("@head", "^");
|
|
166
|
+
}
|
|
167
|
+
|
|
161
168
|
function buildChunkEditResult(result: {
|
|
162
169
|
diffBefore: string;
|
|
163
170
|
diffAfter: string;
|
|
@@ -174,7 +181,7 @@ function buildChunkEditResult(result: {
|
|
|
174
181
|
changed: result.changed,
|
|
175
182
|
parseValid: result.parseValid,
|
|
176
183
|
touchedPaths: result.touchedPaths,
|
|
177
|
-
warnings: result.warnings,
|
|
184
|
+
warnings: result.warnings.map(normalizeChunkRegionSyntax),
|
|
178
185
|
};
|
|
179
186
|
}
|
|
180
187
|
|
|
@@ -192,6 +199,17 @@ export function parseChunkSelector(selector: string | undefined): { selector?: s
|
|
|
192
199
|
return { selector };
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
/** Split a combined `file:selector` path into file path and chunk selector. */
|
|
203
|
+
export function parseChunkEditPath(editPath: string | undefined): { filePath: string; selector?: string } {
|
|
204
|
+
if (!editPath) return { filePath: "" };
|
|
205
|
+
const colonIndex = chunkReadPathSeparatorIndex(editPath);
|
|
206
|
+
if (colonIndex === -1) {
|
|
207
|
+
return { filePath: editPath };
|
|
208
|
+
}
|
|
209
|
+
const sel = editPath.slice(colonIndex + 1) || undefined;
|
|
210
|
+
return { filePath: editPath.slice(0, colonIndex), selector: sel };
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
export function parseChunkReadPath(readPath: string): ParsedChunkReadPath {
|
|
196
214
|
const colonIndex = chunkReadPathSeparatorIndex(readPath);
|
|
197
215
|
if (colonIndex === -1) {
|
|
@@ -252,34 +270,131 @@ export async function formatChunkedRead(params: {
|
|
|
252
270
|
return { text: result.text, resolvedPath: filePath, chunk: result.chunk };
|
|
253
271
|
}
|
|
254
272
|
|
|
255
|
-
export
|
|
273
|
+
export type ChunkedGrepMatch = {
|
|
274
|
+
displayPath: string;
|
|
275
|
+
fileLineCount: number;
|
|
276
|
+
chunkPath?: string;
|
|
277
|
+
chunkChecksum?: string;
|
|
278
|
+
lineNumber: number;
|
|
279
|
+
line: string;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
export async function describeChunkedGrepMatch(params: {
|
|
256
283
|
filePath: string;
|
|
257
284
|
lineNumber: number;
|
|
258
285
|
line: string;
|
|
259
286
|
cwd: string;
|
|
260
287
|
language?: string;
|
|
261
|
-
}): Promise<
|
|
288
|
+
}): Promise<ChunkedGrepMatch> {
|
|
262
289
|
const { filePath, lineNumber, line, cwd, language } = params;
|
|
263
290
|
const { state } = await loadChunkStateForFile(filePath, language);
|
|
264
|
-
|
|
291
|
+
const chunkPath = state.lineToContainingChunkPath(lineNumber) || undefined;
|
|
292
|
+
const chunkInfo = chunkPath ? state.chunk(chunkPath) : null;
|
|
293
|
+
return {
|
|
294
|
+
displayPath: displayPathForFile(filePath, cwd),
|
|
295
|
+
fileLineCount: state.lineCount,
|
|
296
|
+
chunkPath,
|
|
297
|
+
chunkChecksum: chunkInfo?.checksum,
|
|
298
|
+
lineNumber,
|
|
299
|
+
line,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const CHUNK_CHECKSUM_ALPHABET = "ZPMQVRWSNKTXJBYH";
|
|
304
|
+
type NativeChunkRegion = "head" | "body";
|
|
305
|
+
|
|
306
|
+
function isChunkChecksumToken(value: string): boolean {
|
|
307
|
+
return value.length === 4 && Array.from(value).every(ch => CHUNK_CHECKSUM_ALPHABET.includes(ch.toUpperCase()));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function parseChunkEditSelector(selector: string | undefined): {
|
|
311
|
+
selector?: string;
|
|
312
|
+
crc?: string;
|
|
313
|
+
region?: NativeChunkRegion;
|
|
314
|
+
} {
|
|
315
|
+
if (!selector) {
|
|
316
|
+
return {};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let trimmed = selector.trim();
|
|
320
|
+
if (trimmed.length === 0) {
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let region: NativeChunkRegion | undefined;
|
|
325
|
+
const suffix = trimmed.at(-1);
|
|
326
|
+
if (suffix === "~" || suffix === "^") {
|
|
327
|
+
region = suffix === "~" ? "body" : "head";
|
|
328
|
+
trimmed = trimmed.slice(0, -1).trimEnd();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let selectorPart = trimmed;
|
|
332
|
+
let crc: string | undefined;
|
|
333
|
+
const hashIndex = selectorPart.lastIndexOf("#");
|
|
334
|
+
if (hashIndex >= 0) {
|
|
335
|
+
const suffix = selectorPart.slice(hashIndex + 1).trim();
|
|
336
|
+
if (isChunkChecksumToken(suffix)) {
|
|
337
|
+
crc = suffix.toUpperCase();
|
|
338
|
+
selectorPart = selectorPart.slice(0, hashIndex).trimEnd();
|
|
339
|
+
}
|
|
340
|
+
} else if (isChunkChecksumToken(selectorPart)) {
|
|
341
|
+
crc = selectorPart.toUpperCase();
|
|
342
|
+
selectorPart = "";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { selector: selectorPart || undefined, crc, region };
|
|
265
346
|
}
|
|
266
347
|
|
|
267
|
-
|
|
348
|
+
type NativeChunkRegionEncoding = "named" | "symbolic";
|
|
349
|
+
|
|
350
|
+
function toNativeEditRegion(
|
|
351
|
+
region: NativeChunkRegion | undefined,
|
|
352
|
+
encoding: NativeChunkRegionEncoding,
|
|
353
|
+
): NativeEditOperation["region"] | undefined {
|
|
354
|
+
if (!region) {
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
if (encoding === "symbolic") {
|
|
358
|
+
return region === "body" ? ChunkRegion.Body : ChunkRegion.Head;
|
|
359
|
+
}
|
|
360
|
+
return region as unknown as NativeEditOperation["region"] | undefined;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function toNativeEditOperation(
|
|
364
|
+
operation: ChunkEditOperation,
|
|
365
|
+
defaultRegion: NativeChunkRegion | undefined,
|
|
366
|
+
encoding: NativeChunkRegionEncoding,
|
|
367
|
+
): NativeEditOperation {
|
|
368
|
+
const { selector, crc, region } = parseChunkEditSelector(operation.sel);
|
|
369
|
+
const nativeRegion = toNativeEditRegion(operation.sel === undefined ? (region ?? defaultRegion) : region, encoding);
|
|
268
370
|
switch (operation.op) {
|
|
371
|
+
case "put":
|
|
372
|
+
return {
|
|
373
|
+
op: ChunkEditOp.Put,
|
|
374
|
+
sel: selector,
|
|
375
|
+
crc,
|
|
376
|
+
region: nativeRegion,
|
|
377
|
+
content: operation.content,
|
|
378
|
+
};
|
|
269
379
|
case "replace":
|
|
270
380
|
return {
|
|
271
381
|
op: ChunkEditOp.Replace,
|
|
272
|
-
sel:
|
|
382
|
+
sel: selector,
|
|
383
|
+
crc,
|
|
384
|
+
region: nativeRegion,
|
|
385
|
+
find: operation.find,
|
|
273
386
|
content: operation.content,
|
|
274
387
|
};
|
|
275
388
|
case "before":
|
|
276
|
-
return { op: ChunkEditOp.Before, sel:
|
|
389
|
+
return { op: ChunkEditOp.Before, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
277
390
|
case "after":
|
|
278
|
-
return { op: ChunkEditOp.After, sel:
|
|
391
|
+
return { op: ChunkEditOp.After, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
279
392
|
case "prepend":
|
|
280
|
-
return { op: ChunkEditOp.Prepend, sel:
|
|
393
|
+
return { op: ChunkEditOp.Prepend, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
281
394
|
case "append":
|
|
282
|
-
return { op: ChunkEditOp.Append, sel:
|
|
395
|
+
return { op: ChunkEditOp.Append, sel: selector, crc, region: nativeRegion, content: operation.content };
|
|
396
|
+
case "delete":
|
|
397
|
+
return { op: ChunkEditOp.Delete, sel: selector, crc, region: nativeRegion };
|
|
283
398
|
default: {
|
|
284
399
|
const exhaustive: never = operation;
|
|
285
400
|
return exhaustive;
|
|
@@ -287,6 +402,28 @@ function toNativeEditOperation(operation: ChunkEditOperation): NativeEditOperati
|
|
|
287
402
|
}
|
|
288
403
|
}
|
|
289
404
|
|
|
405
|
+
function buildNativeChunkEditRequest(
|
|
406
|
+
params: { defaultSelector?: string; defaultCrc?: string; operations: ChunkEditOperation[] },
|
|
407
|
+
encoding: NativeChunkRegionEncoding,
|
|
408
|
+
): Pick<Parameters<ChunkState["applyEdits"]>[0], "operations" | "defaultSelector" | "defaultCrc"> {
|
|
409
|
+
const parsedDefaultSelector = parseChunkEditSelector(params.defaultSelector);
|
|
410
|
+
const operations = params.operations.map(operation =>
|
|
411
|
+
toNativeEditOperation(operation, parsedDefaultSelector.region, encoding),
|
|
412
|
+
);
|
|
413
|
+
return {
|
|
414
|
+
operations,
|
|
415
|
+
defaultSelector: parsedDefaultSelector.selector,
|
|
416
|
+
defaultCrc: params.defaultCrc ?? parsedDefaultSelector.crc,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function isChunkRegionEncodingError(error: unknown): error is Error {
|
|
421
|
+
return (
|
|
422
|
+
error instanceof Error &&
|
|
423
|
+
/value `"(body|head|~|\^)"` does not match any variant of enum `ChunkRegion`/.test(error.message)
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
290
427
|
export function applyChunkEdits(params: {
|
|
291
428
|
source: string;
|
|
292
429
|
language?: string;
|
|
@@ -298,19 +435,40 @@ export function applyChunkEdits(params: {
|
|
|
298
435
|
anchorStyle?: ChunkAnchorStyle;
|
|
299
436
|
}): ChunkEditResult {
|
|
300
437
|
const normalizedSource = normalizeChunkSource(params.source);
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
438
|
+
const applyNativeEdits = (encoding: NativeChunkRegionEncoding): ChunkEditResult => {
|
|
439
|
+
const request = buildNativeChunkEditRequest(params, encoding);
|
|
440
|
+
const state = ChunkState.parse(normalizedSource, normalizeLanguage(params.language));
|
|
441
|
+
return buildChunkEditResult(
|
|
442
|
+
state.applyEdits({
|
|
443
|
+
operations: request.operations,
|
|
444
|
+
normalizeIndent: resolveChunkAutoIndent(),
|
|
445
|
+
defaultSelector: request.defaultSelector,
|
|
446
|
+
defaultCrc: request.defaultCrc,
|
|
447
|
+
anchorStyle: params.anchorStyle,
|
|
448
|
+
cwd: params.cwd,
|
|
449
|
+
filePath: params.filePath,
|
|
450
|
+
}),
|
|
451
|
+
);
|
|
452
|
+
};
|
|
312
453
|
|
|
313
|
-
|
|
454
|
+
try {
|
|
455
|
+
return applyNativeEdits("named");
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (isChunkRegionEncodingError(error)) {
|
|
458
|
+
try {
|
|
459
|
+
return applyNativeEdits("symbolic");
|
|
460
|
+
} catch (fallbackError) {
|
|
461
|
+
if (fallbackError instanceof Error) {
|
|
462
|
+
throw new Error(normalizeChunkRegionSyntax(fallbackError.message));
|
|
463
|
+
}
|
|
464
|
+
throw fallbackError;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (error instanceof Error) {
|
|
468
|
+
throw new Error(normalizeChunkRegionSyntax(error.message));
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
314
472
|
}
|
|
315
473
|
|
|
316
474
|
export async function getChunkInfoForFile(
|
|
@@ -326,22 +484,39 @@ export function missingChunkReadTarget(selector: string): ChunkReadTarget {
|
|
|
326
484
|
return { status: ChunkReadStatus.NotFound, selector };
|
|
327
485
|
}
|
|
328
486
|
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
487
|
+
export const chunkToolEditSchema = Type.Object(
|
|
488
|
+
{
|
|
489
|
+
path: Type.String({
|
|
490
|
+
description: "File path with chunk selector. Examples: 'src/app.ts:fn_foo#ABCD~', 'src/app.ts:class_Bar'.",
|
|
491
|
+
}),
|
|
492
|
+
write: Type.Optional(
|
|
493
|
+
Type.Union([Type.String(), Type.Null()], {
|
|
494
|
+
description: "Write complete new content to the targeted region. Use null to delete the chunk.",
|
|
495
|
+
}),
|
|
496
|
+
),
|
|
497
|
+
replace: Type.Optional(
|
|
498
|
+
Type.Object(
|
|
499
|
+
{
|
|
500
|
+
old: Type.String({ description: "Literal substring to find. Must match exactly once." }),
|
|
501
|
+
new: Type.String({ description: "Replacement text." }),
|
|
502
|
+
},
|
|
503
|
+
{ description: "Find and replace a substring within the chunk." },
|
|
504
|
+
),
|
|
505
|
+
),
|
|
506
|
+
insert: Type.Optional(
|
|
507
|
+
Type.Object(
|
|
508
|
+
{
|
|
509
|
+
loc: StringEnum(["append", "prepend"] as const),
|
|
510
|
+
body: Type.String({ description: "Content to insert." }),
|
|
511
|
+
},
|
|
512
|
+
{ description: "Insert content relative to the chunk." },
|
|
513
|
+
),
|
|
514
|
+
),
|
|
515
|
+
},
|
|
516
|
+
{ additionalProperties: false },
|
|
517
|
+
);
|
|
342
518
|
export const chunkEditParamsSchema = Type.Object(
|
|
343
519
|
{
|
|
344
|
-
path: Type.String({ description: "File path" }),
|
|
345
520
|
edits: Type.Array(chunkToolEditSchema, {
|
|
346
521
|
description: "Chunk edits",
|
|
347
522
|
minItems: 1,
|
|
@@ -353,9 +528,10 @@ export const chunkEditParamsSchema = Type.Object(
|
|
|
353
528
|
export type ChunkToolEdit = Static<typeof chunkToolEditSchema>;
|
|
354
529
|
export type ChunkParams = Static<typeof chunkEditParamsSchema>;
|
|
355
530
|
|
|
356
|
-
interface
|
|
531
|
+
export interface ExecuteChunkSingleOptions {
|
|
357
532
|
session: ToolSession;
|
|
358
|
-
|
|
533
|
+
path: string;
|
|
534
|
+
edits: ChunkToolEdit[];
|
|
359
535
|
signal?: AbortSignal;
|
|
360
536
|
batchRequest?: LspBatchRequest;
|
|
361
537
|
writethrough: WritethroughCallback;
|
|
@@ -363,20 +539,122 @@ interface ExecuteChunkModeOptions {
|
|
|
363
539
|
}
|
|
364
540
|
|
|
365
541
|
export function isChunkParams(params: unknown): params is ChunkParams {
|
|
366
|
-
|
|
367
|
-
typeof params
|
|
368
|
-
params
|
|
369
|
-
"edits" in params
|
|
370
|
-
Array.isArray(params.edits)
|
|
371
|
-
params.edits.length
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
542
|
+
if (
|
|
543
|
+
typeof params !== "object" ||
|
|
544
|
+
params === null ||
|
|
545
|
+
!("edits" in params) ||
|
|
546
|
+
!Array.isArray(params.edits) ||
|
|
547
|
+
params.edits.length === 0
|
|
548
|
+
) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
const first = params.edits[0];
|
|
552
|
+
if (typeof first !== "object" || first === null || !("path" in first)) return false;
|
|
553
|
+
return "write" in first || "replace" in first || "insert" in first;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Auto-correct indentation for content targeting a body region (`~`) when autoIndent is on.
|
|
557
|
+
* Handles two patterns:
|
|
558
|
+
* 1. Tab-based over-indentation: models include the function's base \t indent.
|
|
559
|
+
* 2. Space-based indentation: models use literal spaces instead of \t.
|
|
560
|
+
* Returns the corrected content and any warnings. */
|
|
561
|
+
function autoCorrectBodyIndent(content: string, index: number): { content: string; warnings: string[] } {
|
|
562
|
+
const warnings: string[] = [];
|
|
563
|
+
if (!content || !resolveChunkAutoIndent()) return { content, warnings };
|
|
564
|
+
const lines = content.split("\n");
|
|
565
|
+
const nonEmpty = lines.filter(l => l.length > 0);
|
|
566
|
+
if (nonEmpty.length <= 1) return { content, warnings };
|
|
567
|
+
|
|
568
|
+
// 1. Tab-based over-indentation: strip common leading tabs.
|
|
569
|
+
const minTabs = Math.min(...nonEmpty.map(l => l.match(/^\t*/)?.[0].length ?? 0));
|
|
570
|
+
if (minTabs >= 1) {
|
|
571
|
+
const fixed = lines.map(l => (l.length === 0 ? l : l.slice(minTabs))).join("\n");
|
|
572
|
+
warnings.push(
|
|
573
|
+
`Edit ${index + 1}: auto-corrected body indentation \u2014 stripped ${minTabs} leading tab(s). When writing to \`~\`, write at column 0; the tool adds the function's base indent.`,
|
|
574
|
+
);
|
|
575
|
+
return { content: fixed, warnings };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// 2. Space-based indentation: strip common leading spaces and convert to tabs.
|
|
579
|
+
const spaceIndents = nonEmpty.map(l => l.match(/^ */)?.[0].length ?? 0);
|
|
580
|
+
const minSpaces = Math.min(...spaceIndents);
|
|
581
|
+
if (minSpaces >= 2) {
|
|
582
|
+
const indentDiffs = spaceIndents.map(s => s - minSpaces).filter(d => d > 0);
|
|
583
|
+
const indentUnit = indentDiffs.length > 0 ? Math.min(...indentDiffs) : 4;
|
|
584
|
+
const unit = indentUnit >= 2 && indentUnit <= 8 ? indentUnit : 4;
|
|
585
|
+
const fixed = lines
|
|
586
|
+
.map(line => {
|
|
587
|
+
if (line.length === 0) return line;
|
|
588
|
+
const stripped = line.slice(minSpaces);
|
|
589
|
+
const leadingSpaces = stripped.match(/^ */)?.[0].length ?? 0;
|
|
590
|
+
const tabs = Math.floor(leadingSpaces / unit);
|
|
591
|
+
const rem = leadingSpaces % unit;
|
|
592
|
+
return "\t".repeat(tabs) + " ".repeat(rem) + stripped.slice(leadingSpaces);
|
|
593
|
+
})
|
|
594
|
+
.join("\n");
|
|
595
|
+
warnings.push(
|
|
596
|
+
`Edit ${index + 1}: auto-converted space indentation to tabs \u2014 stripped ${minSpaces} common leading spaces and converted ${unit}-space indent to tabs. When auto-indent is on, use \\t for indentation.`,
|
|
597
|
+
);
|
|
598
|
+
return { content: fixed, warnings };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { content, warnings };
|
|
376
602
|
}
|
|
377
603
|
|
|
378
|
-
function normalizeChunkEditOperations(edits: ChunkToolEdit[]):
|
|
379
|
-
|
|
604
|
+
function normalizeChunkEditOperations(edits: ChunkToolEdit[]): {
|
|
605
|
+
operations: ChunkEditOperation[];
|
|
606
|
+
warnings: string[];
|
|
607
|
+
} {
|
|
608
|
+
const warnings: string[] = [];
|
|
609
|
+
const operations = edits.map((edit, index): ChunkEditOperation => {
|
|
610
|
+
const { selector } = parseChunkEditPath(edit.path);
|
|
611
|
+
// When multiple ops are present (model confusion), prefer write (total replacement) as the
|
|
612
|
+
// safest default, then replace (surgical), then insert (additive), then delete.
|
|
613
|
+
const hasInsert = edit.insert != null && typeof edit.insert.body === "string" && edit.insert.body.length > 0;
|
|
614
|
+
const hasReplace =
|
|
615
|
+
edit.replace != null &&
|
|
616
|
+
((typeof edit.replace.old === "string" && edit.replace.old.length > 0) ||
|
|
617
|
+
(typeof edit.replace.new === "string" && edit.replace.new.length > 0));
|
|
618
|
+
const hasWrite = typeof edit.write === "string" && edit.write.length > 0;
|
|
619
|
+
const opCount = [hasInsert, hasReplace, hasWrite].filter(Boolean).length;
|
|
620
|
+
if (opCount > 1) {
|
|
621
|
+
const chosen = hasWrite ? "write" : hasReplace ? "replace" : "insert";
|
|
622
|
+
const present = [hasWrite && "write", hasReplace && "replace", hasInsert && "insert"]
|
|
623
|
+
.filter(Boolean)
|
|
624
|
+
.join(", ");
|
|
625
|
+
warnings.push(
|
|
626
|
+
`Edit ${index + 1}: multiple operation fields set (${present}). Each edit entry must have exactly ONE of write/replace/insert — not multiple. Used "${chosen}", ignored the rest.`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
if (hasWrite) {
|
|
630
|
+
let writeContent = edit.write!;
|
|
631
|
+
if (selector?.endsWith("~")) {
|
|
632
|
+
const corrected = autoCorrectBodyIndent(writeContent, index);
|
|
633
|
+
writeContent = corrected.content;
|
|
634
|
+
warnings.push(...corrected.warnings);
|
|
635
|
+
}
|
|
636
|
+
return { op: "put", sel: selector, content: writeContent };
|
|
637
|
+
}
|
|
638
|
+
if (typeof edit.write === "string" && !hasInsert && !hasReplace) {
|
|
639
|
+
return { op: "put", sel: selector, content: edit.write };
|
|
640
|
+
}
|
|
641
|
+
if (hasReplace) {
|
|
642
|
+
return { op: "replace", sel: selector, content: edit.replace!.new, find: edit.replace!.old };
|
|
643
|
+
}
|
|
644
|
+
if (hasInsert) {
|
|
645
|
+
const op = edit.insert!.loc === "prepend" ? "before" : "after";
|
|
646
|
+
let insertContent = edit.insert!.body;
|
|
647
|
+
if (selector?.endsWith("~")) {
|
|
648
|
+
const corrected = autoCorrectBodyIndent(insertContent, index);
|
|
649
|
+
insertContent = corrected.content;
|
|
650
|
+
warnings.push(...corrected.warnings);
|
|
651
|
+
}
|
|
652
|
+
return { op, sel: selector, content: insertContent };
|
|
653
|
+
}
|
|
654
|
+
// write: null or no op specified → delete
|
|
655
|
+
return { op: "delete", sel: selector };
|
|
656
|
+
});
|
|
657
|
+
return { operations, warnings };
|
|
380
658
|
}
|
|
381
659
|
|
|
382
660
|
async function writeChunkResult(params: {
|
|
@@ -428,11 +706,10 @@ async function writeChunkResult(params: {
|
|
|
428
706
|
};
|
|
429
707
|
}
|
|
430
708
|
|
|
431
|
-
export async function
|
|
432
|
-
options:
|
|
709
|
+
export async function executeChunkSingle(
|
|
710
|
+
options: ExecuteChunkSingleOptions,
|
|
433
711
|
): Promise<AgentToolResult<EditToolDetails, typeof chunkEditParamsSchema>> {
|
|
434
|
-
const { session,
|
|
435
|
-
const { path, edits } = params;
|
|
712
|
+
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
436
713
|
const { resolvedPath, sourceFile, sourceExists, rawContent, chunkLanguage } = await resolveChunkSourceContext(
|
|
437
714
|
session,
|
|
438
715
|
path,
|
|
@@ -441,7 +718,7 @@ export async function executeChunkMode(
|
|
|
441
718
|
if (parentDir && parentDir !== ".") {
|
|
442
719
|
await fs.mkdir(parentDir, { recursive: true });
|
|
443
720
|
}
|
|
444
|
-
const normalizedOperations = normalizeChunkEditOperations(edits);
|
|
721
|
+
const { operations: normalizedOperations, warnings: normWarnings } = normalizeChunkEditOperations(edits);
|
|
445
722
|
|
|
446
723
|
if (!sourceExists && normalizedOperations.some(op => op.sel)) {
|
|
447
724
|
throw new Error(
|
|
@@ -457,10 +734,12 @@ export async function executeChunkMode(
|
|
|
457
734
|
operations: normalizedOperations,
|
|
458
735
|
anchorStyle: resolveAnchorStyle(session.settings),
|
|
459
736
|
});
|
|
737
|
+
chunkResult.warnings.push(...normWarnings);
|
|
460
738
|
|
|
461
739
|
if (!chunkResult.changed) {
|
|
740
|
+
const warningsBlock = chunkResult.warnings.length > 0 ? `\n\nWarnings:\n${chunkResult.warnings.join("\n")}` : "";
|
|
462
741
|
return {
|
|
463
|
-
content: [{ type: "text", text:
|
|
742
|
+
content: [{ type: "text", text: `[No changes needed — content already matches.]${warningsBlock}` }],
|
|
464
743
|
details: {
|
|
465
744
|
diff: "",
|
|
466
745
|
op: sourceExists ? "update" : "create",
|
|
@@ -128,18 +128,18 @@ const locSchema = Type.Union(
|
|
|
128
128
|
|
|
129
129
|
export const hashlineEditSchema = Type.Object(
|
|
130
130
|
{
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
path: Type.String({ description: "File path" }),
|
|
132
|
+
loc: Type.Optional(locSchema),
|
|
133
|
+
content: Type.Optional(linesSchema),
|
|
134
|
+
delete: Type.Optional(Type.Boolean({ description: "Delete the file" })),
|
|
135
|
+
move: Type.Optional(Type.String({ description: "Move/rename the file to this path" })),
|
|
133
136
|
},
|
|
134
137
|
{ additionalProperties: false },
|
|
135
138
|
);
|
|
136
139
|
|
|
137
140
|
export const hashlineEditParamsSchema = Type.Object(
|
|
138
141
|
{
|
|
139
|
-
|
|
140
|
-
edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }),
|
|
141
|
-
delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })),
|
|
142
|
-
move: Type.Optional(Type.String({ description: "If set, move $path to $move" })),
|
|
142
|
+
edits: Type.Array(hashlineEditSchema, { description: "edits" }),
|
|
143
143
|
},
|
|
144
144
|
{ additionalProperties: false },
|
|
145
145
|
);
|
|
@@ -147,9 +147,10 @@ export const hashlineEditParamsSchema = Type.Object(
|
|
|
147
147
|
export type HashlineToolEdit = Static<typeof hashlineEditSchema>;
|
|
148
148
|
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
|
|
149
149
|
|
|
150
|
-
interface
|
|
150
|
+
export interface ExecuteHashlineSingleOptions {
|
|
151
151
|
session: ToolSession;
|
|
152
|
-
|
|
152
|
+
path: string;
|
|
153
|
+
edits: HashlineToolEdit[];
|
|
153
154
|
signal?: AbortSignal;
|
|
154
155
|
batchRequest?: LspBatchRequest;
|
|
155
156
|
writethrough: WritethroughCallback;
|
|
@@ -166,20 +167,38 @@ export function hashlineParseText(edit: string[] | string | null | undefined): s
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
export function isHashlineParams(params: unknown): params is HashlineParams {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
(typeof params.edits[0] === "object" && params.edits[0] !== null && "loc" in params.edits[0]))
|
|
176
|
-
);
|
|
170
|
+
if (typeof params !== "object" || params === null || !("edits" in params) || !Array.isArray(params.edits))
|
|
171
|
+
return false;
|
|
172
|
+
if (params.edits.length === 0) return true;
|
|
173
|
+
const first = params.edits[0];
|
|
174
|
+
if (typeof first !== "object" || first === null) return false;
|
|
175
|
+
return "loc" in first || "delete" in first || "move" in first;
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] {
|
|
180
179
|
return edits.map(resolveEditAnchor);
|
|
181
180
|
}
|
|
182
181
|
|
|
182
|
+
type HashlineEditInput = HashlineToolEdit | HashlineEdit;
|
|
183
|
+
|
|
184
|
+
function resolveHashlineEditsForDiff(edits: HashlineEditInput[]): HashlineEdit[] {
|
|
185
|
+
return edits.map((edit, editIndex) => {
|
|
186
|
+
if (!edit || typeof edit !== "object") {
|
|
187
|
+
throw new Error(`Invalid hashline edit at index ${editIndex}: expected object.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if ("op" in edit) {
|
|
191
|
+
return edit;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if ("loc" in edit) {
|
|
195
|
+
return resolveEditAnchor(edit);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw new Error(`Invalid hashline edit at index ${editIndex}: expected op/loc payload.`);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
183
202
|
function tryParseTag(raw: string): Anchor | undefined {
|
|
184
203
|
try {
|
|
185
204
|
return parseTag(raw);
|
|
@@ -1126,7 +1145,7 @@ export function buildCompactHashlineDiffPreview(
|
|
|
1126
1145
|
}
|
|
1127
1146
|
|
|
1128
1147
|
export async function computeHashlineDiff(
|
|
1129
|
-
input: { path: string; edits:
|
|
1148
|
+
input: { path: string; edits: HashlineEditInput[]; move?: string },
|
|
1130
1149
|
cwd: string,
|
|
1131
1150
|
): Promise<
|
|
1132
1151
|
| {
|
|
@@ -1143,6 +1162,7 @@ export async function computeHashlineDiff(
|
|
|
1143
1162
|
const isMoveOnly = Boolean(movePath) && movePath !== absolutePath && edits.length === 0;
|
|
1144
1163
|
|
|
1145
1164
|
try {
|
|
1165
|
+
const resolvedEdits = resolveHashlineEditsForDiff(edits);
|
|
1146
1166
|
const file = Bun.file(absolutePath);
|
|
1147
1167
|
|
|
1148
1168
|
if (movePath === absolutePath) {
|
|
@@ -1156,7 +1176,7 @@ export async function computeHashlineDiff(
|
|
|
1156
1176
|
|
|
1157
1177
|
const { text: content } = stripBom(rawContent);
|
|
1158
1178
|
const normalizedContent = normalizeToLF(content);
|
|
1159
|
-
const result = applyHashlineEdits(normalizedContent,
|
|
1179
|
+
const result = applyHashlineEdits(normalizedContent, resolvedEdits);
|
|
1160
1180
|
if (normalizedContent === result.lines && !move) {
|
|
1161
1181
|
return { error: `No changes would be made to ${path}. The edits produce identical content.` };
|
|
1162
1182
|
}
|
|
@@ -1179,15 +1199,20 @@ async function readHashlineFileText(file: BunFile, path: string): Promise<string
|
|
|
1179
1199
|
}
|
|
1180
1200
|
}
|
|
1181
1201
|
|
|
1182
|
-
export async function
|
|
1183
|
-
options:
|
|
1202
|
+
export async function executeHashlineSingle(
|
|
1203
|
+
options: ExecuteHashlineSingleOptions,
|
|
1184
1204
|
): Promise<AgentToolResult<EditToolDetails, typeof hashlineEditParamsSchema>> {
|
|
1185
|
-
const { session,
|
|
1186
|
-
|
|
1205
|
+
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
|
|
1206
|
+
|
|
1207
|
+
// Extract file-level ops from edits
|
|
1208
|
+
const deleteFile = edits.some(e => e.delete);
|
|
1209
|
+
const move = edits.find(e => e.move)?.move;
|
|
1210
|
+
// Filter to content edits only (those with loc)
|
|
1211
|
+
const contentEdits = edits.filter(e => e.loc != null);
|
|
1187
1212
|
|
|
1188
1213
|
enforcePlanModeWrite(session, path, { op: deleteFile ? "delete" : "update", move });
|
|
1189
1214
|
|
|
1190
|
-
if (path.endsWith(".ipynb") &&
|
|
1215
|
+
if (path.endsWith(".ipynb") && contentEdits.length > 0) {
|
|
1191
1216
|
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
|
|
1192
1217
|
}
|
|
1193
1218
|
|
|
@@ -1199,7 +1224,7 @@ export async function executeHashlineMode(
|
|
|
1199
1224
|
|
|
1200
1225
|
const sourceFile = Bun.file(absolutePath);
|
|
1201
1226
|
const sourceExists = await sourceFile.exists();
|
|
1202
|
-
const isMoveOnly = Boolean(resolvedMove) &&
|
|
1227
|
+
const isMoveOnly = Boolean(resolvedMove) && contentEdits.length === 0;
|
|
1203
1228
|
|
|
1204
1229
|
if (deleteFile) {
|
|
1205
1230
|
if (sourceExists) {
|
|
@@ -1239,7 +1264,7 @@ export async function executeHashlineMode(
|
|
|
1239
1264
|
|
|
1240
1265
|
if (!sourceExists) {
|
|
1241
1266
|
const lines: string[] = [];
|
|
1242
|
-
for (const edit of
|
|
1267
|
+
for (const edit of contentEdits) {
|
|
1243
1268
|
if (edit.loc === "append") {
|
|
1244
1269
|
lines.push(...hashlineParseText(edit.content));
|
|
1245
1270
|
} else if (edit.loc === "prepend") {
|
|
@@ -1261,7 +1286,7 @@ export async function executeHashlineMode(
|
|
|
1261
1286
|
};
|
|
1262
1287
|
}
|
|
1263
1288
|
|
|
1264
|
-
const anchorEdits = resolveEditAnchors(
|
|
1289
|
+
const anchorEdits = resolveEditAnchors(contentEdits);
|
|
1265
1290
|
const rawContent = await sourceFile.text();
|
|
1266
1291
|
assertEditableFileContent(rawContent, path);
|
|
1267
1292
|
|