@oh-my-pi/pi-coding-agent 14.9.7 → 14.9.8

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/src/tools/read.ts CHANGED
@@ -33,6 +33,17 @@ import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "
33
33
  import { convertFileWithMarkit } from "../utils/markit";
34
34
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
35
35
  import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader";
36
+ import {
37
+ type ConflictEntry,
38
+ type ConflictScope,
39
+ formatConflictSummary,
40
+ formatConflictWarning,
41
+ getConflictHistory,
42
+ parseConflictUri,
43
+ renderConflictRegion,
44
+ scanConflictLines,
45
+ scanFileForConflicts,
46
+ } from "./conflict-detect";
36
47
  import {
37
48
  executeReadUrl,
38
49
  isReadableUrlPath,
@@ -455,6 +466,8 @@ export interface ReadToolDetails {
455
466
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
456
467
  displayContent?: { text: string; startLine: number };
457
468
  summary?: { lines: number; elidedSpans: number };
469
+ /** Number of unresolved git conflicts surfaced by this read (TUI uses for inline `⚠ N` badge). */
470
+ conflictCount?: number;
458
471
  }
459
472
 
460
473
  type ReadParams = ReadToolInput;
@@ -463,6 +476,7 @@ type ReadParams = ReadToolInput;
463
476
  type ParsedSelector =
464
477
  | { kind: "none" }
465
478
  | { kind: "raw" }
479
+ | { kind: "conflicts" }
466
480
  | { kind: "lines"; startLine: number; endLine: number | undefined; raw?: boolean };
467
481
 
468
482
  const LINE_RANGE_RE = /^L?(\d+)(?:([-+])L?(\d+))?$/i;
@@ -521,6 +535,7 @@ function parseSel(sel: string | undefined): ParsedSelector {
521
535
  }
522
536
 
523
537
  if (sel.toLowerCase() === "raw") return { kind: "raw" };
538
+ if (sel.toLowerCase() === "conflicts") return { kind: "conflicts" };
524
539
  const range = parseLineRangeChunk(sel);
525
540
  if (range) {
526
541
  return { kind: "lines", startLine: range.startLine, endLine: range.endLine };
@@ -1153,6 +1168,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1153
1168
  if (readPath.startsWith("file://")) {
1154
1169
  readPath = expandPath(readPath);
1155
1170
  }
1171
+
1172
+ const conflictUri = parseConflictUri(readPath);
1173
+ if (conflictUri) {
1174
+ if (conflictUri.id === "*") {
1175
+ throw new ToolError(
1176
+ "`read conflict://*` is not supported — wildcards are write-only. Use `read <path>:conflicts` for the full list of conflicts in a file, or `read conflict://<N>` to inspect a single block.",
1177
+ );
1178
+ }
1179
+ return this.#readConflictRegion(conflictUri.id, conflictUri.scope);
1180
+ }
1156
1181
  const displayMode = resolveFileDisplayMode(this.session);
1157
1182
 
1158
1183
  const parsedUrlTarget = parseReadUrlTarget(readPath);
@@ -1257,6 +1282,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1257
1282
  return dirResult;
1258
1283
  }
1259
1284
 
1285
+ if (parsed.kind === "conflicts") {
1286
+ return this.#readFileConflicts(absolutePath, suffixResolution, signal);
1287
+ }
1288
+
1260
1289
  const imageMetadata = await readImageMetadata(absolutePath);
1261
1290
  const mimeType = imageMetadata?.mimeType;
1262
1291
  const ext = path.extname(absolutePath).toLowerCase();
@@ -1517,6 +1546,38 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1517
1546
  details.displayContent = capturedDisplayContent;
1518
1547
  }
1519
1548
 
