pi-hashline-edit-pro 7.0.0 → 7.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/src/grep.ts CHANGED
@@ -15,6 +15,7 @@ import { loadP, loadGuidelines } from "./prompts";
15
15
  import { normReq } from "./payload-contract";
16
16
  import { DEFAULT_EDIT_FLAGS, withGrepPrompts, type EditToolFlags } from "./edit-common";
17
17
  import { abortIf, clipLine, errCode, isRec, makePrepareArguments, rejectUnknownFields, truncateToBytes, visLines, gutterWidth } from "./utils";
18
+ import type { LineEnding } from "./normalize";
18
19
  import { withAnchorSession, formatAnchorReclaimNotice, takeReclaimedPaths } from "./anchor-registry";
19
20
  import { serveRows } from "./served";
20
21
  import { Text } from "@earendil-works/pi-tui";
@@ -139,6 +140,62 @@ function grepHeadFragment(line: string): string {
139
140
  return head.length < line.length ? `${head}...` : head;
140
141
  }
141
142
 
143
+ function lineStartsOf(text: string): number[] {
144
+ const starts = [0];
145
+ for (let index = 0; index < text.length; index += 1) {
146
+ if (text.charCodeAt(index) === 10 && index + 1 < text.length) starts.push(index + 1);
147
+ }
148
+ return starts;
149
+ }
150
+
151
+ function physicalStartsOf(text: string, separators: readonly LineEnding[]): number[] {
152
+ const starts = [0];
153
+ let separatorIndex = 0;
154
+ for (let index = 0; index < text.length; index += 1) {
155
+ if (text.charCodeAt(index) !== 10) continue;
156
+ const separator = separators[separatorIndex] ?? "\n";
157
+ separatorIndex += 1;
158
+ if (separator === "\n" || separator === "\r\n") starts.push(index + 1);
159
+ }
160
+ return starts;
161
+ }
162
+
163
+ function lineIndexOf(starts: readonly number[], position: number): number {
164
+ let low = 0;
165
+ let high = starts.length - 1;
166
+ while (low < high) {
167
+ const mid = (low + high + 1) >>> 1;
168
+ if (starts[mid]! <= position) low = mid;
169
+ else high = mid - 1;
170
+ }
171
+ return low;
172
+ }
173
+
174
+ function toNormalizedMatches(
175
+ norm: { normalized: string; endingSeparators: readonly LineEnding[] },
176
+ matches: RgMatchLine[],
177
+ ): RgMatchLine[] {
178
+ if (!norm.endingSeparators.includes("\r")) return matches;
179
+ const text = norm.normalized;
180
+ const starts = lineStartsOf(text);
181
+ const physical = physicalStartsOf(text, norm.endingSeparators);
182
+ const lines = visLines(text);
183
+ return matches.map((match) => {
184
+ const base = physical[match.line - 1] ?? 0;
185
+ const physicalLine = text.slice(base, physical[match.line] ?? text.length);
186
+ const position = base + byteOffsetToCharIndex(physicalLine, match.start);
187
+ const index = lineIndexOf(starts, position);
188
+ const lineText = lines[index] ?? "";
189
+ const columnStart = Math.min(position - starts[index]!, lineText.length);
190
+ const columnEnd = Math.min(base + byteOffsetToCharIndex(physicalLine, match.end) - starts[index]!, lineText.length);
191
+ return {
192
+ line: index + 1,
193
+ start: Buffer.byteLength(lineText.slice(0, columnStart), "utf-8"),
194
+ end: Buffer.byteLength(lineText.slice(0, Math.max(columnStart, columnEnd)), "utf-8"),
195
+ };
196
+ });
197
+ }
198
+
142
199
  function makeHitFromIndices(
143
200
  norm: { normalized: string; fileHashes: string[]; absolutePath: string; hadUtf8DecodeErrors: boolean },
144
201
  displayPath: string,
@@ -247,6 +304,11 @@ async function insideGitRepo(start: string): Promise<boolean> {
247
304
  }
248
305
  }
249
306
 
