diffing 0.16.1 → 0.17.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.
@@ -25,6 +25,13 @@ const EXIT_AWAIT_TIMEOUT = 2;
25
25
  const EXIT_NO_SERVER = 3;
26
26
  const EXIT_NOT_FOUND = 4;
27
27
  const EXIT_USAGE = 5;
28
+ function validateInspectSelectors(resource, file, path) {
29
+ const hasFile = file != null && file !== "";
30
+ const hasPath = path != null && path !== "";
31
+ if ((resource === "hunks" || resource === "slice") && hasFile && hasPath) return `diffing inspect ${resource}: --path and --file are mutually exclusive`;
32
+ if ((resource === "hunks" || resource === "slice") && !hasFile && !hasPath) return `diffing inspect ${resource}: --file or --path is required`;
33
+ return null;
34
+ }
28
35
  let activeCapability;
29
36
  let activeAuthToken;
30
37
  /** Resolve the running server's base URL from the lockfile, or exit cleanly. */
@@ -880,6 +887,8 @@ async function inspect(args) {
880
887
  cursor: { type: "string" },
881
888
  limit: { type: "string" },
882
889
  file: { type: "string" },
890
+ path: { type: "string" },
891
+ exclude: { type: "string" },
883
892
  generation: { type: "string" },
884
893
  start: { type: "string" },
885
894
  row: { type: "string" },
@@ -906,12 +915,13 @@ async function inspect(args) {
906
915
  console.error(`Usage: diffing inspect <summary|files|hunks|slice|search> [options]
907
916
 
908
917
  Read bounded data from a running session (web, TUI, or gh-pr) without transferring the full patch.
909
- summary
910
- files [--cursor N] [--limit N]
911
- hunks --file N [--cursor N] [--limit N] [--generation N]
912
- slice --file N [--start N] [--max-lines N] [--max-bytes N] [--generation N]
913
- search <text>|--query <text> [--file N] [--row N] [--limit N] [--max-bytes N] [--generation N]
918
+ summary [--exclude lockfiles]
919
+ files [--path GLOB] [--cursor N] [--limit N]
920
+ hunks (--file N | --path GLOB) [--cursor N] [--limit N] [--generation N]
921
+ slice (--file N | --path GLOB) [--start N] [--max-lines N] [--max-bytes N] [--generation N]
922
+ search <text>|--query <text> [--path GLOB] [--file N] [--row N] [--limit N] [--max-bytes N] [--generation N]
914
923
 
924
+ Filtered files nextCursor is an index into the filtered list, not a global file index.
915
925
  Add --pretty for indented JSON. Compact JSON is the token-efficient default.`);
916
926
  return parsed.values.help ? EXIT_OK : EXIT_USAGE;
917
927
  }
@@ -945,8 +955,13 @@ Add --pretty for indented JSON. Compact JSON is the token-efficient default.`);
945
955
  }
946
956
  params.set(parameter, value);
947
957
  }
948
- if ((resource === "hunks" || resource === "slice") && !params.has("file")) {
949
- console.error(`diffing inspect ${resource}: --file is required`);
958
+ const path = parsed.values.path;
959
+ if (typeof path === "string") params.set("path", path);
960
+ const exclude = parsed.values.exclude;
961
+ if (typeof exclude === "string") params.set("exclude", exclude);
962
+ const selectorError = validateInspectSelectors(resource, typeof parsed.values.file === "string" ? parsed.values.file : void 0, typeof path === "string" ? path : void 0);
963
+ if (selectorError) {
964
+ console.error(selectorError);
950
965
  return EXIT_USAGE;
951
966
  }
952
967
  if (resource === "search") {
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as buildTuiGitDiffArgs, c as printHelp, n as runTerminalDiff, o as intoShowMode, r as validateEnvironment, s as parseDiffOptions, t as startServer } from "./server-Ct9QuV1l.mjs";
2
+ import { a as buildTuiGitDiffArgs, c as printHelp, n as runTerminalDiff, o as intoShowMode, r as validateEnvironment, s as parseDiffOptions, t as startServer } from "./server-CEbp7K-k.mjs";
3
3
  import { h as getRepoRoot, r as getBranchName } from "./git-BGj31GX-.mjs";
4
4
  import { i as loadSettings } from "./settings-DkPDB_hS.mjs";
5
5
  import { _ as generateSessionToken, b as isWildcardBindHost, c as removeServerLockIfOwned, p as writeServerLock, r as diffScopeKey, t as acquireServerStartupLease, u as resolveActiveServerLock } from "./server-lock-CdaUNZyX.mjs";
@@ -561,11 +561,11 @@ See docs/cli.md §5 (MCP) for the full tool table.`);
561
561
  console.error("diffing mcp: --repo must be an absolute path");