1549
+ if (!firstLineExceedsLimit && collectedLines.length > 0) {
1550
+ const blocks = scanConflictLines(collectedLines, startLineDisplay);
1551
+ if (blocks.length > 0) {
1552
+ const history = getConflictHistory(this.session);
1553
+ const displayPathForWarning = formatPathRelativeToCwd(absolutePath, this.session.cwd);
1554
+ const entries = blocks.map(block =>
1555
+ history.register({
1556
+ absolutePath,
1557
+ displayPath: displayPathForWarning,
1558
+ ...block,
1559
+ }),
1560
+ );
1561
+ // Cheap full-file scan only when the window already showed
1562
+ // at least one conflict — otherwise pay nothing on clean files.
1563
+ let totalInFile = entries.length;
1564
+ let scanTruncated = false;
1565
+ try {
1566
+ const fileScan = await scanFileForConflicts(absolutePath);
1567
+ totalInFile = Math.max(entries.length, fileScan.blocks.length);
1568
+ scanTruncated = fileScan.scanTruncated;
1569
+ } catch {
1570
+ // Best-effort enrichment; fall back to window-only count.
1571
+ }
1572
+ outputText += formatConflictWarning(entries, {
1573
+ totalInFile,
1574
+ displayPath: displayPathForWarning,
1575
+ scanTruncated,
1576
+ });
1577
+ details.conflictCount = entries.length;
1578
+ }
1579
+ }
1580
+
1520
1581
  content = [{ type: "text", text: outputText }];
1521
1582
  }
1522
1583
  }
@@ -1542,6 +1603,71 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1542
1603
  return resultBuilder.done();
1543
1604
  }
1544
1605
 
