@oh-my-pi/pi-coding-agent 14.7.7 → 14.8.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/src/tools/read.ts CHANGED
@@ -8,6 +8,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
8
8
  import { Text } from "@oh-my-pi/pi-tui";
9
9
  import { getRemoteDir, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import { type Static, Type } from "@sinclair/typebox";
11
+ import { getFileReadCache } from "../edit/file-read-cache";
11
12
  import { formatHashLine, formatHashLines, formatLineHash, HL_BODY_SEP } from "../edit/line-hash";
12
13
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
13
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
@@ -163,6 +164,33 @@ function countTextLines(text: string): number {
163
164
  }
164
165
  const READ_CHUNK_SIZE = 8 * 1024;
165
166
 
167
+ /**
168
+ * Number of unanchored context lines to include before/after a user-requested
169
+ * range. Anchor-stale failures are heavily concentrated on edits whose anchors
170
+ * land just outside the most recent read window — a few lines of pre-anchored
171
+ * context covers off-by-one anchor selection without much cost.
172
+ */
173
+ const RANGE_CONTEXT_LINES = 3;
174
+
175
+ /**
176
+ * Expand a [start, end) range with ±RANGE_CONTEXT_LINES context lines on the
177
+ * sides where the user actually constrained the range. A start of 0 (no
178
+ * explicit offset) does not get leading context — that's already an open-ended
179
+ * read from the top.
180
+ */
181
+ function expandRangeWithContext(
182
+ requestedStart: number,
183
+ requestedEnd: number,
184
+ totalLines: number,
185
+ expandStart: boolean,
186
+ expandEnd: boolean,
187
+ ): { startLine: number; endLine: number } {
188
+ return {
189
+ startLine: expandStart ? Math.max(0, requestedStart - RANGE_CONTEXT_LINES) : requestedStart,
190
+ endLine: expandEnd ? Math.min(totalLines, requestedEnd + RANGE_CONTEXT_LINES) : requestedEnd,
191
+ };
192
+ }
193
+
166
194
  async function streamLinesFromFile(
167
195
  filePath: string,
168
196
  startLine: number,
@@ -645,9 +673,26 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
645
673
  const details = options.details ?? {};
646
674
  const allLines = text.split("\n");
647
675
  const totalLines = allLines.length;
648
- const startLine = offset ? Math.max(0, offset - 1) : 0;
649
- const startLineDisplay = startLine + 1;
676
+ // User-requested 0-indexed range start. Lines BEFORE this are leading
677
+ // context (added below if offset is explicit).
678
+ const requestedStart = offset ? Math.max(0, offset - 1) : 0;
650
679
  const ignoreResultLimits = options.ignoreResultLimits ?? false;
680
+ const requestedEnd =
681
+ limit !== undefined && !ignoreResultLimits
682
+ ? Math.min(requestedStart + limit, allLines.length)
683
+ : allLines.length;
684
+ // Expand only on sides the user actually constrained: leading context
685
+ // when offset>1, trailing context when a finite limit was set.
686
+ const expanded = expandRangeWithContext(
687
+ requestedStart,
688
+ requestedEnd,
689
+ allLines.length,
690
+ offset !== undefined && offset > 1,
691
+ limit !== undefined && !ignoreResultLimits,
692
+ );
693
+ const startLine = expanded.startLine;
694
+ const endLineExpanded = expanded.endLine;
695
+ const startLineDisplay = startLine + 1;
651
696
 
652
697
  const resultBuilder = toolResult(details);
653
698
  if (options.sourcePath) {
@@ -660,20 +705,19 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
660
705
  resultBuilder.sourceInternal(options.sourceInternal);
661
706
  }
662
707
 
663
- if (startLine >= allLines.length) {
708
+ if (requestedStart >= allLines.length) {
664
709
  const suggestion =
665
710
  allLines.length === 0
666
711
  ? `The ${options.entityLabel} is empty.`
667
712
  : `Use :1 to read from the start, or :${allLines.length} to read the last line.`;
668
713
  return resultBuilder
669
714
  .text(
670
- `Line ${startLineDisplay} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
715
+ `Line ${requestedStart + 1} is beyond end of ${options.entityLabel} (${allLines.length} lines total). ${suggestion}`,
671
716
  )
672
717
  .done();
673
718
  }
674
719
 
675
- const endLine =
676
- limit !== undefined && !ignoreResultLimits ? Math.min(startLine + limit, allLines.length) : allLines.length;
720
+ const endLine = endLineExpanded;
677
721
  const selectedContent = allLines.slice(startLine, endLine).join("\n");
678
722
  const userLimitedLines = limit !== undefined && !ignoreResultLimits ? endLine - startLine : undefined;
679
723
  const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
@@ -1318,13 +1362,20 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1318
1362
  if (!content) {
1319
1363
  // Raw text or line-range mode
1320
1364
  const { offset, limit } = selToOffsetLimit(parsed);
1321
- const startLine = offset ? Math.max(0, offset - 1) : 0;
1365
+ // User-requested 0-indexed range start. Lines BEFORE this become
1366
+ // leading context (added below if offset is explicit).
1367
+ const requestedStart = offset ? Math.max(0, offset - 1) : 0;
1368
+ const expandStart = offset !== undefined && offset > 1;
1369
+ const expandEnd = limit !== undefined;
1370
+ const leadingContext = expandStart ? Math.min(requestedStart, RANGE_CONTEXT_LINES) : 0;
1371
+ const trailingContext = expandEnd ? RANGE_CONTEXT_LINES : 0;
1372
+ const startLine = requestedStart - leadingContext;
1322
1373
  const startLineDisplay = startLine + 1;
1323
1374
 
1324
1375
  const DEFAULT_LIMIT = this.#defaultLimit;
1325
1376
  const effectiveLimit = limit ?? DEFAULT_LIMIT;
1326
- const maxLinesToCollect = Math.min(effectiveLimit, DEFAULT_MAX_LINES);
1327
- const selectedLineLimit = effectiveLimit;
1377
+ const maxLinesToCollect = Math.min(effectiveLimit + leadingContext + trailingContext, DEFAULT_MAX_LINES);
1378
+ const selectedLineLimit = effectiveLimit + leadingContext + trailingContext;
1328
1379
  // Scale byte budget with line limit so the configured line count actually fits.
1329
1380
  // Assume ~512 bytes/line average; never go below the shared default.
1330
1381
  const maxBytesForRead = Math.max(DEFAULT_MAX_BYTES, maxLinesToCollect * 512);
@@ -1348,13 +1399,15 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1348
1399
  } = streamResult;
1349
1400
 
1350
1401
  // Check if offset is out of bounds - return graceful message instead of throwing
1351
- if (startLine >= totalFileLines) {
1402
+ if (requestedStart >= totalFileLines) {
1352
1403
  const suggestion =
1353
1404
  totalFileLines === 0
1354
1405
  ? "The file is empty."
1355
1406
  : `Use :1 to read from the start, or :${totalFileLines} to read the last line.`;
1356
1407
  return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
1357
- .text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
1408
+ .text(
1409
+ `Line ${requestedStart + 1} is beyond end of file (${totalFileLines} lines total). ${suggestion}`,
1410
+ )
1358
1411
  .done();
1359
1412
  }
1360
1413
 
@@ -1378,6 +1431,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1378
1431
  firstLineExceedsLimit,
1379
1432
  };
1380
1433
 
1434
+ if (collectedLines.length > 0 && !firstLineExceedsLimit) {
1435
+ getFileReadCache(this.session).recordContiguous(absolutePath, startLineDisplay, collectedLines);
1436
+ }
1437
+
1381
1438
  const isRawMode = parsed.kind === "raw";
1382
1439
  const shouldAddHashLines = !isRawMode && displayMode.hashLines;
1383
1440
  const shouldAddLineNumbers = isRawMode ? false : shouldAddHashLines ? false : displayMode.lineNumbers;
@@ -6,6 +6,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
+ import { getFileReadCache } from "../edit/file-read-cache";
9
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
11
  import type { Theme } from "../modes/theme/theme";
11
12
  import searchDescription from "../prompts/tools/search.md" with { type: "text" };
@@ -345,27 +346,32 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
345
346
  }
346
347
  return nextWidth;
347
348
  }, 0);
349
+ const cacheEntries: Array<readonly [number, string]> = [];
348
350
  for (const match of fileMatches) {
349
- const pushLine = (lineNumber: number, line: string, isMatch: boolean) => {
351
+ const pushLine = (lineNumber: number, line: string, isMatch: boolean, recordable: boolean) => {
350
352
  modelOut.push(formatMatchLine(lineNumber, line, isMatch, { useHashLines }));
351
353
  displayOut.push(formatCodeFrameLine(isMatch ? "*" : " ", lineNumber, line, lineNumberWidth));
354
+ if (recordable) cacheEntries.push([lineNumber, line] as const);
352
355
  };
353
356
  if (match.contextBefore) {
354
357
  for (const ctx of match.contextBefore) {
355
- pushLine(ctx.lineNumber, ctx.line, false);
358
+ pushLine(ctx.lineNumber, ctx.line, false, true);
356
359
  }
357
360
  }
358
- pushLine(match.lineNumber, match.line, true);
361
+ pushLine(match.lineNumber, match.line, true, !match.truncated);
359
362
  if (match.truncated) {
360
363
  linesTruncated = true;
361
364
  }
362
365
  if (match.contextAfter) {
363
366
  for (const ctx of match.contextAfter) {
364
- pushLine(ctx.lineNumber, ctx.line, false);
367
+ pushLine(ctx.lineNumber, ctx.line, false, true);
365
368
  }
366
369
  }
367
370
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
368
371
  }
372
+ if (cacheEntries.length > 0) {
373
+ getFileReadCache(this.session).recordSparse(path.resolve(searchPath, relativePath), cacheEntries);
374
+ }
369
375
  return { model: modelOut, display: displayOut };
370
376
  };
371
377
  if (isDirectory) {
package/src/utils/git.ts CHANGED
@@ -40,6 +40,12 @@ export type HunkSelection = {
40
40
  hunks: { type: "all" } | { type: "indices"; indices: number[] } | { type: "lines"; start: number; end: number };
41
41
  };
42
42
 
43
+ export interface StageHunksOptions {
44
+ readonly diffCached?: boolean;
45
+ readonly rawDiff?: string;
46
+ readonly signal?: AbortSignal;
47
+ }
48
+
43
49
  export interface DiffOptions {
44
50
  readonly allowFailure?: boolean;
45
51
  readonly base?: string;
@@ -803,10 +809,10 @@ export const stage = {
803
809
  await runEffect(cwd, args, { signal });
804
810
  },
805
811
 
806
- /** Selectively stage hunks from the working tree diff. */
807
- async hunks(cwd: string, selections: HunkSelection[], signal?: AbortSignal): Promise<void> {
812
+ /** Selectively stage hunks from the provided diff or the current working tree diff. */
813
+ async hunks(cwd: string, selections: HunkSelection[], options: StageHunksOptions = {}): Promise<void> {
808
814
  if (selections.length === 0) return;
809
- const rawDiff = await diff(cwd, { cached: false, signal });
815
+ const rawDiff = options.rawDiff ?? (await diff(cwd, { cached: options.diffCached, signal: options.signal }));
810
816
  const fileDiffs = parseFileDiffs(rawDiff);
811
817
  const fileDiffMap = new Map(fileDiffs.map(entry => [entry.filename, entry]));
812
818
  const patchParts: string[] = [];
@@ -833,7 +839,7 @@ export const stage = {
833
839
 
834
840
  const patchText = patch.join(patchParts);
835
841
  if (!patchText.trim()) return;
836
- await patch.applyText(cwd, patchText, { cached: true, signal });
842
+ await patch.applyText(cwd, patchText, { cached: true, signal: options.signal });
837
843
  },
838
844
 
839
845
  /** Unstage files. Empty array unstages all (`git reset`). */
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { glob } from "@oh-my-pi/pi-natives";
4
- import { formatAge, formatBytes } from "@oh-my-pi/pi-utils";
4
+ import { $which, formatAge, formatBytes, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  export interface DirectoryTree {
7
7
  rootPath: string;
@@ -35,6 +35,13 @@ export interface DirectoryTreeOptions {
35
35
  cache?: boolean;
36
36
  /** Rendered label for the root line. */
37
37
  rootLabel?: string;
38
+ /**
39
+ * Pre-built map of `parentRelativePath` → child name set used in place of
40
+ * native directory listing. When provided, the tree builder consults this
41
+ * map for child enumeration instead of `glob` / `readdir`. Stat calls per
42
+ * displayed node are still performed for mtime/size/dir-ness.
43
+ */
44
+ childIndex?: ReadonlyMap<string, ReadonlySet<string>>;
38
45
  }
39
46
 
40
47
  const WORKSPACE_TREE_MAX_DEPTH = 3;
@@ -81,6 +88,7 @@ interface ResolvedDirectoryTreeOptions {
81
88
  gitignore: boolean;
82
89
  cache: boolean;
83
90
  rootLabel: string;
91
+ childIndex: ReadonlyMap<string, ReadonlySet<string>> | null;
84
92
  }
85
93
 
86
94
  interface RenderLine {
@@ -122,6 +130,7 @@ function resolveDirectoryTreeOptions(options: DirectoryTreeOptions): ResolvedDir
122
130
  gitignore: options.gitignore ?? false,
123
131
  cache: options.cache ?? true,
124
132
  rootLabel: options.rootLabel ?? ".",
133
+ childIndex: options.childIndex ?? null,
125
134
  };
126
135
  }
127
136
 
@@ -157,6 +166,10 @@ async function listDirectChildNames(
157
166
  parent: DirectoryTreeNode,
158
167
  options: ResolvedDirectoryTreeOptions,
159
168
  ): Promise<string[]> {
169
+ if (options.childIndex) {
170
+ const names = options.childIndex.get(parent.relativePath);
171
+ return names ? Array.from(names) : [];
172
+ }
160
173
  if (!options.gitignore) {
161
174
  const directoryPath = parent.relativePath ? path.join(rootPath, parent.relativePath) : rootPath;
162
175
  return await fs.readdir(directoryPath);
@@ -377,19 +390,96 @@ export async function buildDirectoryTree(rootPath: string, options: DirectoryTre
377
390
  };
378
391
  }
379
392
 
393
+ /**
394
+ * Build a `parentRelativePath` → child name index from a flat list of POSIX
395
+ * paths. Intermediate directory components are inferred from path segments;
396
+ * the index covers every ancestor directory implied by the input.
397
+ */
398
+ function buildChildIndexFromPaths(paths: readonly string[]): Map<string, Set<string>> {
399
+ const index = new Map<string, Set<string>>();
400
+ const ensure = (parent: string): Set<string> => {
401
+ let bucket = index.get(parent);
402
+ if (!bucket) {
403
+ bucket = new Set<string>();
404
+ index.set(parent, bucket);
405
+ }
406
+ return bucket;
407
+ };
408
+ for (const raw of paths) {
409
+ if (!raw) continue;
410
+ const normalized = raw.replace(/\\/g, "/");
411
+ const parts = normalized.split("/").filter(segment => segment.length > 0);
412
+ if (parts.length === 0) continue;
413
+ for (let i = 0; i < parts.length; i += 1) {
414
+ const parent = parts.slice(0, i).join("/");
415
+ const segment = parts[i];
416
+ if (segment !== undefined) ensure(parent).add(segment);
417
+ }
418
+ }
419
+ return index;
420
+ }
421
+
422
+ const GIT_LS_FILES_TIMEOUT_MS = 3000;
423
+
424
+ /**
425
+ * List tracked + untracked-not-ignored files at `rootPath` via `git ls-files`.
426
+ * Returns `null` when git is unavailable, the directory is not inside a
427
+ * worktree, or the call fails / times out — caller falls back to native
428
+ * directory listing.
429
+ */
430
+ async function tryListGitFiles(rootPath: string): Promise<string[] | null> {
431
+ const gitPath = $which("git");
432
+ if (!gitPath) return null;
433
+ const signal = AbortSignal.timeout(GIT_LS_FILES_TIMEOUT_MS);
434
+ try {
435
+ const child = Bun.spawn([gitPath, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
436
+ cwd: rootPath,
437
+ stdout: "pipe",
438
+ stderr: "pipe",
439
+ stdin: "ignore",
440
+ signal,
441
+ });
442
+ const [stdout, exitCode] = await Promise.all([
443
+ new Response(child.stdout as ReadableStream<Uint8Array>).text(),
444
+ child.exited,
445
+ ]);
446
+ if (exitCode !== 0) return null;
447
+ if (!stdout) return [];
448
+ // `-z` separates entries with NUL; trailing NUL after final entry.
449
+ return stdout.split("\0").filter(entry => entry.length > 0);
450
+ } catch (error) {
451
+ logger.debug("git ls-files failed; falling back to native directory listing", {
452
+ rootPath,
453
+ error: error instanceof Error ? error.message : String(error),
454
+ });
455
+ return null;
456
+ }
457
+ }
458
+
380
459
  export async function buildWorkspaceTree(cwd: string): Promise<WorkspaceTree> {
381
460
  const rootPath = path.resolve(cwd);
461
+ const baseOptions = {
462
+ maxDepth: WORKSPACE_TREE_MAX_DEPTH,
463
+ directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
464
+ lineCap: WORKSPACE_TREE_LINE_CAP,
465
+ excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
466
+ hidden: false,
467
+ cache: true,
468
+ rootLabel: ".",
469
+ } satisfies DirectoryTreeOptions;
470
+
382
471
  try {
383
- return await buildDirectoryTree(rootPath, {
384
- maxDepth: WORKSPACE_TREE_MAX_DEPTH,
385
- directoryEntryLimit: WORKSPACE_TREE_DIR_LIMIT,
386
- lineCap: WORKSPACE_TREE_LINE_CAP,
387
- excludedDirectoryNames: WORKSPACE_TREE_EXCLUDED_DIRS,
388
- hidden: false,
389
- gitignore: true,
390
- cache: true,
391
- rootLabel: ".",
392
- });
472
+ const gitFiles = await tryListGitFiles(rootPath);
473
+ if (gitFiles !== null) {
474
+ // Git already applied gitignore + tracking semantics: bypass native
475
+ // recursive scan and feed the index directly to the tree builder.
476
+ return await buildDirectoryTree(rootPath, {
477
+ ...baseOptions,
478
+ gitignore: false,
479
+ childIndex: buildChildIndexFromPaths(gitFiles),
480
+ });
481
+ }
482
+ return await buildDirectoryTree(rootPath, { ...baseOptions, gitignore: true });
393
483
  } catch {
394
484
  return emptyWorkspaceTree(rootPath);
395
485
  }