562
562
  process.exit(5);
563
563
  }
564
- const { startMcpServer } = await import("./mcp-BgrRjzn3.mjs");
564
+ const { startMcpServer } = await import("./mcp-DhynPewa.mjs");
565
565
  await startMcpServer({ repoPath });
566
566
  await new Promise(() => {});
567
567
  }
568
- const { runSubcommand } = await import("./cli-agent-Dk0yRtrA.mjs");
568
+ const { runSubcommand } = await import("./cli-agent-qNwkSjxK.mjs");
569
569
  const code = await runSubcommand(args[0], args.slice(1));
570
570
  process.exit(code);
571
571
  }
@@ -1,4 +1,4 @@
1
- import { i as buildGitDiffArgs, s as parseDiffOptions, t as startServer } from "./server-Ct9QuV1l.mjs";
1
+ import { i as buildGitDiffArgs, s as parseDiffOptions, t as startServer } from "./server-CEbp7K-k.mjs";
2
2
  import { t as formatComments } from "./comment-format-B85NHZnG.mjs";
3
3
  import { n as formatPlanReview } from "./plan-format-BwJh5JSE.mjs";
4
4
  import { _ as generateSessionToken, c as removeServerLockIfOwned, i as isLockAlive, p as writeServerLock, r as diffScopeKey, s as removeServerLock, t as acquireServerStartupLease, u as resolveActiveServerLock, x as SESSION_TOKEN_HEADER, y as isLoopbackHost } from "./server-lock-CdaUNZyX.mjs";
@@ -618,54 +618,67 @@ function createMcpServer(options) {
618
618
  });
619
619
  server.registerTool("diff_summary", {
620
620
  title: "Summarize the active diff (bounded)",
621
- description: "Return bounded totals and change-kind counts without transferring the patch. Works for web, TUI, and GitHub PR sessions. Prefer this over get_diff.",
622
- inputSchema: {},
621
+ description: "Return bounded totals, top-level directory buckets, and change-kind counts without transferring the patch. Optional exclude=[\"lockfiles\"] drops lock/generated basenames from counts only. Works for web, TUI, and GitHub PR sessions. Prefer this over get_diff.",
622
+ inputSchema: { exclude: z.array(z.enum(["lockfiles"])).optional() },
623
623
  outputSchema: { result: z.unknown() },
624
624
  annotations: READ_ONLY
625
- }, async () => {
626
- const result = await requestSessionJson(requireInspectSession(), "/api/diff/summary");
625
+ }, async ({ exclude } = {}) => {
626
+ const session = requireInspectSession();
627
+ const query = new URLSearchParams();
628
+ if (exclude?.length) query.set("exclude", exclude.join(","));
629
+ const result = await requestSessionJson(session, `/api/diff/summary${query.size ? `?${query}` : ""}`);
627
630
  return textResult(JSON.stringify(result), { result });
628
631
  });
629
632
  server.registerTool("diff_files", {
630
633
  title: "Page changed files (bounded)",
631
- description: "Return a bounded page of changed-file metadata with an opaque numeric continuation cursor. Works for web, TUI, and GitHub PR sessions.",
634
+ description: "Return a bounded page of changed-file metadata. Optional path is a git pathspec-ish glob (src/lib/**, **/foo.ts). cursor/nextCursor index the filtered list; each row still has the global file index. Works for web, TUI, and GitHub PR sessions.",
632
635
  inputSchema: {
633
636
  cursor: z.number().int().nonnegative().optional(),
634
- limit: z.number().int().positive().max(1e3).optional()
637
+ limit: z.number().int().positive().max(1e3).optional(),
638
+ path: z.string().min(1).optional()
635
639
  },
636
640
  outputSchema: { result: z.unknown() },
637
641
  annotations: READ_ONLY
638
- }, async ({ cursor = 0, limit = 100 }) => {
639
- const result = await requestSessionJson(requireInspectSession(), `/api/diff/files?cursor=${cursor}&limit=${limit}`);
642
+ }, async ({ cursor = 0, limit = 100, path }) => {
643
+ const session = requireInspectSession();
644
+ const query = new URLSearchParams({
645
+ cursor: String(cursor),
646
+ limit: String(limit)
647
+ });
648
+ if (path) query.set("path", path);
649
+ const result = await requestSessionJson(session, `/api/diff/files?${query}`);
640
650
  return textResult(JSON.stringify(result), { result });
641
651
  });