307
+ interface RgSearch {
308
+ matches: Map<string, RgMatchLine[]>;
309
+ skipped?: string;
310
+ }
311
+
250
312
  async function collectRgMatches(
251
313
  rgPath: string,
252
314
  pattern: string,
@@ -254,8 +316,8 @@ async function collectRgMatches(
254
316
  req: GrepReq,
255
317
  signal: AbortSignal | undefined,
256
318
  repoRooted: boolean,
257
- ): Promise<Map<string, RgMatchLine[]>> {
258
- const args = ["--json", "--line-number", "--color=never", "--hidden", "--glob", "!.git"];
319
+ ): Promise<RgSearch> {
320
+ const args = ["--json", "--line-number", "--color=never", "--hidden", "--crlf", "--glob", "!.git"];
259
321
  if (!repoRooted) args.push("--no-require-git");
260
322
  const wanted = req.limit ?? 100;
261
323
  args.push("--max-count", String(wanted + 1));
@@ -267,7 +329,7 @@ async function collectRgMatches(
267
329
  if (req.literal) args.push("--fixed-strings");
268
330
  args.push("--", pattern, searchPath);
269
331
  const result = new Map<string, RgMatchLine[]>();
270
- return await new Promise<Map<string, RgMatchLine[]>>((resolve, reject) => {
332
+ return await new Promise<RgSearch>((resolve, reject) => {
271
333
  const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
272
334
  const rl = createInterface({ input: child.stdout });
273
335
  let stderr = "";
@@ -331,12 +393,16 @@ async function collectRgMatches(
331
393
  }
332
394
  if (code !== 0 && code !== 1) {
333
395
  const detail = clipLine(stderr.trim(), 300);
334
- reject(new Error(detail
335
- ? `[E_GREP_FAILED] ripgrep exited with code ${code}: ${detail}`
336
- : `[E_GREP_FAILED] ripgrep exited with code ${code}.`));
396
+ if (result.size === 0) {
397
+ reject(new Error(detail
398
+ ? `[E_GREP_FAILED] ripgrep exited with code ${code}: ${detail}`
399
+ : `[E_GREP_FAILED] ripgrep exited with code ${code}.`));
400
+ return;
401
+ }
402
+ resolve({ matches: result, ...(detail ? { skipped: detail } : {}) });
337
403
  return;
338
404
  }
339
- resolve(result);
405
+ resolve({ matches: result });
340
406
  });
341
407
  });
342
408
  }
@@ -541,7 +607,8 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
541
607
  const readGrepFile = makeGrepReader("real");
542
608
  const readGrepFileShadow = makeGrepReader("shadow");
543
609
  const repoRooted = await insideGitRepo(globRoot);
544
- const rgMatches = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
610
+ const rgSearch = await collectRgMatches(rgPath, req.pattern, base, req, signal, repoRooted);
611
+ const rgMatches = rgSearch.matches;
545
612
  const sortedFiles = [...rgMatches.keys()].sort(cmp);
546
613
  for (let f = 0; f < sortedFiles.length; f++) {
547
614
  abortIf(signal);
@@ -552,7 +619,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
552
619
  if (!matchesGlob(absPath)) continue;
553
620
  const norm = await readGrepFileShadow(absPath);
554
621
  if (!norm) continue;
555
- const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, fileMatches.length);
622
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), toNormalizedMatches(norm, fileMatches), context, totalForFile, fileMatches.length);
556
623
  const display = displayRowsForHit(hit);
557
624
  totalRows += display.length;
558
625
  for (const r of display) totalBytes += Buffer.byteLength(r, "utf-8") + 1;
@@ -574,7 +641,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
574
641
  if (!matchesGlob(absPath)) continue;
575
642
  const norm = await readGrepFile(absPath);
576
643
  if (!norm) continue;
577
- const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), fileMatches, context, totalForFile, Math.min(totalForFile, remaining));
644
+ const hit = makeHitFromIndices(norm, toDisplayPath(ctx.cwd, absPath), toNormalizedMatches(norm, fileMatches), context, totalForFile, Math.min(totalForFile, remaining));
578
645
  const display = displayRowsForHit(hit);
579
646
  const keptRows: string[] = [];
580
647
  const keptHashes: string[] = [];