1606
+ /**
1607
+ * Render a `conflict://<N>` (or `conflict://<N>/<scope>`) region as
1608
+ * regular file content. The lines are emitted with their original
1609
+ * file line numbers so hashline anchors line up with the source
1610
+ * file, and no truncation footer is appended.
1611
+ */
1612
+ async #readConflictRegion(id: number, scope: ConflictScope | undefined): Promise<AgentToolResult<ReadToolDetails>> {
1613
+ const entry: ConflictEntry | undefined = getConflictHistory(this.session).get(id);
1614
+ if (!entry) {
1615
+ throw new ToolError(
1616
+ `Conflict #${id} not found. Conflict ids are registered when \`read\` surfaces a marker block; re-read the file to get a current id.`,
1617
+ );
1618
+ }
1619
+
1620
+ const region = renderConflictRegion(entry, scope);
1621
+ const displayMode = resolveFileDisplayMode(this.session);
1622
+ const shouldAddHashLines = displayMode.hashLines;
1623
+ const shouldAddLineNumbers = shouldAddHashLines ? false : displayMode.lineNumbers;
1624
+
1625
+ const rawText = region.lines.join("\n");
1626
+ const formattedText = formatTextWithMode(rawText, region.startLine, shouldAddHashLines, shouldAddLineNumbers);
1627
+
1628
+ const details: ReadToolDetails = {
1629
+ resolvedPath: entry.absolutePath,
1630
+ displayContent: { text: rawText, startLine: region.startLine },
1631
+ };
1632
+ return toolResult<ReadToolDetails>(details).text(formattedText).sourcePath(entry.absolutePath).done();
1633
+ }
1634
+
1635
+ /**
1636
+ * Implement `read <path>:conflicts`: scan the whole file once, register
1637
+ * every block in the session's conflict history, and return a compact
1638
+ * `#N L_a-L_b` index instead of file content. Designed for heavily
1639
+ * conflicted files where dumping every body would be wasteful.
1640
+ */
1641
+ async #readFileConflicts(
1642
+ absolutePath: string,
1643
+ suffixResolution: { from: string; to: string } | undefined,
1644
+ signal: AbortSignal | undefined,
1645
+ ): Promise<AgentToolResult<ReadToolDetails>> {
1646
+ throwIfAborted(signal);
1647
+ const scan = await scanFileForConflicts(absolutePath);
1648
+ const displayPath = formatPathRelativeToCwd(absolutePath, this.session.cwd);
1649
+ const history = getConflictHistory(this.session);
1650
+ const entries = scan.blocks.map(block =>
1651
+ history.register({
1652
+ absolutePath,
1653
+ displayPath,
1654
+ ...block,
1655
+ }),
1656
+ );
1657
+
1658
+ const summary =
1659
+ entries.length === 0
1660
+ ? `No unresolved git merge conflicts in ${displayPath}.`
1661
+ : formatConflictSummary(entries, { displayPath, scanTruncated: scan.scanTruncated });
1662
+
1663
+ const details: ReadToolDetails = {
1664
+ resolvedPath: absolutePath,
1665
+ suffixResolution,
1666
+ conflictCount: entries.length,
1667
+ };
1668
+ return toolResult<ReadToolDetails>(details).text(summary).sourcePath(absolutePath).done();
1669
+ }
1670
+
1545
1671
  /**
1546
1672
  * Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
1547
1673
  * Supports pagination via offset/limit but rejects them when query extraction is used.
@@ -1763,6 +1889,10 @@ export const readToolRenderer = {
1763
1889
  if (details?.summary) {
1764
1890
  title += ` (summary: ${details.summary.elidedSpans} elided span${details.summary.elidedSpans === 1 ? "" : "s"})`;
1765
1891
  }
1892
+ if (details?.conflictCount && details.conflictCount > 0) {
1893
+ const n = details.conflictCount;
1894
+ title += ` ${uiTheme.fg("warning", `(⚠ ${n} conflict${n === 1 ? "" : "s"})`)}`;
1895
+ }
1766
1896
  let cachedWidth: number | undefined;
1767
1897
  let cachedLines: string[] | undefined;
1768
1898
  return {
@@ -16,6 +16,13 @@ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, truncateToWidth }
16
16
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
17
17
  import { parseArchivePathCandidates } from "./archive-reader";
18
18
  import { assertEditableFile } from "./auto-generated-guard";
19
+ import {
20
+ type ConflictEntry,
21
+ expandContentTokens,
22
+ getConflictHistory,
23
+ parseConflictUri,
24
+ spliceConflict,
25
+ } from "./conflict-detect";
19
26
  import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
20
27
  import { type OutputMeta, outputMeta } from "./output-meta";
21
28
  import { formatPathRelativeToCwd } from "./path-utils";
@@ -423,6 +430,185 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
423
430
  }
424
431
  }
425
432
 
433
+ /**
434
+ * Resolve a single `conflict://<N>` write by splicing the recorded
435
+ * marker region in the registered file with `replacementContent`,
436
+ * then routing the new file content through the normal writethrough
437
+ * pipeline so LSP format/diagnostics still run.
438
+ *
439
+ * Entry ids are session-stable: they keep working even after later
440
+ * writes resolve other blocks in the same file. The recorded range
441
+ * is re-validated on disk before splicing so an out-of-band edit
442
+ * surfaces as a clear error instead of corrupting the file.
443
+ */
444
+ async #resolveConflict(
445
+ entry: ConflictEntry,
446
+ replacementContent: string,
447
+ stripped: boolean,
448
+ signal: AbortSignal | undefined,
449
+ context: AgentToolContext | undefined,
450
+ ): Promise<AgentToolResult<WriteToolDetails>> {
451
+ const absolutePath = entry.absolutePath;
452
+ if (!(await fs.exists(absolutePath))) {
453
+ throw new ToolError(`Conflict #${entry.id} target '${entry.displayPath}' no longer exists.`);
454
+ }
455
+
456
+ const expanded = expandContentTokens(replacementContent, entry);
457
+ const originalText = await Bun.file(absolutePath).text();
458
+ const newContent = spliceConflict(originalText, entry, expanded);
459
+
460
+ const batchRequest = getLspBatchRequest(context?.toolCall);
461
+ const diagnostics = await this.#writethrough(absolutePath, newContent, signal, undefined, batchRequest);
462
+ invalidateFsScanAfterWrite(absolutePath);
463
+ this.session.fileReadCache?.invalidate(absolutePath);
464
+ this.session.conflictHistory?.invalidate(entry.id);
465
+
466
+ const range =
467
+ entry.startLine === entry.endLine
468
+ ? `line ${entry.startLine}`
469
+ : `lines ${entry.startLine}\u2013${entry.endLine}`;
470
+ let resultText = `Resolved conflict #${entry.id} at ${range} in ${entry.displayPath}.`;
471
+ if (stripped) {
472
+ resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
473
+ }
474
+
475
+ if (!diagnostics) {
476
+ return {
477
+ content: [{ type: "text", text: resultText }],
478
+ details: {},
479
+ };
480
+ }
481
+ return {
482
+ content: [{ type: "text", text: resultText }],
483
+ details: {
484
+ diagnostics,
485
+ meta: outputMeta()
486
+ .diagnostics(diagnostics.summary, diagnostics.messages ?? [])
487
+ .get(),
488
+ },
489
+ };
490
+ }
491
+
492
+ /**
493
+ * Bulk-resolve every registered conflict via `conflict://*`.
494
+ *
495
+ * Entries are grouped by file and applied bottom-up by recorded start
496
+ * line so each splice keeps later anchors valid. `content` tokens are
497
+ * expanded *per entry*, so `content: "@ours"` keeps each block's own
498
+ * ours side rather than collapsing every conflict to the first
499
+ * block's ours.
500
+ *
501
+ * All-or-nothing semantics within a file: if any splice for a file
502
+ * fails (stale anchors, missing base for `@base`, etc.), that file is
503
+ * left untouched and the error is surfaced. Files that succeed are
504
+ * still written. The result text reports per-file counts so the agent
505
+ * can re-read the failed files and retry.
506
+ */
507
+ async #resolveAllConflicts(
508
+ replacementContent: string,
509
+ stripped: boolean,
510
+ signal: AbortSignal | undefined,
511
+ context: AgentToolContext | undefined,
512
+ ): Promise<AgentToolResult<WriteToolDetails>> {
513
+ const history = getConflictHistory(this.session);
514
+ const allEntries = history.entries();
515
+ if (allEntries.length === 0) {
516
+ throw new ToolError(
517
+ "`conflict://*` has nothing to resolve — no conflicts are currently registered. Re-read the file(s) with conflicts first.",
518
+ );
519
+ }
520
+
521
+ const byFile = new Map<string, ConflictEntry[]>();
522
+ for (const entry of allEntries) {
523
+ const bucket = byFile.get(entry.absolutePath) ?? [];
524
+ bucket.push(entry);
525
+ byFile.set(entry.absolutePath, bucket);
526
+ }
527
+
528
+ const batchRequest = getLspBatchRequest(context?.toolCall);
529
+ const allDiagnostics: FileDiagnosticsResult[] = [];
530
+ const succeededFiles: { displayPath: string; count: number }[] = [];
531
+ const failedFiles: { displayPath: string; count: number; error: string }[] = [];
532
+ let totalResolvedIds = 0;
533
+
534
+ for (const [absolutePath, fileEntries] of byFile) {
535
+ const sample = fileEntries[0]!;
536
+ if (!(await fs.exists(absolutePath))) {
537
+ failedFiles.push({
538
+ displayPath: sample.displayPath,
539
+ count: fileEntries.length,
540
+ error: "file no longer exists",
541
+ });
542
+ continue;
543
+ }
544
+
545
+ fileEntries.sort((a, b) => b.startLine - a.startLine);
546
+
547
+ let text: string;
548
+ try {
549
+ text = await Bun.file(absolutePath).text();
550
+ for (const entry of fileEntries) {
551
+ const expanded = expandContentTokens(replacementContent, entry);
552
+ text = spliceConflict(text, entry, expanded);
553
+ }
554
+ } catch (error) {
555
+ failedFiles.push({
556
+ displayPath: sample.displayPath,
557
+ count: fileEntries.length,
558
+ error: error instanceof Error ? error.message : String(error),
559
+ });
560
+ continue;
561
+ }
562
+
563
+ const diagnostics = await this.#writethrough(absolutePath, text, signal, undefined, batchRequest);
564
+ invalidateFsScanAfterWrite(absolutePath);
565
+ this.session.fileReadCache?.invalidate(absolutePath);
566
+ for (const entry of fileEntries) history.invalidate(entry.id);
567
+ succeededFiles.push({ displayPath: sample.displayPath, count: fileEntries.length });
568
+ totalResolvedIds += fileEntries.length;
569
+ if (diagnostics) allDiagnostics.push(diagnostics);
570
+ }
571
+
572
+ const summaryLines: string[] = [];
573
+ const fileWord = (n: number) => (n === 1 ? "file" : "files");
574
+ const conflictWord = (n: number) => (n === 1 ? "conflict" : "conflicts");
575
+ if (succeededFiles.length > 0) {
576
+ summaryLines.push(
577
+ `Resolved ${totalResolvedIds} ${conflictWord(totalResolvedIds)} across ${succeededFiles.length} ${fileWord(succeededFiles.length)}:`,
578
+ );
579
+ for (const file of succeededFiles) {
580
+ summaryLines.push(` ${file.displayPath}: ${file.count} ${conflictWord(file.count)}`);
581
+ }
582
+ }
583
+ if (failedFiles.length > 0) {
584
+ summaryLines.push(
585
+ `Failed to resolve ${failedFiles.length} ${fileWord(failedFiles.length)} — registered entries left intact for retry:`,
586
+ );
587
+ for (const file of failedFiles) {
588
+ summaryLines.push(` ${file.displayPath}: ${file.count} ${conflictWord(file.count)} (${file.error})`);
589
+ }
590
+ }
591
+ if (stripped) {
592
+ summaryLines.push("Note: auto-stripped hashline display prefixes from content before writing.");
593
+ }
594
+ const resultText = summaryLines.join("\n");
595
+
596
+ if (allDiagnostics.length === 0) {
597
+ if (failedFiles.length > 0 && succeededFiles.length === 0) {
598
+ throw new ToolError(resultText);
599
+ }
600
+ return { content: [{ type: "text", text: resultText }], details: {} };
601
+ }
602
+ const mergedSummary = allDiagnostics.map(d => d.summary).join("\n");
603
+ const mergedMessages = allDiagnostics.flatMap(d => d.messages ?? []);
604
+ return {
605
+ content: [{ type: "text", text: resultText }],
606
+ details: {
607
+ meta: outputMeta().diagnostics(mergedSummary, mergedMessages).get(),
608
+ },
609
+ };
610
+ }
611
+
426
612
  async execute(
427
613
  _toolCallId: string,
428
614
  { path, content }: WriteParams,
@@ -433,6 +619,24 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
433
619
  return untilAborted(signal, async () => {
434
620
  // Strip hashline display prefixes (LINE+ID|) if the model copied them from read output
435
621
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
622
+ const conflictUri = parseConflictUri(path);
623
+ if (conflictUri) {
624
+ if (conflictUri.scope) {
625
+ throw new ToolError(
626
+ `Conflict URI scope '/${conflictUri.scope}' is read-only — use \`read conflict://${conflictUri.id}/${conflictUri.scope}\` to inspect that side. To write, drop the scope (\`conflict://${conflictUri.id}\`) and put the chosen content (or shorthand like \`@${conflictUri.scope}\`) in \`content\`.`,
627
+ );
628
+ }
629
+ if (conflictUri.id === "*") {
630
+ return this.#resolveAllConflicts(cleanContent, stripped, signal, context);
631
+ }
632
+ const entry = getConflictHistory(this.session).get(conflictUri.id);
633
+ if (!entry) {
634
+ throw new ToolError(
635
+ `Conflict #${conflictUri.id} not found. Conflict ids are registered when \`read\` surfaces a marker block; re-read the file to get a current id.`,
636
+ );
637
+ }
638
+ return this.#resolveConflict(entry, cleanContent, stripped, signal, context);
639
+ }
436
640
  const resolvedArchivePath = await this.#resolveArchiveWritePath(path);
437
641
  if (resolvedArchivePath) {
438
642
  enforcePlanModeWrite(this.session, resolvedArchivePath.archivePath, {