642
652
  server.registerTool("diff_hunks", {
643
653
  title: "Page hunk metadata (bounded)",
644
- description: "Return bounded hunk metadata for one file index. Pass generation from diff_summary to reject stale navigation. Works for web, TUI, and GitHub PR sessions.",
654
+ description: "Return bounded hunk metadata for one file. Pass path (glob resolving to exactly one file) or file (global index), not both. Pass generation from diff_summary to reject stale navigation. Works for web, TUI, and GitHub PR sessions.",
645
655
  inputSchema: {
646
- file: z.number().int().nonnegative(),
656
+ file: z.number().int().nonnegative().optional(),
657
+ path: z.string().min(1).optional(),
647
658
  generation: z.number().int().nonnegative().optional(),
648
659
  cursor: z.number().int().nonnegative().optional(),
649
660
  limit: z.number().int().positive().max(1e3).optional()
650
661
  },
651
662
  outputSchema: { result: z.unknown() },
652
663
  annotations: READ_ONLY
653
- }, async ({ file, generation, cursor = 0, limit = 100 }) => {
664
+ }, async ({ file, path, generation, cursor = 0, limit = 100 }) => {
654
665
  const session = requireInspectSession();
655
666
  const query = new URLSearchParams({
656
- file: String(file),
657
667
  cursor: String(cursor),
658
668
  limit: String(limit)
659
669
  });
670
+ if (file !== void 0) query.set("file", String(file));
671
+ if (path) query.set("path", path);
660
672
  if (generation !== void 0) query.set("generation", String(generation));
661
673
  const result = await requestSessionJson(session, `/api/diff/hunks?${query}`);
662
674
  return textResult(JSON.stringify(result), { result });
663
675
  });