@@ -624,6 +691,7 @@ export function regGrep(pi: ExtensionAPI, flags: EditToolFlags = DEFAULT_EDIT_FL
624
691
  if (limitTruncated) notes.push(`[grep: showing first ${limit} matches; increase limit to see more.]`);
625
692
  if (linesReplaced > 0) notes.push(`[grep: ${linesReplaced} line(s) exceed ${formatSize(MAX_GREP_LINE_BYTES)} and are shown as truncated fragments; use read to see the full lines.]`);
626
693
  if (poolSkipped > 0) notes.push(`[grep: ${poolSkipped} file(s) skipped because the session's anchor pool is exhausted; narrow the search.]`);
694
+ if (rgSearch.skipped !== undefined) notes.push(`[grep: some paths could not be searched, so these results are partial: ${rgSearch.skipped}]`);
627
695
  const truncated = limitTruncated || rowTruncated;
628
696
  const truncation: TruncationResult | undefined = rowTruncated
629
697
  ? {
@@ -113,9 +113,12 @@ export function fmtMismatchWithHashes(
113
113
  for (let ln = from; ln <= to; ln++) {
114
114
  const h = fileHashes[ln - 1]!;
115
115
  const c = fileLines[ln - 1] ?? "";
116
- hashes.push(h);
117
- servedMap.set(h, lineChecksum(c));
118
- rows.push(` ${ln}: ${h}│${clipLine(c)}`);
116
+ const shown = clipLine(c);
117
+ if (shown === c) {
118
+ hashes.push(h);
119
+ servedMap.set(h, lineChecksum(c));
120
+ }
121
+ rows.push(` ${ln}: ${h}│${shown}`);
119
122
  }
120
123
  out.push("");
121
124
  out.push(` Current context around resolved anchor "${ctx.hash}" (line ${ctx.line}):\n${rows.join("\n")}`);
@@ -441,12 +444,18 @@ export function assertRangeServed(
441
444
  const rows: string[] = [];
442
445
  const shownHashes: string[] = [];
443
446
  const shownMap = new Map<string, string>();
447
+ const clippedLines: number[] = [];
444
448
  for (let line = startLine; line < startLine + shownLength; line++) {
445
449
  const hash = fileHashes[line - 1]!;
446
450
  const content = fileLines[line - 1]!;
451
+ const shown = clipLine(content);
452
+ rows.push(fmtRow(hash, shown));
453
+ if (shown !== content) {
454
+ clippedLines.push(line);
455
+ continue;
456
+ }
447
457
  shownHashes.push(hash);
448
458
  shownMap.set(hash, lineChecksum(content));
449
- rows.push(fmtRow(hash, clipLine(content)));
450
459
  }
451
460
  const location = filePath ? ` in ${filePath}` : "";
452
461
  const rangeDescription = `the replaced range (lines ${startLine}-${endLine})${location}`;
@@ -457,7 +466,9 @@ export function assertRangeServed(
457
466
  const capHint =
458
467
  rangeLength > shownLength
459
468
  ? `\n\n[The range has ${rangeLength} lines; showing the first ${shownLength}. Call read()${filePath ? ` on ${filePath}` : ""} with offset=${startLine + shownLength} to see the rest.]`
460
- : "\n\nRetry with the fresh anchors above without a read.";
469
+ : clippedLines.length > 0
470
+ ? `\n\n[${clippedLines.length === 1 ? `Line ${clippedLines[0]} is` : `Lines ${clippedLines.join(", ")} are`} clipped above and not editable from here; call read()${filePath ? ` on ${filePath}` : ""} before editing.]`
471
+ : "\n\nRetry with the fresh anchors above without a read.";
461
472
  const message =
462
473
  `[E_RANGE_STALE] ${mismatchText}. Current range with fresh anchors:\n\n${rows.join("\n")}${capHint}`;
463
474
  throw new RangeStaleError(message, shownHashes, shownMap);
package/src/model-gate.ts CHANGED
@@ -6,6 +6,7 @@ export interface ModelLike {
6
6
 
7
7
  export const ANCHOR_TOOL_NAMES: readonly string[] = [
8
8
  "read",
9
+ "outline",
9
10
  "replace",
10
11
  "replace_match",
11
12
  "insert",
@@ -18,7 +18,7 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
18
18
  const parser = await loadOutlineParser();
19
19
  const parsed = parser === undefined ? undefined : await parser.parse(input.displayPath, input.content);
20
20
  if (parsed !== undefined && parsed.symbols.length > 0) {
21
- return renderSymbolOutline({
21
+ const outline = renderSymbolOutline({
22
22
  displayPath: input.displayPath,
23
23
  languageName: parsed.languageName,
24
24
  totalLines: lines.length,
@@ -29,8 +29,9 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
29
29
  ...(input.maxDepth !== undefined ? { maxDepth: input.maxDepth } : {}),
30
30
  ...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}),
31
31
  });
32
+ return parsed.hasSyntaxErrors ? { ...outline, hasSyntaxErrors: true } : outline;
32
33
  }
33
- return renderPreviewOutline({
34
+ const preview = renderPreviewOutline({
34
35
  displayPath: input.displayPath,
35
36
  lines,
36
37
  hashes: input.hashes,
@@ -38,6 +39,8 @@ export async function buildFileOutline(input: FileOutlineInput): Promise<Outline
38
39
  ...(input.previewTailLines !== undefined ? { tailLines: input.previewTailLines } : {}),
39
40
  ...(input.maxBytes !== undefined ? { maxBytes: input.maxBytes } : {}),
40
41
  });
42
+ if (lines.length === 0) return preview;
43
+ return { ...preview, preview: parsed === undefined ? "unsupported" : "no-symbols", ...(parsed?.hasSyntaxErrors ? { hasSyntaxErrors: true } : {}) };
41
44
  }
42
45
 
43
46
  export { loadOutlineParser } from "./parsers";
@@ -4,12 +4,12 @@ export interface OutlineSymbol {
4
4
  startLine: number;
5
5
  endLine: number;
6
6
  children?: OutlineSymbol[];
7
- detail?: string;
8
7
  }
9
8
 
10
9
  export interface OutlineParse {
11
10
  symbols: OutlineSymbol[];
12
11
  languageName: string;
12
+ hasSyntaxErrors: boolean;
13
13
  }
14
14
 
15
15
  export interface OutlineParser {
@@ -25,7 +25,7 @@ async function load(): Promise<OutlineParser | undefined> {
25
25
  parse: async (path, content) => {
26
26
  if (parsers.detectLanguage(path) === null) return undefined;
27
27
  const parsed = await parsers.parseSourceFile(path, content);
28
- return { symbols: parsed.symbols, languageName: parsed.languageName };
28
+ return { symbols: parsed.symbols, languageName: parsed.languageName, hasSyntaxErrors: parsed.hasError === true };
29
29
  },
30
30
  };
31
31
  } catch (error) {
@@ -1,14 +1,18 @@
1
1
  import { DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
2
+ import { HASH_SEP } from "../hashline";
2
3
  import { clipLine } from "../utils";
3
4
  import type { OutlineSymbol } from "./parsers";
4
5
 
5
6
  const DEFAULT_MAX_ROWS = 400;
6
7
  const DEFAULT_MAX_DEPTH = 6;
7
- const MAX_DETAIL_CHARS = 60;
8
8
  const PREVIEW_HEAD_LINES = 20;
9
9
  const PREVIEW_TAIL_LINES = 10;
10
10
  const PREVIEW_LINE_CHARS = 200;
11
11
 
12
+ function formatMoreLines(count: number): string {
13
+ return `... (${count} more ${count === 1 ? "line" : "lines"})`;
14
+ }
15
+
12
16
  export interface OutlineRow {
13
17
  line: number;
14
18
  anchor: string;
@@ -20,6 +24,9 @@ export interface OutlineRender {
20
24
  rows: OutlineRow[];
21
25
  servedHashes: string[];
22
26
  truncated: boolean;
27
+ complete: boolean;
28
+ preview?: "unsupported" | "no-symbols";
29
+ hasSyntaxErrors?: boolean;
23
30
  }
24
31
 
25
32
  export interface SymbolOutlineInput {
@@ -43,64 +50,118 @@ export interface PreviewOutlineInput {
43
50
  maxBytes?: number;
44
51
  }
45
52
 
46
- function servedHashesOf(rows: OutlineRow[]): string[] {
47
- return [...new Set(rows.map((row) => row.anchor))];
53
+ interface OutlineBudget {
54
+ output: string[];
55
+ rows: OutlineRow[];
56
+ servedHashes: string[];
57
+ maxRows: number;
58
+ maxBytes: number;
59
+ bytes: number;
60
+ truncated: boolean;
61
+ truncation?: "rows" | "bytes";
62
+ skipped: number;
63
+ }
64
+
65
+ function startBudget(header: string, maxRows: number, maxBytes: number): OutlineBudget {
66
+ return { output: [header], rows: [], servedHashes: [], maxRows, maxBytes, bytes: Buffer.byteLength(header, "utf-8") + 1, truncated: false, skipped: 0 };
67
+ }
68
+
69
+ function pushLine(budget: OutlineBudget, text: string): boolean {
70
+ const size = Buffer.byteLength(text, "utf-8") + 1;
71
+ if (budget.bytes + size > budget.maxBytes) return false;
72
+ budget.bytes += size;
73
+ budget.output.push(text);
74
+ return true;
75
+ }
76
+
77
+ function markTruncated(budget: OutlineBudget, cause: "rows" | "bytes"): void {
78
+ budget.truncated = true;
79
+ if (budget.truncation === undefined) budget.truncation = cause;
80
+ }
81
+
82
+ function isComplete(budget: OutlineBudget): boolean {
83
+ return !budget.truncated && budget.skipped === 0 && budget.servedHashes.length === budget.rows.length;
84
+ }
85
+
86
+ type EmitOutcome = "emitted" | "skipped" | "stopped";
87
+
88
+ function emitRow(budget: OutlineBudget, hashes: string[], lines: string[], line: number, text: string): EmitOutcome {
89
+ const anchor = hashes[line - 1];
90
+ if (anchor === undefined) {
91
+ budget.skipped += 1;
92
+ return "skipped";
93
+ }
94
+ if (budget.rows.length >= budget.maxRows) {
95
+ markTruncated(budget, "rows");
96
+ return "stopped";
97
+ }
98
+ if (!pushLine(budget, `${anchor}${HASH_SEP}${text}`)) {
99
+ markTruncated(budget, "bytes");
100
+ return "stopped";
101
+ }
102
+ budget.rows.push({ line, anchor, text });
103
+ if (text === (lines[line - 1] ?? "")) budget.servedHashes.push(anchor);
104
+ return "emitted";
48
105
  }
49
106
 
50
- function countSymbols(symbols: OutlineSymbol[]): number {
107
+ function countSymbolRows(
108
+ symbols: OutlineSymbol[],
109
+ depth: number,
110
+ maxDepth: number,
111
+ hashes: string[],
112
+ seenLines: Set<number>,
113
+ ): number {
51
114
  let total = 0;
52
115
  for (const symbol of symbols) {
53
- total += 1;
54
- if (symbol.children) total += countSymbols(symbol.children);
116
+ const children = symbol.children ?? [];
117
+ if (seenLines.has(symbol.startLine)) {
118
+ if (children.length > 0 && depth < maxDepth) total += countSymbolRows(children, depth + 1, maxDepth, hashes, seenLines);
119
+ continue;
120
+ }
121
+ seenLines.add(symbol.startLine);
122
+ if (hashes[symbol.startLine - 1] !== undefined) total += 1;
123
+ if (children.length === 0 || depth >= maxDepth) continue;
124
+ total += countSymbolRows(children, depth + 1, maxDepth, hashes, seenLines);
55
125
  }
56
126
  return total;
57
127
  }
58
128
 
59
- function symbolLabel(symbol: OutlineSymbol, depth: number): string {
60
- const indent = " ".repeat(depth);
61
- const name = symbol.name.length > 0 ? ` ${symbol.name}` : "";
62
- const detail = symbol.detail !== undefined && symbol.detail.length > 0
63
- ? ` ${symbol.detail.length > MAX_DETAIL_CHARS ? `${symbol.detail.slice(0, MAX_DETAIL_CHARS - 3)}...` : symbol.detail}`
64
- : "";
65
- const children = symbol.children !== undefined && symbol.children.length > 0 ? ` (${symbol.children.length} children)` : "";
66
- const span = symbol.endLine - symbol.startLine + 1;
67
- return `${indent}${symbol.type}${name}${detail}${children}${span > 1 ? ` [limit ${span}]` : ""}`;
68
- }
69
-
70
129
  export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
71
- const maxRows = input.maxRows ?? DEFAULT_MAX_ROWS;
72
- const maxDepth = input.maxDepth ?? DEFAULT_MAX_DEPTH;
73
- const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
74
130
  const header = `=== ${input.displayPath}${input.languageName !== undefined ? ` (${input.languageName})` : ""} — ${input.totalLines} lines ===`;
75
- const rows: OutlineRow[] = [];
76
- const output: string[] = [header];
77
- let bytes = Buffer.byteLength(header, "utf-8") + 1;
78
- let truncated = false;
79
-
80
- const addRow = (line: number, text: string): boolean => {
81
- const anchor = input.hashes[line - 1];
82
- if (anchor === undefined) return true;
83
- const rendered = `${anchor}│${text}`;
84
- const size = Buffer.byteLength(rendered, "utf-8") + 1;
85
- if (rows.length >= maxRows || bytes + size > maxBytes) {
86
- truncated = true;
87
- return false;
88
- }
89
- bytes += size;
90
- output.push(rendered);
91
- rows.push({ line, anchor, text });
92
- return true;
93
- };
131
+ const budget = startBudget(header, input.maxRows ?? DEFAULT_MAX_ROWS, input.maxBytes ?? DEFAULT_MAX_BYTES);
132
+ const maxDepth = input.maxDepth ?? DEFAULT_MAX_DEPTH;
133
+ const seenLines = new Set<number>();
94
134
 
95
135
  const walk = (symbols: OutlineSymbol[], depth: number): boolean => {
96
136
  for (const symbol of symbols) {
97
- const contentLine = symbol.endLine === symbol.startLine ? input.lines[symbol.startLine - 1] : undefined;
98
- const text = contentLine === undefined ? symbolLabel(symbol, depth) : clipLine(contentLine, PREVIEW_LINE_CHARS);
99
- if (!addRow(symbol.startLine, text)) return false;
137
+ if (seenLines.has(symbol.startLine)) {
138
+ const children = symbol.children ?? [];
139
+ if (children.length > 0) {
140
+ if (depth < maxDepth) {
141
+ if (!walk(children, depth + 1)) return false;
142
+ } else if (!pushLine(budget, `[${children.length} nested]`)) {
143
+ markTruncated(budget, "bytes");
144
+ return false;
145
+ }
146
+ }
147
+ continue;
148
+ }
149
+ seenLines.add(symbol.startLine);
150
+ const text = clipLine(input.lines[symbol.startLine - 1] ?? "", PREVIEW_LINE_CHARS);
151
+ const outcome = emitRow(budget, input.hashes, input.lines, symbol.startLine, text);
152
+ if (outcome === "stopped") return false;
153
+ const span = symbol.endLine - symbol.startLine + 1;
154
+ if (outcome === "emitted" && span > 1 && !pushLine(budget, `[limit ${span}]`)) {
155
+ markTruncated(budget, "bytes");
156
+ return false;
157
+ }
100
158
  const children = symbol.children ?? [];
101
159
  if (children.length === 0) continue;
102
160
  if (depth >= maxDepth) {
103
- output.push(`${" ".repeat(depth + 1)}(${children.length} nested items)`);
161
+ if (!pushLine(budget, `[${children.length} nested]`)) {
162
+ markTruncated(budget, "bytes");
163
+ return false;
164
+ }
104
165
  continue;
105
166
  }
106
167
  if (!walk(children, depth + 1)) return false;
@@ -109,62 +170,75 @@ export function renderSymbolOutline(input: SymbolOutlineInput): OutlineRender {
109
170
  };
110
171
 
111
172
  walk(input.symbols, 0);
112
- if (truncated) {
113
- output.push(`... (${countSymbols(input.symbols) - rows.length} more symbols)`);
173
+ if (budget.truncated) {
174
+ const remaining = countSymbolRows(input.symbols, 0, maxDepth, input.hashes, new Set<number>()) - budget.rows.length;
175
+ const more = remaining > 0 ? ` (${remaining} more ${remaining === 1 ? "symbol" : "symbols"})` : "";
176
+ const cause = budget.truncation === "rows" ? "row cap" : "byte budget";
177
+ if (budget.rows.length === 0) {
178
+ budget.output.push(more.length > 0
179
+ ? `...${more}; outline truncated by the ${cause} before the first row.`
180
+ : `... (outline truncated by the ${cause} before the first row.)`);
181
+ } else if (remaining > 0) {
182
+ budget.output.push(`...${more}; read from the last shown anchor to continue.`);
183
+ } else {
184
+ budget.output.push(`... (outline truncated by the ${cause}; read from the last shown anchor to continue.)`);
185
+ }
114
186
  }
115
- return { text: output.join("\n"), rows, servedHashes: servedHashesOf(rows), truncated };
187
+ return { text: budget.output.join("\n"), rows: budget.rows, servedHashes: budget.servedHashes, truncated: budget.truncated, complete: isComplete(budget) };
116
188
  }
117
189
 
118
190
  export function renderPreviewOutline(input: PreviewOutlineInput): OutlineRender {
119
- const headCount = input.headLines ?? PREVIEW_HEAD_LINES;
120
- const tailCount = input.tailLines ?? PREVIEW_TAIL_LINES;
121
- const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
122
191
  const header = `=== ${input.displayPath} — ${input.lines.length} lines ===`;
123
- const rows: OutlineRow[] = [];
124
- const output: string[] = [header];
125
- let bytes = Buffer.byteLength(header, "utf-8") + 1;
126
- let truncated = false;
192
+ const budget = startBudget(header, Number.POSITIVE_INFINITY, input.maxBytes ?? DEFAULT_MAX_BYTES);
127
193
  if (input.lines.length === 0) {
128
194
  const anchor = input.hashes[0];
129
195
  if (anchor !== undefined) {
130
- output.push(`${anchor}│`, "[File is empty. Use replace to insert content.]");
131
- rows.push({ line: 1, anchor, text: "" });
196
+ const marker = `${anchor}${HASH_SEP}`;
197
+ const note = "[File is empty. Use replace to insert content.]";
198
+ if (pushLine(budget, marker)) {
199
+ budget.rows.push({ line: 1, anchor, text: "" });
200
+ budget.servedHashes.push(anchor);
201
+ if (!pushLine(budget, note)) markTruncated(budget, "bytes");
202
+ } else {
203
+ markTruncated(budget, "bytes");
204
+ }
132
205
  }
133
- return { text: output.join("\n"), rows, servedHashes: servedHashesOf(rows), truncated: false };
206
+ return {
207
+ text: budget.output.join("\n"),
208
+ rows: budget.rows,
209
+ servedHashes: budget.servedHashes,
210
+ truncated: budget.truncated,
211
+ complete: isComplete(budget),
212
+ };
134
213
  }
135
214
 
136
- const addRow = (index: number): boolean => {
137
- const line = input.lines[index];
138
- const anchor = input.hashes[index];
139
- if (line === undefined || anchor === undefined) return true;
140
- const text = clipLine(line, PREVIEW_LINE_CHARS);
141
- const rendered = `${anchor}│${text}`;
142
- const size = Buffer.byteLength(rendered, "utf-8") + 1;
143
- if (bytes + size > maxBytes) {
144
- truncated = true;
145
- return false;
146
- }
147
- bytes += size;
148
- output.push(rendered);
149
- rows.push({ line: index + 1, anchor, text });
150
- return true;
151
- };
215
+ const head = Math.min(input.headLines ?? PREVIEW_HEAD_LINES, input.lines.length);
216
+ const tail = Math.min(input.tailLines ?? PREVIEW_TAIL_LINES, input.lines.length - head);
217
+ const attempt = (index: number): EmitOutcome => emitRow(budget, input.hashes, input.lines, index + 1, clipLine(input.lines[index] ?? "", PREVIEW_LINE_CHARS));
152
218
 
153
- const head = Math.min(headCount, input.lines.length);
154
- const tail = Math.min(tailCount, input.lines.length - head);
155
219
  for (let index = 0; index < head; index += 1) {
156
- if (!addRow(index)) break;
220
+ if (attempt(index) === "stopped") break;
157
221
  }
158
- if (!truncated && head + tail < input.lines.length) {
159
- output.push(`... (${input.lines.length - head - tail} more lines)`);
222
+ let middleIndex: number | undefined;
223
+ if (!budget.truncated && head + tail < input.lines.length) {
224
+ if (pushLine(budget, formatMoreLines(input.lines.length - head - tail))) middleIndex = budget.output.length - 1;
225
+ else markTruncated(budget, "bytes");
160
226
  }
161
- if (!truncated) {
227
+ if (!budget.truncated) {
162
228
  for (let index = input.lines.length - tail; index < input.lines.length; index += 1) {
163
- if (!addRow(index)) break;
229
+ if (attempt(index) === "stopped") break;
164
230
  }
165
231
  }
166
- if (truncated) {
167
- output.push(`... (${input.lines.length - rows.length} more lines)`);
232
+ if (budget.truncated) {
233
+ const remaining = input.lines.length - budget.rows.length;
234
+ if (middleIndex !== undefined) budget.output.splice(middleIndex, 1);
235
+ if (remaining > 0) budget.output.push(formatMoreLines(remaining));
168
236
  }
169
- return { text: output.join("\n"), rows, servedHashes: servedHashesOf(rows), truncated };
237
+ return {
238
+ text: budget.output.join("\n"),
239
+ rows: budget.rows,
240
+ servedHashes: budget.servedHashes,
241
+ truncated: budget.truncated,
242
+ complete: head + tail >= input.lines.length && isComplete(budget),
243
+ };
170
244
  }
@@ -9,6 +9,10 @@ These files are copied from [pi-codebase-reader](https://github.com/HanzCEO/pi-c
9
9
  - `parsers/manager.ts` extracts anonymous default exports (`export default function`/`class`), function expressions, top-level variable declarators, and nested `describe`/`test`/`it` calls, so entry-point, config, data, and test files outline instead of falling back to a preview.
10
10
  - `parsers/manager.ts` adds PHP support: `parsers/php.ts` outlines classes, interfaces, traits, enums, methods, properties, constants, top-level functions, and assigned closures.
11
11
  - `parsers/manager.ts` adds Blade support: `parsers/blade.ts` outlines `@section`/`@push`/`@once`/`@php` blocks, named directives such as `@extends` and `@include`, and `<x-…>`/`<livewire:…>` components.
12
+ - `parsers/java.ts` reads the field-based tree-sitter-java grammar shipped in `wasm/` (method, constructor, field, constant, enum, record, and annotation-element nodes), so annotated members keep their names; every member anchors on its declaration line rather than on its annotation.
13
+ - `parsers/php.ts` anchors a declaration on its first line after its attributes, so a member with `#[Test]` starts on the declaration instead of the attribute.
14
+ - `types.ts` and every parser drop the upstream `detail` string: outlines render the first line of each symbol's declaration, so the parameters, inheritance, and qualifier text was computed and discarded.
15
+ - `types.ts` keeps `SymbolInfo.name` and `SymbolInfo.type`: the extractors use them while building symbol trees and the upstream contract carries them, even though outlines render a real file line for each symbol.
12
16
 
13
17
  The `wasm/` grammars come from the MIT-licensed `tree-sitter-javascript`, `tree-sitter-typescript`, `tree-sitter-python`, `tree-sitter-go`, `tree-sitter-rust`, `tree-sitter-solidity`, and `tree-sitter-java` packages, plus pi-codebase-reader's vendored Sass and SCSS grammars, the VS Code WebAssembly build of `tree-sitter-php`, and `tree-sitter-blade` built from v0.12.3; see `wasm/LICENSES.md` for the per-grammar copyright notices. Comments in these files are upstream prose and are exempt from this repository's no-comments rule.
14
18
 
@@ -62,8 +62,13 @@ function bodyStart(node: Node): number {
62
62
  }
63
63
 
64
64
  function blockSymbol(node: Node, source: string): SymbolInfo | null {
65
+ const directive = node.namedChildren[0];
66
+ if (directive?.type === "directive_start" && (node.type === "conditional" || node.type === "loop")) {
67
+ const [startLine, endLine] = rangeOf(node);
68
+ return { name: parameterName(node, source), type: textOf(directive, source).slice(1), startLine, endLine };
69
+ }
65
70
  const label = BLOCK_LABELS[node.type];
66
- if (label === undefined) return null;
71
+ if (label === undefined || (node.type === "php_statement" && directive?.type !== "directive_start")) return null;
67
72
  const [startLine, endLine] = rangeOf(node);
68
73
  return { name: parameterName(node, source), type: label, startLine, endLine };
69
74
  }
@@ -76,7 +81,7 @@ function directiveSymbol(node: Node, parameter: Node | null, source: string): Sy
76
81
  const raw = textOf(parameter, source);
77
82
  const quoted = unquote(raw);
78
83
  return quoted === undefined
79
- ? { name: "", type: label, startLine, endLine, detail: raw.trim() }
84
+ ? { name: "", type: label, startLine, endLine }
80
85
  : { name: quoted, type: label, startLine, endLine };
81
86
  }
82
87
 
@@ -21,6 +21,7 @@ export interface ParseFileResult {
21
21
  symbols: SymbolInfo[];
22
22
  language: string | null;
23
23
  languageName: string;
24
+ hasError?: boolean;
24
25
  }
25
26
 
26
27
  /**
@@ -38,8 +39,8 @@ export async function parseSourceFile(
38
39
  }
39
40
 
40
41
  try {
41
- const symbols = await parseCode(lang, content);
42
- return { symbols, language: lang, languageName: languageLabel(lang) };
42
+ const { symbols, hasError } = await parseCode(lang, content);
43
+ return { symbols, language: lang, languageName: languageLabel(lang), hasError };
43
44
  } catch (err) {
44
45
  console.warn(
45
46
  `[codebase-reader] Parse failed for ${filePath}:`,