664
676
  server.registerTool("diff_slice", {
665
677
  title: "Read a bounded diff slice",
666
- description: "Read exact logical rows for one file with strict line and byte budgets; use nextRow to continue. Works for web, TUI, and GitHub PR sessions. Prefer this over get_diff.",
678
+ description: "Read exact logical rows for one file with strict line and byte budgets; use nextRow to continue. Pass path (glob resolving to exactly one file) or file (global index), not both. Works for web, TUI, and GitHub PR sessions. Prefer this over get_diff.",
667
679
  inputSchema: {
668
- file: z.number().int().nonnegative(),
680
+ file: z.number().int().nonnegative().optional(),
681
+ path: z.string().min(1).optional(),
669
682
  start: z.number().int().nonnegative().optional(),
670
683
  generation: z.number().int().nonnegative().optional(),
671
684
  maxLines: z.number().int().positive().max(1e3).optional(),
@@ -673,23 +686,25 @@ function createMcpServer(options) {
673
686
  },
674
687
  outputSchema: { result: z.unknown() },
675
688
  annotations: READ_ONLY
676
- }, async ({ file, start = 0, generation, maxLines = 120, maxBytes = 256 * 1024 }) => {
689
+ }, async ({ file, path, start = 0, generation, maxLines = 120, maxBytes = 256 * 1024 }) => {
677
690
  const session = requireInspectSession();
678
691
  const query = new URLSearchParams({
679
- file: String(file),
680
692
  start: String(start),
681
693
  maxLines: String(maxLines),
682
694
  maxBytes: String(maxBytes)
683
695
  });
696
+ if (file !== void 0) query.set("file", String(file));
697
+ if (path) query.set("path", path);
684
698
  if (generation !== void 0) query.set("generation", String(generation));
685
699
  const result = await requestSessionJson(session, `/api/diff/slice?${query}`);
686
700
  return textResult(JSON.stringify(result), { result });
687
701
  });
688
702
  server.registerTool("diff_search", {
689
703
  title: "Search the active diff (bounded)",
690
- description: "Search changed paths and content with bounded hits/bytes and generation-safe continuation coordinates. Works for web, TUI, and GitHub PR sessions.",
704
+ description: "Search changed paths and content with bounded hits/bytes and generation-safe continuation coordinates. Optional path glob limits hits to matching files (in addition to file+row continuation). Works for web, TUI, and GitHub PR sessions.",
691
705
  inputSchema: {
692
706
  query: z.string().min(1),
707
+ path: z.string().min(1).optional(),
693
708
  generation: z.number().int().nonnegative().optional(),
694
709
  file: z.number().int().nonnegative().optional(),
695
710
  row: z.number().int().nonnegative().optional(),
@@ -698,7 +713,7 @@ function createMcpServer(options) {
698
713
  },
699
714
  outputSchema: { result: z.unknown() },
700
715
  annotations: READ_ONLY
701
- }, async ({ query, generation, file = 0, row = 0, limit = 100, maxBytes = 256 * 1024 }) => {
716
+ }, async ({ query, path, generation, file = 0, row = 0, limit = 100, maxBytes = 256 * 1024 }) => {
702
717
  const session = requireInspectSession();
703
718
  const params = new URLSearchParams({
704
719
  q: query,
@@ -707,6 +722,7 @@ function createMcpServer(options) {
707
722
  limit: String(limit),
708
723
  maxBytes: String(maxBytes)
709
724
  });
725
+ if (path) params.set("path", path);
710
726
  if (generation !== void 0) params.set("generation", String(generation));
711
727
  const result = await requestSessionJson(session, `/api/diff/search?${params}`);
712
728
  return textResult(JSON.stringify(result), { result });
@@ -2201,6 +2201,113 @@ var FilePrSessionStore = class {
2201
2201
  } catch {}
2202
2202
  }
2203
2203
  };
2204
+ const PATH_MATCH_CAP = 20;
2205
+ function compilePathspecGlob(pattern) {
2206
+ const source = pattern.trim();
2207
+ if (!source) return {
2208
+ error: "invalid path glob: empty pattern",
2209
+ status: 400,
2210
+ path: pattern
2211
+ };
2212
+ try {
2213
+ return {
2214
+ source,
2215
+ test: compileGlobTester(source)
2216
+ };
2217
+ } catch (error) {
2218
+ return {
2219
+ error: error instanceof Error ? error.message : "invalid path glob",
2220
+ status: 400,
2221
+ path: pattern
2222
+ };
2223
+ }
2224
+ }
2225
+ function fileMatchesPath(matcher, oldPath, newPath) {
2226
+ if (newPath && matcher.test(newPath)) return true;
2227
+ if (oldPath && matcher.test(oldPath)) return true;
2228
+ return false;
2229
+ }
2230
+ function parseExcludeList(raw) {
2231
+ if (raw == null) return [];
2232
+ const values = (Array.isArray(raw) ? raw : raw.split(",")).map((value) => value.trim()).filter(Boolean);
2233
+ for (const value of values) if (value !== "lockfiles") return {
2234
+ error: `unknown exclude: ${value}`,
2235
+ status: 400
2236
+ };
2237
+ return [...new Set(values)];
2238
+ }
2239
+ function isLockfileNoise(path) {
2240
+ const base = path.split("/").pop() ?? path;
2241
+ if (base === "package-lock.json" || base === "pnpm-lock.yaml") return true;
2242
+ if (base.endsWith(".lock")) return true;
2243
+ if (base.endsWith(".min.js")) return true;
2244
+ return false;
2245
+ }
2246
+ function displayPath(oldPath, newPath) {
2247
+ return newPath ?? oldPath ?? "";
2248
+ }
2249
+ function capPathMatches(matches) {
2250
+ return matches.slice(0, PATH_MATCH_CAP);
2251
+ }
2252
+ function compileGlobTester(pattern) {
2253
+ const parts = (pattern.includes("/") ? pattern : `**/${pattern}`).split("/");
2254
+ for (const part of parts) {
2255
+ if (part !== "**" && part.includes("**")) throw new Error(`invalid path glob: ${pattern}`);
2256
+ validateGlobSegment(part, pattern);
2257
+ }
2258
+ return (path) => matchParts(parts, path.split("/"));
2259
+ }
2260
+ function matchParts(pat, path) {
2261
+ if (pat.length === 0) return path.length === 0;
2262
+ if (pat[0] === "**") return matchParts(pat.slice(1), path) || path.length > 0 && matchParts(pat, path.slice(1));
2263
+ if (path.length === 0) return false;
2264
+ return globSegment(pat[0], path[0]) && matchParts(pat.slice(1), path.slice(1));
2265
+ }
2266
+ function validateGlobSegment(segment, pattern) {
2267
+ for (let i = 0; i < segment.length; i++) {
2268
+ if (segment[i] === "[") {
2269
+ const close = segment.indexOf("]", i + 1);
2270
+ if (close < 0 || close === i + 1) throw new Error(`invalid path glob: ${pattern}`);
2271
+ i = close;
2272
+ continue;
2273
+ }
2274
+ if (segment[i] === "\\") {
2275
+ if (segment[i + 1] == null) throw new Error(`invalid path glob: ${pattern}`);
2276
+ i++;
2277
+ }
2278
+ }
2279
+ }
2280
+ function globSegment(pattern, value) {
2281
+ return globChars([...pattern], [...value]);
2282
+ }
2283
+ function globChars(pattern, value) {
2284
+ if (pattern.length === 0) return value.length === 0;
2285
+ const [head, ...rest] = pattern;
2286
+ if (head === "*") return globChars(rest, value) || value.length > 0 && globChars(pattern, value.slice(1));
2287
+ if (head === "?") return value.length > 0 && globChars(rest, value.slice(1));
2288
+ if (head === "[") {
2289
+ const close = rest.indexOf("]");
2290
+ if (close < 0 || value.length === 0) return false;
2291
+ const classBody = rest.slice(0, close);
2292
+ const negated = classBody[0] === "!" || classBody[0] === "^";
2293
+ if (classMatches(negated ? classBody.slice(1) : classBody, value[0]) === negated) return false;
2294
+ return globChars(rest.slice(close + 1), value.slice(1));
2295
+ }
2296
+ if (head === "\\") return rest.length > 0 && value.length > 0 && rest[0] === value[0] && globChars(rest.slice(1), value.slice(1));
2297
+ return value.length > 0 && head === value[0] && globChars(rest, value.slice(1));
2298
+ }
2299
+ function classMatches(body, value) {
2300
+ for (let i = 0; i < body.length;) {
2301
+ if (i + 2 < body.length && body[i + 1] === "-") {
2302
+ if (body[i] <= value && value <= body[i + 2]) return true;
2303
+ i += 3;
2304
+ continue;
2305
+ }
2306
+ if (body[i] === value) return true;
2307
+ i++;
2308
+ }
2309
+ return false;
2310
+ }
2204
2311
  //#endregion
2205
2312
  //#region src/lib/agent-diff-index.ts
2206
2313
  /**
@@ -2429,33 +2536,140 @@ function buildAgentDiffIndex(patch, generation = nextGeneration++) {
2429
2536
  patchBytes
2430
2537
  };
2431
2538
  }
2432
- function indexSummary(index) {
2539
+ const DIRECTORY_CAP = 20;
2540
+ function indexSummary(index, excludeRaw) {
2541
+ const exclude = parseExcludeList(excludeRaw);
2542
+ if ("error" in exclude) return exclude;
2543
+ const counted = exclude.includes("lockfiles") ? index.files.filter((file) => !isLockfileNoise(displayPath(file.oldPath, file.newPath))) : index.files;
2433
2544
  const changes = {};
2434
- for (const file of index.files) changes[file.kind] = (changes[file.kind] ?? 0) + 1;
2435
- return {
2545
+ let hunks = 0;
2546
+ let rows = 0;
2547
+ let additions = 0;
2548
+ let deletions = 0;
2549
+ for (const file of counted) {
2550
+ changes[file.kind] = (changes[file.kind] ?? 0) + 1;
2551
+ hunks += file.hunks.length;
2552
+ rows += file.rowCount;
2553
+ additions += file.additions;
2554
+ deletions += file.deletions;
2555
+ }
2556
+ const summary = {
2436
2557
  generation: index.generation,
2437
2558
  complete: index.complete,
2438
- files: index.files.length,
2439
- hunks: index.totalHunks,
2440
- rows: index.totalRows,
2441
- additions: index.additions,
2442
- deletions: index.deletions,
2559
+ files: counted.length,
2560
+ hunks,
2561
+ rows,
2562
+ additions,
2563
+ deletions,
2443
2564
  patchBytes: index.patchBytes,
2444
2565
  changes,
2566
+ directories: summarizeDirectories(counted),
2445
2567
  next: [
2446
2568
  "diff_files",
2447
2569
  "diff_search",
2448
2570
  "diff_slice"
2449
2571
  ]
2450
2572
  };
2573
+ if (exclude.length > 0) summary.exclude = exclude;
2574
+ return summary;
2575
+ }
2576
+ function summarizeDirectories(files) {
2577
+ const buckets = /* @__PURE__ */ new Map();
2578
+ for (const file of files) {
2579
+ const path = displayPath(file.oldPath, file.newPath);
2580
+ const slash = path.indexOf("/");
2581
+ const dir = slash < 0 ? "." : path.slice(0, slash);
2582
+ const bucket = buckets.get(dir) ?? {
2583
+ path: dir,
2584
+ files: 0,
2585
+ hunks: 0,
2586
+ additions: 0,
2587
+ deletions: 0
2588
+ };
2589
+ bucket.files += 1;
2590
+ bucket.hunks += file.hunks.length;
2591
+ bucket.additions += file.additions;
2592
+ bucket.deletions += file.deletions;
2593
+ buckets.set(dir, bucket);
2594
+ }
2595
+ const ranked = [...buckets.values()].sort((a, b) => {
2596
+ if (b.files !== a.files) return b.files - a.files;
2597
+ return b.additions + b.deletions - (a.additions + a.deletions);
2598
+ });
2599
+ if (ranked.length <= DIRECTORY_CAP) return ranked;
2600
+ const head = ranked.slice(0, DIRECTORY_CAP);
2601
+ const rest = ranked.slice(DIRECTORY_CAP);
2602
+ const other = {
2603
+ path: "+other",
2604
+ files: 0,
2605
+ hunks: 0,
2606
+ additions: 0,
2607
+ deletions: 0
2608
+ };
2609
+ for (const bucket of rest) {
2610
+ other.files += bucket.files;
2611
+ other.hunks += bucket.hunks;
2612
+ other.additions += bucket.additions;
2613
+ other.deletions += bucket.deletions;
2614
+ }
2615
+ head.push(other);
2616
+ return head;
2617
+ }
2618
+ function scopedFiles(index, path) {
2619
+ if (path == null || path === "") return { entries: index.files.map((file, i) => ({
2620
+ index: i,
2621
+ file
2622
+ })) };
2623
+ const matcher = compilePathspecGlob(path);
2624
+ if ("error" in matcher) return matcher;
2625
+ return {
2626
+ matcher,
2627
+ entries: index.files.flatMap((file, i) => fileMatchesPath(matcher, file.oldPath, file.newPath) ? [{
2628
+ index: i,
2629
+ file
2630
+ }] : [])
2631
+ };
2632
+ }
2633
+ function resolveInspectFile(index, file, path) {
2634
+ const hasFile = file != null;
2635
+ const hasPath = path != null && path !== "";
2636
+ if (hasFile && hasPath) return {
2637
+ error: "path and file are mutually exclusive",
2638
+ status: 400,
2639
+ path
2640
+ };
2641
+ if (!hasFile && !hasPath) return {
2642
+ error: "file or path is required",
2643
+ status: 400
2644
+ };
2645
+ if (hasFile) return { fileIndex: file };
2646
+ const scoped = scopedFiles(index, path);
2647
+ if ("error" in scoped) return scoped;
2648
+ if (scoped.entries.length === 0) return {
2649
+ error: "path matched no files",
2650
+ status: 404,
2651
+ path
2652
+ };
2653
+ if (scoped.entries.length > 1) return {
2654
+ error: "path matched multiple files; narrow the glob or pass file",
2655
+ status: 409,
2656
+ path,
2657
+ matches: capPathMatches(scoped.entries.map(({ index, file }) => ({
2658
+ index,
2659
+ path: displayPath(file.oldPath, file.newPath)
2660
+ })))
2661
+ };
2662
+ return { fileIndex: scoped.entries[0].index };
2451
2663
  }
2452
- function indexFiles(index, cursor = 0, limit = 100) {
2664
+ function indexFiles(index, cursor = 0, limit = 100, path) {
2665
+ const scoped = scopedFiles(index, path);
2666
+ if ("error" in scoped) return scoped;
2453
2667
  const safeLimit = clamp(limit, 1, MAX_PAGE_LINES);
2454
2668
  const start = Math.max(0, cursor);
2455
- const end = Math.min(index.files.length, start + safeLimit);
2456
- const files = index.files.slice(start, end).map((file, offset) => ({
2457
- index: start + offset,
2458
- path: file.newPath ?? file.oldPath ?? "",
2669
+ const end = Math.min(scoped.entries.length, start + safeLimit);
2670
+ const files = scoped.entries.slice(start, end).map(({ index, file }) => ({
2671
+ index,
2672
+ path: displayPath(file.oldPath, file.newPath),
2459
2673
  oldPath: file.oldPath,
2460
2674
  newPath: file.newPath,
2461
2675
  kind: file.kind,
@@ -2465,13 +2679,16 @@ function indexFiles(index, cursor = 0, limit = 100) {
2465
2679
  additions: file.additions,
2466
2680
  deletions: file.deletions
2467
2681
  }));
2468
- return {
2682
+ const page = {
2469
2683
  generation: index.generation,
2470
2684
  returned: files.length,
2471
2685
  total: index.files.length,
2472
- nextCursor: end < index.files.length ? end : null,
2686
+ matched: scoped.entries.length,
2687
+ nextCursor: end < scoped.entries.length ? end : null,
2473
2688
  files
2474
2689
  };
2690
+ if (path) page.path = path;
2691
+ return page;
2475
2692
  }
2476
2693
  function indexHunks(index, fileIndex, cursor = 0, limit = 100, generation) {
2477
2694
  if (generation !== void 0 && generation !== index.generation) return {
@@ -2542,11 +2759,17 @@ function indexSlice(index, fileIndex, startRow = 0, maxLines = DEFAULT_SLICE_LIN
2542
2759
  rows
2543
2760
  };
2544
2761
  }
2545
- function indexSearch(index, query, fileStart = 0, rowStart = 0, limit = 100, maxBytes = DEFAULT_MAX_BYTES, generation) {
2762
+ function indexSearch(index, query, fileStart = 0, rowStart = 0, limit = 100, maxBytes = DEFAULT_MAX_BYTES, generation, path) {
2546
2763
  if (generation !== void 0 && generation !== index.generation) return {
2547
2764
  error: `stale generation ${generation}; current generation is ${index.generation}`,
2548
2765
  status: 409
2549
2766
  };
2767
+ let matcher;
2768
+ if (path) {
2769
+ const compiled = compilePathspecGlob(path);
2770
+ if ("error" in compiled) return compiled;
2771
+ matcher = compiled;
2772
+ }
2550
2773
  const q = query.toLowerCase();
2551
2774
  if (!q) return {
2552
2775
  generation: index.generation,
@@ -2565,6 +2788,7 @@ function indexSearch(index, query, fileStart = 0, rowStart = 0, limit = 100, max
2565
2788
  let nextRow = null;
2566
2789
  outer: for (let fi = Math.max(0, fileStart); fi < index.files.length; fi++) {
2567
2790
  const file = index.files[fi];
2791
+ if (matcher && !fileMatchesPath(matcher, file.oldPath, file.newPath)) continue;
2568
2792
  const path = file.newPath ?? file.oldPath ?? "";
2569
2793
  const pathMatch = path.toLowerCase().includes(q);
2570
2794
  const rowBegin = fi === fileStart ? Math.max(0, rowStart) : 0;
@@ -3169,9 +3393,21 @@ function createApp(clientDir, diffOptsInput = DEFAULTS, commentStore, planStore,
3169
3393
  const n = Number(value);
3170
3394
  return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : fallback;
3171
3395
  }
3396
+ function optionalUInt(value) {
3397
+ if (value == null || value === "") return void 0;
3398
+ const n = Number(value);
3399
+ return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : void 0;
3400
+ }
3401
+ function inspectError(c, result) {
3402
+ const body = { error: result.error };
3403
+ if (result.path != null) body.path = result.path;
3404
+ if (result.matches != null) body.matches = result.matches;
3405
+ return c.json(body, result.status);
3406
+ }
3172
3407
  app.get("/api/diff/summary", async (c) => {
3173
3408
  const patch = await resolveAgentPatch();
3174
- const summary = indexSummary(agentDiffCache.getOrBuild(patch));
3409
+ const summary = indexSummary(agentDiffCache.getOrBuild(patch), c.req.query("exclude"));
3410
+ if ("status" in summary) return inspectError(c, summary);
3175
3411
  if (!prMode) return c.json(summary);
3176
3412
  const session = await prStore.get();
3177
3413
  if (!session) return c.json(summary);
@@ -3189,32 +3425,33 @@ function createApp(clientDir, diffOptsInput = DEFAULTS, commentStore, planStore,
3189
3425
  });
3190
3426
  app.get("/api/diff/files", async (c) => {
3191
3427
  const patch = await resolveAgentPatch();
3192
- const index = agentDiffCache.getOrBuild(patch);
3193
- const cursor = parseUInt(c.req.query("cursor"), 0);
3194
- const limit = parseUInt(c.req.query("limit"), 100);
3195
- return c.json(indexFiles(index, cursor, limit));
3428
+ const result = indexFiles(agentDiffCache.getOrBuild(patch), parseUInt(c.req.query("cursor"), 0), parseUInt(c.req.query("limit"), 100), c.req.query("path"));
3429
+ if ("status" in result) return inspectError(c, result);
3430
+ return c.json(result);
3196
3431
  });
3197
3432
  app.get("/api/diff/hunks", async (c) => {
3198
3433
  const patch = await resolveAgentPatch();
3199
3434
  const index = agentDiffCache.getOrBuild(patch);
3200
- const file = parseUInt(c.req.query("file"), 0);
3435
+ const resolved = resolveInspectFile(index, optionalUInt(c.req.query("file")), c.req.query("path"));
3436
+ if ("status" in resolved) return inspectError(c, resolved);
3201
3437
  const cursor = parseUInt(c.req.query("cursor"), 0);
3202
3438
  const limit = parseUInt(c.req.query("limit"), 100);
3203
3439
  const generationRaw = c.req.query("generation");
3204
- const result = indexHunks(index, file, cursor, limit, generationRaw != null && generationRaw !== "" ? parseUInt(generationRaw, 0) : void 0);
3205
- if ("status" in result) return c.json({ error: result.error }, result.status);
3440
+ const result = indexHunks(index, resolved.fileIndex, cursor, limit, generationRaw != null && generationRaw !== "" ? parseUInt(generationRaw, 0) : void 0);
3441
+ if ("status" in result) return inspectError(c, result);
3206
3442
  return c.json(result);
3207
3443
  });
3208
3444
  app.get("/api/diff/slice", async (c) => {
3209
3445
  const patch = await resolveAgentPatch();
3210
3446
  const index = agentDiffCache.getOrBuild(patch);
3211
- const file = parseUInt(c.req.query("file"), 0);
3447
+ const resolved = resolveInspectFile(index, optionalUInt(c.req.query("file")), c.req.query("path"));
3448
+ if ("status" in resolved) return inspectError(c, resolved);
3212
3449
  const start = parseUInt(c.req.query("start"), 0);
3213
3450
  const maxLines = parseUInt(c.req.query("maxLines"), 120);
3214
3451
  const maxBytes = parseUInt(c.req.query("maxBytes"), 256 * 1024);
3215
3452
  const generationRaw = c.req.query("generation");
3216
- const result = indexSlice(index, file, start, maxLines, maxBytes, generationRaw != null && generationRaw !== "" ? parseUInt(generationRaw, 0) : void 0);
3217
- if ("status" in result) return c.json({ error: result.error }, result.status);
3453
+ const result = indexSlice(index, resolved.fileIndex, start, maxLines, maxBytes, generationRaw != null && generationRaw !== "" ? parseUInt(generationRaw, 0) : void 0);
3454
+ if ("status" in result) return inspectError(c, result);
3218
3455
  return c.json(result);
3219
3456
  });
3220
3457
  app.get("/api/diff/search", async (c) => {
@@ -3226,8 +3463,8 @@ function createApp(clientDir, diffOptsInput = DEFAULTS, commentStore, planStore,
3226
3463
  const limit = parseUInt(c.req.query("limit"), 100);
3227
3464
  const maxBytes = parseUInt(c.req.query("maxBytes"), 256 * 1024);
3228
3465
  const generationRaw = c.req.query("generation");
3229
- const result = indexSearch(index, q, file, row, limit, maxBytes, generationRaw != null && generationRaw !== "" ? parseUInt(generationRaw, 0) : void 0);
3230
- if ("status" in result) return c.json({ error: result.error }, result.status);
3466
+ const result = indexSearch(index, q, file, row, limit, maxBytes, generationRaw != null && generationRaw !== "" ? parseUInt(generationRaw, 0) : void 0, c.req.query("path"));
3467
+ if ("status" in result) return inspectError(c, result);
3231
3468
  return c.json(result);
3232
3469
  });
3233
3470
  const resolveFileVersion = async (path, version) => {
@@ -45,6 +45,7 @@ const SKILL_NAMES = [
45
45
  "diffing-plan-review",
46
46
  "diffing-pr-address",
47
47
  "diffing-pr-read",
48
+ "diffing-release",
48
49
  "diffing-review",
49
50
  "diffing-start-review",
50
51
  ] as const;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffing",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "local-first CLI for reviewing, navigating, and discussing git diffs with AI",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,6 +56,7 @@
56
56
  "verify:release-bundle": "node scripts/verify-tui-bundle.mjs",
57
57
  "prepublishOnly": "pnpm run verify:release-bundle",
58
58
  "version-packages": "changeset version",
59
+ "release": "node scripts/release.mjs",
59
60
  "docs:dev": "pnpm --dir site dev",
60
61
  "docs:build": "pnpm --dir site build",
61
62
  "docs:preview": "pnpm --dir site preview"