@oh-my-pi/pi-coding-agent 15.10.0 → 15.10.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.
Files changed (176) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  3. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  4. package/dist/types/commit/analysis/summary.d.ts +2 -2
  5. package/dist/types/commit/changelog/generate.d.ts +2 -2
  6. package/dist/types/commit/changelog/index.d.ts +2 -2
  7. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  8. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  9. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  10. package/dist/types/commit/model-selection.d.ts +10 -4
  11. package/dist/types/config/api-key-resolver.d.ts +34 -0
  12. package/dist/types/config/model-registry.d.ts +17 -1
  13. package/dist/types/config/settings-schema.d.ts +9 -0
  14. package/dist/types/dap/config.d.ts +14 -1
  15. package/dist/types/dap/types.d.ts +10 -0
  16. package/dist/types/lsp/utils.d.ts +3 -2
  17. package/dist/types/modes/components/chat-block.d.ts +64 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  19. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  20. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  21. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  22. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  23. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  24. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  25. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  27. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  28. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  29. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  30. package/dist/types/modes/interactive-mode.d.ts +15 -5
  31. package/dist/types/modes/theme/theme.d.ts +1 -1
  32. package/dist/types/modes/types.d.ts +18 -5
  33. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  34. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  35. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  36. package/dist/types/sdk.d.ts +2 -0
  37. package/dist/types/session/agent-session.d.ts +21 -0
  38. package/dist/types/session/messages.d.ts +12 -0
  39. package/dist/types/session/session-manager.d.ts +3 -1
  40. package/dist/types/slash-commands/types.d.ts +4 -6
  41. package/dist/types/task/executor.d.ts +7 -0
  42. package/dist/types/task/index.d.ts +1 -0
  43. package/dist/types/task/render.d.ts +3 -2
  44. package/dist/types/tools/archive-reader.d.ts +5 -0
  45. package/dist/types/tools/ast-edit.d.ts +3 -0
  46. package/dist/types/tools/ast-grep.d.ts +3 -0
  47. package/dist/types/tools/bash.d.ts +1 -0
  48. package/dist/types/tools/find.d.ts +8 -4
  49. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  50. package/dist/types/tools/memory-render.d.ts +4 -1
  51. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  52. package/dist/types/tools/render-utils.d.ts +5 -9
  53. package/dist/types/tools/search.d.ts +4 -0
  54. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  55. package/dist/types/tools/todo.d.ts +3 -2
  56. package/dist/types/tools/write.d.ts +3 -0
  57. package/dist/types/tui/output-block.d.ts +16 -4
  58. package/dist/types/tui/status-line.d.ts +3 -0
  59. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  60. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  61. package/package.json +9 -9
  62. package/src/auto-thinking/classifier.ts +5 -1
  63. package/src/cli/dry-balance-cli.ts +52 -17
  64. package/src/cli/gallery-cli.ts +4 -1
  65. package/src/cli/gallery-fixtures/misc.ts +29 -0
  66. package/src/commit/analysis/conventional.ts +2 -2
  67. package/src/commit/analysis/summary.ts +2 -2
  68. package/src/commit/changelog/generate.ts +2 -2
  69. package/src/commit/changelog/index.ts +2 -2
  70. package/src/commit/map-reduce/index.ts +3 -3
  71. package/src/commit/map-reduce/map-phase.ts +2 -2
  72. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  73. package/src/commit/model-selection.ts +33 -9
  74. package/src/commit/pipeline.ts +4 -4
  75. package/src/config/api-key-resolver.ts +58 -0
  76. package/src/config/model-registry.ts +25 -2
  77. package/src/config/settings-schema.ts +10 -0
  78. package/src/config/settings.ts +20 -2
  79. package/src/dap/config.ts +41 -2
  80. package/src/dap/defaults.json +1 -0
  81. package/src/dap/session.ts +1 -0
  82. package/src/dap/types.ts +10 -0
  83. package/src/debug/index.ts +40 -54
  84. package/src/edit/renderer.ts +82 -78
  85. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  86. package/src/eval/llm-bridge.ts +8 -3
  87. package/src/goals/tools/goal-tool.ts +36 -26
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/lsp/utils.ts +3 -2
  90. package/src/main.ts +9 -7
  91. package/src/memories/index.ts +12 -5
  92. package/src/mnemopi/backend.ts +5 -1
  93. package/src/modes/acp/acp-agent.ts +33 -26
  94. package/src/modes/components/assistant-message.ts +2 -9
  95. package/src/modes/components/chat-block.ts +111 -0
  96. package/src/modes/components/copy-selector.ts +1 -44
  97. package/src/modes/components/custom-editor.ts +23 -0
  98. package/src/modes/components/custom-message.ts +1 -3
  99. package/src/modes/components/execution-shared.ts +1 -2
  100. package/src/modes/components/hook-message.ts +1 -3
  101. package/src/modes/components/overlay-box.ts +108 -0
  102. package/src/modes/components/plan-review-overlay.ts +799 -0
  103. package/src/modes/components/plan-toc.ts +138 -0
  104. package/src/modes/components/read-tool-group.ts +20 -4
  105. package/src/modes/components/skill-message.ts +0 -1
  106. package/src/modes/components/tips.txt +1 -0
  107. package/src/modes/components/todo-reminder.ts +0 -2
  108. package/src/modes/components/tool-execution.ts +68 -88
  109. package/src/modes/components/transcript-container.ts +84 -24
  110. package/src/modes/components/user-message.ts +1 -2
  111. package/src/modes/controllers/command-controller-shared.ts +7 -6
  112. package/src/modes/controllers/command-controller.ts +57 -55
  113. package/src/modes/controllers/event-controller.ts +41 -40
  114. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  115. package/src/modes/controllers/input-controller.ts +124 -119
  116. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  117. package/src/modes/controllers/selector-controller.ts +23 -25
  118. package/src/modes/controllers/streaming-reveal.ts +212 -0
  119. package/src/modes/controllers/tan-command-controller.ts +173 -0
  120. package/src/modes/interactive-mode.ts +169 -94
  121. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  122. package/src/modes/theme/theme-schema.json +1 -1
  123. package/src/modes/theme/theme.ts +8 -4
  124. package/src/modes/types.ts +18 -7
  125. package/src/modes/utils/copy-targets.ts +133 -27
  126. package/src/modes/utils/ui-helpers.ts +44 -46
  127. package/src/plan-mode/approved-plan.ts +66 -43
  128. package/src/plan-mode/plan-protection.ts +4 -4
  129. package/src/prompts/system/background-tan-dispatch.md +8 -0
  130. package/src/prompts/system/plan-mode-active.md +67 -58
  131. package/src/prompts/system/plan-mode-approved.md +1 -1
  132. package/src/sdk.ts +11 -37
  133. package/src/session/agent-session.ts +82 -6
  134. package/src/session/messages.ts +26 -0
  135. package/src/session/session-manager.ts +13 -5
  136. package/src/slash-commands/builtin-registry.ts +36 -9
  137. package/src/slash-commands/types.ts +4 -6
  138. package/src/task/executor.ts +5 -2
  139. package/src/task/index.ts +4 -0
  140. package/src/task/render.ts +212 -147
  141. package/src/tools/archive-reader.ts +64 -0
  142. package/src/tools/ask.ts +119 -164
  143. package/src/tools/ast-edit.ts +98 -71
  144. package/src/tools/ast-grep.ts +37 -43
  145. package/src/tools/bash.ts +50 -6
  146. package/src/tools/debug.ts +20 -8
  147. package/src/tools/fetch.ts +297 -7
  148. package/src/tools/find.ts +44 -30
  149. package/src/tools/gh-renderer.ts +81 -42
  150. package/src/tools/grouped-file-output.ts +272 -48
  151. package/src/tools/image-gen.ts +150 -103
  152. package/src/tools/inspect-image-renderer.ts +63 -41
  153. package/src/tools/inspect-image.ts +8 -1
  154. package/src/tools/job.ts +3 -4
  155. package/src/tools/memory-render.ts +4 -1
  156. package/src/tools/plan-mode-guard.ts +21 -39
  157. package/src/tools/read.ts +23 -16
  158. package/src/tools/render-utils.ts +21 -37
  159. package/src/tools/resolve.ts +14 -0
  160. package/src/tools/search-tool-bm25.ts +36 -23
  161. package/src/tools/search.ts +80 -78
  162. package/src/tools/sqlite-reader.ts +9 -12
  163. package/src/tools/todo.ts +118 -52
  164. package/src/tools/write.ts +81 -62
  165. package/src/tui/output-block.ts +60 -13
  166. package/src/tui/status-line.ts +5 -1
  167. package/src/utils/commit-message-generator.ts +9 -1
  168. package/src/utils/enhanced-paste.ts +202 -0
  169. package/src/utils/title-generator.ts +2 -1
  170. package/src/web/search/providers/anthropic.ts +25 -19
  171. package/src/web/search/providers/exa.ts +11 -3
  172. package/src/web/search/providers/kimi.ts +28 -17
  173. package/src/web/search/providers/parallel.ts +35 -24
  174. package/src/web/search/providers/synthetic.ts +8 -6
  175. package/src/web/search/providers/tavily.ts +9 -8
  176. package/src/web/search/providers/zai.ts +8 -6
@@ -1,7 +1,7 @@
1
- import { type Component, padding, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import { type Component, padding, Text, visibleWidth } from "@oh-my-pi/pi-tui";
2
2
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
3
3
  import type { Theme, ThemeColor } from "../modes/theme/theme";
4
- import { renderStatusLine } from "../tui";
4
+ import { framedBlock, renderStatusLine } from "../tui";
5
5
  import type {
6
6
  GhRunWatchFailedLogDetails,
7
7
  GhRunWatchJobDetails,
@@ -239,7 +239,7 @@ function renderFailedLogs(
239
239
  return [];
240
240
  }
241
241
 
242
- const lines = ["", theme.fg("error", "failed logs")];
242
+ const lines: string[] = [];
243
243
  for (const entry of failedLogs) {
244
244
  const context = entry.workflowName ? `${entry.workflowName} #${entry.runId}` : `run #${entry.runId}`;
245
245
  lines.push(
@@ -268,36 +268,45 @@ function renderFailedLogs(
268
268
  return lines;
269
269
  }
270
270
 
271
- function buildWatchLines(
271
+ function buildWatchSections(
272
272
  watch: GhRunWatchViewDetails,
273
273
  theme: Theme,
274
274
  options: RenderResultOptions,
275
275
  width: number,
276
- ): string[] {
277
- const lines = [theme.fg("muted", getWatchHeader(watch))];
276
+ ): Array<{ label?: string; lines: string[] }> {
277
+ const main: string[] = [];
278
278
 
279
279
  if (watch.note) {
280
- lines.push(theme.fg("dim", replaceTabs(watch.note)));
280
+ main.push(theme.fg("dim", replaceTabs(watch.note)));
281
281
  }
282
282
 
283
283
  if (watch.mode === "run" && watch.run) {
284
- lines.push(...renderRunBlock(watch.run, width, theme));
284
+ main.push(...renderRunBlock(watch.run, width, theme));
285
285
  } else if (watch.mode === "commit") {
286
286
  const runs = watch.runs ?? [];
287
287
  if (runs.length === 0) {
288
- lines.push(theme.fg("dim", "waiting for workflow runs..."));
288
+ main.push(theme.fg("dim", "waiting for workflow runs..."));
289
289
  } else {
290
290
  runs.forEach((run, index) => {
291
291
  if (index > 0) {
292
- lines.push("");
292
+ main.push("");
293
293
  }
294
- lines.push(...renderRunBlock(run, width, theme));
294
+ main.push(...renderRunBlock(run, width, theme));
295
295
  });
296
296
  }
297
297
  }
298
298
 
299
- lines.push(...renderFailedLogs(watch.failedLogs ?? [], width, theme, options.expanded));
300
- return lines;
299
+ const sections: Array<{ label?: string; lines: string[] }> = [];
300
+ if (main.length > 0) {
301
+ sections.push({ lines: main });
302
+ }
303
+
304
+ const failed = renderFailedLogs(watch.failedLogs ?? [], width, theme, options.expanded);
305
+ if (failed.length > 0) {
306
+ sections.push({ label: "failed logs", lines: failed });
307
+ }
308
+
309
+ return sections;
301
310
  }
302
311
 
303
312
  function extractText(content: Array<{ type: string; text?: string }>): string {
@@ -335,29 +344,44 @@ function renderFallbackComponent(
335
344
  }
336
345
 
337
346
  const allLines = replaceTabs(text).split("\n");
347
+ while (allLines.length > 0 && allLines[0].trim() === "") allLines.shift();
348
+ while (allLines.length > 0 && allLines[allLines.length - 1].trim() === "") allLines.pop();
349
+
350
+ // Trivial one-line *success* result: a clean status line beats an almost-empty box.
351
+ // Errors always frame so the message reads as a structured block, never a raw red wrap.
352
+ if (allLines.length <= 1 && !isError) {
353
+ const body = allLines[0];
354
+ if (!body) return new Text(header, 0, 0);
355
+ const colored = isError ? theme.fg("error", body) : theme.fg("toolOutput", body);
356
+ return new Text(`${header}\n${colored}`, 0, 0);
357
+ }
338
358
 
339
- return {
340
- render(width: number): string[] {
341
- const lineWidth = Math.max(24, width || FALLBACK_WIDTH);
342
- const expanded = options.expanded;
343
- const limit = expanded ? allLines.length : Math.min(allLines.length, PREVIEW_LIMITS.OUTPUT_EXPANDED);
344
- const visible = allLines.slice(0, limit);
345
- const remaining = allLines.length - visible.length;
346
-
347
- const out: string[] = [header];
348
- for (const line of visible) {
349
- const colored = isError ? theme.fg("error", line) : theme.fg("toolOutput", line);
350
- out.push(truncateVisualWidth(colored, lineWidth));
351
- }
352
- if (!expanded && remaining > 0) {
353
- const hint = formatExpandHint(theme, expanded, true);
354
- const more = `${formatMoreItems(remaining, "line")}${hint ? ` ${hint}` : ""}`;
355
- out.push(theme.fg("dim", more));
356
- }
357
- return out.map(line => truncateToWidth(line, lineWidth));
358
- },
359
- invalidate() {},
360
- };
359
+ return framedBlock(theme, width => {
360
+ const lineWidth = Math.max(1, (width || FALLBACK_WIDTH) - 3);
361
+ const expanded = options.expanded;
362
+ const limit = expanded ? allLines.length : Math.min(allLines.length, PREVIEW_LIMITS.OUTPUT_EXPANDED);
363
+ const visible = allLines.slice(0, limit);
364
+ const remaining = allLines.length - visible.length;
365
+
366
+ const out: string[] = [];
367
+ for (const line of visible) {
368
+ const colored = isError ? theme.fg("error", line) : theme.fg("toolOutput", line);
369
+ out.push(truncateVisualWidth(colored, lineWidth));
370
+ }
371
+ if (!expanded && remaining > 0) {
372
+ const hint = formatExpandHint(theme, expanded, true);
373
+ const more = `${formatMoreItems(remaining, "line")}${hint ? ` ${hint}` : ""}`;
374
+ out.push(theme.fg("dim", more));
375
+ }
376
+ return {
377
+ header,
378
+ sections: out.length > 0 ? [{ lines: out }] : [],
379
+ state: isError ? "error" : "success",
380
+ borderColor: isError ? "error" : "borderMuted",
381
+ applyBg: false,
382
+ width,
383
+ };
384
+ });
361
385
  }
362
386
 
363
387
  function renderWatchCall(args: GithubToolRenderArgs, options: RenderResultOptions, theme: Theme): Component {
@@ -380,7 +404,7 @@ function renderWatchCall(args: GithubToolRenderArgs, options: RenderResultOption
380
404
  }
381
405
 
382
406
  const header = `${icon} ${titleText} ${metaText}`;
383
- const wait = theme.fg("dim", " waiting for workflow data...");
407
+ const wait = theme.fg("dim", "waiting for workflow data...");
384
408
  return new Text(`${header}\n${wait}`, 0, 0);
385
409
  }
386
410
 
@@ -412,13 +436,28 @@ export const githubToolRenderer = {
412
436
  ): Component {
413
437
  const watch = result.details?.watch;
414
438
  if (watch) {
415
- return {
416
- render(width: number): string[] {
417
- const lineWidth = Math.max(24, width || FALLBACK_WIDTH);
418
- return buildWatchLines(watch, uiTheme, options, lineWidth).map(line => truncateToWidth(line, lineWidth));
439
+ const isError = result.isError === true;
440
+ const header = renderStatusLine(
441
+ {
442
+ icon: isError ? "error" : "success",
443
+ title: "GitHub Run Watch",
444
+ titleColor: isError ? "error" : "accent",
445
+ meta: [getWatchHeader(watch)],
419
446
  },
420
- invalidate() {},
421
- };
447
+ uiTheme,
448
+ );
449
+ return framedBlock(uiTheme, width => {
450
+ const innerWidth = Math.max(1, (width || FALLBACK_WIDTH) - 3);
451
+ const sections = buildWatchSections(watch, uiTheme, options, innerWidth);
452
+ return {
453
+ header,
454
+ sections,
455
+ state: isError ? "error" : "success",
456
+ borderColor: isError ? "error" : "borderMuted",
457
+ applyBg: false,
458
+ width,
459
+ };
460
+ });
422
461
  }
423
462
 
424
463
  return renderFallbackComponent(result, options, uiTheme, args ?? {});
@@ -6,10 +6,127 @@ function isUrlLikePath(filePath: string): boolean {
6
6
  return URL_LIKE_PATH_RE.test(filePath);
7
7
  }
8
8
 
9
+ // =============================================================================
10
+ // Multi-level path tree
11
+ // =============================================================================
12
+ //
13
+ // File listings (grep / ast-grep / ast-edit / lsp diagnostics / find) used to
14
+ // group by the *immediate* parent directory and print the full directory path in
15
+ // every header. For results spread across a deep tree — or rooted outside cwd,
16
+ // where paths stay absolute — that repeated the shared prefix on every line. The
17
+ // tree below folds single-child directory chains (so the common prefix collapses
18
+ // into one header) and nests the rest, charging the model one token per path
19
+ // segment instead of one per file.
20
+
21
+ interface PathTreeNode {
22
+ /** Direct file leaves, in first-seen order. */
23
+ files: Array<{ name: string; key: string }>;
24
+ /** Dedup set for `files` (a glob can surface the same path twice on retry). */
25
+ fileNames: Set<string>;
26
+ /** Child directories, in first-seen order. */
27
+ subdirs: Array<{ name: string; node: PathTreeNode }>;
28
+ /** Dedup index for `subdirs`. */
29
+ dirIndex: Map<string, PathTreeNode>;
30
+ }
31
+
32
+ export interface PathTreeInput {
33
+ /** Path string; absolute, cwd-relative, or url-like. Backslashes are normalized. */
34
+ path: string;
35
+ /** Whether the leaf itself is a directory (trailing-slash match from find). */
36
+ isDir: boolean;
37
+ /** Opaque key carried onto file events for section lookup. Defaults to `path`. */
38
+ key?: string;
39
+ }
40
+
41
+ /** One node emitted while walking the tree: a folded directory or a file leaf. */
42
+ export interface GroupedTreeEvent {
43
+ kind: "dir" | "file";
44
+ /** 0-based nesting depth (root children are depth 0). */
45
+ depth: number;
46
+ /** Folded chain for dirs (e.g. `a/b/c`, no trailing slash); basename for files. */
47
+ name: string;
48
+ /** File key for `kind === "file"`; empty string for directories. */
49
+ key: string;
50
+ }
51
+
52
+ function createNode(): PathTreeNode {
53
+ return { files: [], fileNames: new Set(), subdirs: [], dirIndex: new Map() };
54
+ }
55
+
56
+ function addFile(node: PathTreeNode, name: string, key: string): void {
57
+ if (node.fileNames.has(name)) return;
58
+ node.fileNames.add(name);
59
+ node.files.push({ name, key });
60
+ }
61
+
62
+ /**
63
+ * Build a directory tree from a flat list of paths. URL-like entries are kept
64
+ * whole as root-level file leaves (they have no meaningful directory structure).
65
+ * Absolute paths carry a leading empty segment so they share a common `/` root
66
+ * and fold like any other prefix.
67
+ */
68
+ export function buildPathTree(entries: Iterable<PathTreeInput>): PathTreeNode {
69
+ const root = createNode();
70
+ for (const { path: rawPath, isDir, key } of entries) {
71
+ const normalized = rawPath.replace(/\\/g, "/");
72
+ const fileKey = key ?? rawPath;
73
+ if (isUrlLikePath(normalized)) {
74
+ addFile(root, normalized, fileKey);
75
+ continue;
76
+ }
77
+ const trimmed = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
78
+ if (trimmed.length === 0) continue;
79
+ const segments = trimmed.split("/");
80
+ const dirCount = isDir ? segments.length : segments.length - 1;
81
+ let node = root;
82
+ for (let i = 0; i < dirCount; i++) {
83
+ const segment = segments[i]!;
84
+ let child = node.dirIndex.get(segment);
85
+ if (!child) {
86
+ child = createNode();
87
+ node.dirIndex.set(segment, child);
88
+ node.subdirs.push({ name: segment, node: child });
89
+ }
90
+ node = child;
91
+ }
92
+ if (!isDir) {
93
+ addFile(node, segments[segments.length - 1]!, fileKey);
94
+ }
95
+ }
96
+ return root;
97
+ }
98
+
99
+ /**
100
+ * Depth-first walk yielding directory and file events. Directories collapse their
101
+ * single-child chains (`a` → `a/b` → `a/b/c`) so a shared prefix becomes one
102
+ * header. Each node's direct files are emitted before its subdirectories, keeping
103
+ * a file unambiguously attached to the header above it.
104
+ */
105
+ export function* walkPathTree(node: PathTreeNode, depth = 0): Generator<GroupedTreeEvent> {
106
+ for (const file of node.files) {
107
+ yield { kind: "file", depth, name: file.name, key: file.key };
108
+ }
109
+ for (const subdir of node.subdirs) {
110
+ let dirNode = subdir.node;
111
+ const parts = [subdir.name];
112
+ while (dirNode.files.length === 0 && dirNode.subdirs.length === 1) {
113
+ const only = dirNode.subdirs[0]!;
114
+ parts.push(only.name);
115
+ dirNode = only.node;
116
+ }
117
+ yield { kind: "dir", depth, name: parts.join("/"), key: "" };
118
+ yield* walkPathTree(dirNode, depth + 1);
119
+ }
120
+ }
121
+
122
+ // =============================================================================
123
+ // Grouped file output (grep / ast-grep / ast-edit / lsp diagnostics)
124
+ // =============================================================================
125
+
9
126
  /**
10
127
  * One file's contribution to a grouped file output. The header itself is generated
11
- * by `formatGroupedFiles` (single `#` for root files, `##` for files inside a dir);
12
- * use `headerSuffix` to tack on extras like ` (1 replacement)`.
128
+ * by `formatGroupedFiles` (one `#` per nesting level); use `headerSuffix` to tack
129
+ * on extras like ` (1 replacement)`.
13
130
  */
14
131
  export interface GroupedFileSection {
15
132
  /** Optional suffix appended to the file header. */
@@ -28,76 +145,183 @@ export interface GroupedFilesOutput {
28
145
  }
29
146
 
30
147
  /**
31
- * Render a list of files as directory-grouped sections shared by grep, ast-grep,
32
- * ast-edit, and the LSP diagnostic formatter.
148
+ * Render a list of files as a multi-level, prefix-folded directory tree shared by
149
+ * grep, ast-grep, ast-edit, and the LSP diagnostic formatter.
33
150
  *
34
- * Layout:
35
- * # dir/
36
- * ## file.ts
151
+ * Layout (one `#` per level; the shared prefix folds into the top header):
152
+ * # packages/pkg/src/
153
+ * ## root.ts
37
154
  * …body…
38
- *
39
- * # otherdir/
40
- * ## other.ts
155
+ * ## nested/
156
+ * ### child.ts
41
157
  * …body…
42
158
  *
43
- * Files in the project root (directory `.`) become single-`#` headers without a
44
- * `## file` line, matching the existing convention.
159
+ * Files in the (folded) project root become single-`#` headers with no parent
160
+ * directory line. A blank line precedes every directory header and every
161
+ * root-level file so the renderers can split the output into collapsible groups.
45
162
  */
46
163
  export function formatGroupedFiles(
47
164
  files: string[],
48
165
  renderFile: (filePath: string) => GroupedFileSection,
49
166
  ): GroupedFilesOutput {
50
- const filesByDirectory = new Map<string, string[]>();
167
+ const sections = new Map<string, GroupedFileSection>();
168
+ const inputs: PathTreeInput[] = [];
51
169
  for (const filePath of files) {
52
- const directory = isUrlLikePath(filePath) ? "." : path.dirname(filePath).replace(/\\/g, "/");
53
- if (!filesByDirectory.has(directory)) {
54
- filesByDirectory.set(directory, []);
55
- }
56
- filesByDirectory.get(directory)!.push(filePath);
170
+ if (sections.has(filePath)) continue;
171
+ const section = renderFile(filePath);
172
+ if (section.skip) continue;
173
+ sections.set(filePath, section);
174
+ inputs.push({ path: filePath, isDir: false, key: filePath });
57
175
  }
58
176
 
177
+ const tree = buildPathTree(inputs);
59
178
  const model: string[] = [];
60
179
  const display: string[] = [];
180
+ let emitted = false;
61
181
 
62
- const pushSeparatorIfNeeded = () => {
63
- if (model.length > 0) {
182
+ for (const event of walkPathTree(tree)) {
183
+ const hashes = "#".repeat(event.depth + 1);
184
+ const needsSeparator = emitted && (event.depth === 0 || event.kind === "dir");
185
+ if (needsSeparator) {
64
186
  model.push("");
65
187
  display.push("");
66
188
  }
189
+ emitted = true;
190
+ if (event.kind === "dir") {
191
+ const header = `${hashes} ${event.name}/`;
192
+ model.push(header);
193
+ display.push(header);
194
+ continue;
195
+ }
196
+ const section = sections.get(event.key)!;
197
+ const header = `${hashes} ${event.name}${section.headerSuffix ?? ""}`;
198
+ model.push(header, ...section.modelLines);
199
+ display.push(header, ...(section.displayLines ?? section.modelLines));
200
+ }
201
+
202
+ return { model, display };
203
+ }
204
+
205
+ // =============================================================================
206
+ // Parsing grouped output back into per-line context (TUI renderers)
207
+ // =============================================================================
208
+
209
+ const GROUPED_HEADER_RE = /^(#+)\s+(.*)$/;
210
+ const HEADER_SUFFIX_RE = /\s+\([^)]*\)\s*$/;
211
+ const HEADER_HASH_TAG_RE = /#[0-9a-f]+$/i;
212
+
213
+ /** Per-line classification of grouped output, used by renderers for hyperlinks. */
214
+ export interface GroupedLineContext {
215
+ /** Directory header, file header, or any non-header body/content line. */
216
+ kind: "dir" | "file" | "content";
217
+ /** Number of leading `#` for headers; 0 for content lines. */
218
+ depth: number;
219
+ /** Resolved absolute path of the dir/file a header points at (when resolvable). */
220
+ headerPath?: string;
221
+ /** For content lines, the absolute path of the owning file (line hyperlinks). */
222
+ filePath?: string;
223
+ /** Header is an internal/url-like target the caller resolves itself. */
224
+ isUrl?: boolean;
225
+ }
226
+
227
+ function resolveGroupedPath(parent: string | undefined, name: string): string | undefined {
228
+ if (parent === undefined) return undefined;
229
+ if (name === "" || name === ".") return parent;
230
+ // `path.resolve` keeps an absolute `name` intact (out-of-cwd results) while
231
+ // joining a relative folded chain (`packages/pkg/src`) onto the parent.
232
+ return path.resolve(parent, name);
233
+ }
234
+
235
+ /**
236
+ * Walk grouped output lines, tracking a directory stack keyed by header depth, so
237
+ * each header and body line can be linked back to its absolute filesystem path.
238
+ * Reconstruction is stack-based (not per-blank-group) so nested directory headers
239
+ * resolve correctly across the whole output.
240
+ *
241
+ * `headerBase` is the directory the displayed (folded) header paths are relative
242
+ * to — for grep/ast tools that is the session cwd, since display paths are
243
+ * formatted relative to cwd regardless of the (sub)directory the search was
244
+ * scoped to. `fileScope` is the initial owning file for body lines that appear
245
+ * before any header (single-file scopes have no `#` headers); it defaults to
246
+ * `headerBase` and should be passed the scoped file's absolute path.
247
+ */
248
+ export function classifyGroupedLines(
249
+ lines: readonly string[],
250
+ headerBase: string | undefined,
251
+ fileScope: string | undefined = headerBase,
252
+ ): GroupedLineContext[] {
253
+ const result: GroupedLineContext[] = [];
254
+ const dirAtDepth = new Map<number, string>();
255
+ // Body lines before any header (single-file scopes) link to the scoped file.
256
+ let currentFile = fileScope;
257
+
258
+ const clearDeeper = (depth: number) => {
259
+ for (const key of dirAtDepth.keys()) {
260
+ if (key >= depth) dirAtDepth.delete(key);
261
+ }
67
262
  };
68
263
 
69
- for (const [directory, dirFiles] of filesByDirectory) {
70
- if (directory === ".") {
71
- for (const filePath of dirFiles) {
72
- const section = renderFile(filePath);
73
- if (section.skip) continue;
74
- pushSeparatorIfNeeded();
75
- const headerName = isUrlLikePath(filePath) ? filePath : path.basename(filePath);
76
- const header = `# ${headerName}${section.headerSuffix ?? ""}`;
77
- model.push(header, ...section.modelLines);
78
- display.push(header, ...(section.displayLines ?? section.modelLines));
79
- }
264
+ for (const line of lines) {
265
+ const match = GROUPED_HEADER_RE.exec(line);
266
+ if (!match) {
267
+ result.push({ kind: "content", depth: 0, filePath: currentFile });
80
268
  continue;
81
269
  }
82
-
83
- const sections: Array<{ filePath: string; section: GroupedFileSection }> = [];
84
- for (const filePath of dirFiles) {
85
- const section = renderFile(filePath);
86
- if (section.skip) continue;
87
- sections.push({ filePath, section });
270
+ const depth = match[1]!.length;
271
+ const rest = match[2]!.trimEnd();
272
+ if (isUrlLikePath(rest)) {
273
+ clearDeeper(depth);
274
+ currentFile = undefined;
275
+ result.push({ kind: "file", depth, isUrl: true });
276
+ continue;
88
277
  }
89
- if (sections.length === 0) continue;
90
-
91
- pushSeparatorIfNeeded();
92
- const dirHeader = `# ${directory}/`;
93
- model.push(dirHeader);
94
- display.push(dirHeader);
95
- for (const { filePath, section } of sections) {
96
- const fileHeader = `## ${path.basename(filePath)}${section.headerSuffix ?? ""}`;
97
- model.push(fileHeader, ...section.modelLines);
98
- display.push(fileHeader, ...(section.displayLines ?? section.modelLines));
278
+ const parent = depth > 1 ? dirAtDepth.get(depth - 1) : headerBase;
279
+ if (rest.endsWith("/")) {
280
+ const name = rest.slice(0, -1).replace(HEADER_SUFFIX_RE, "");
281
+ const abs = resolveGroupedPath(parent, name);
282
+ clearDeeper(depth);
283
+ if (abs !== undefined) dirAtDepth.set(depth, abs);
284
+ currentFile = undefined;
285
+ result.push({ kind: "dir", depth, headerPath: abs });
286
+ continue;
99
287
  }
288
+ const name = rest.replace(HEADER_SUFFIX_RE, "").replace(HEADER_HASH_TAG_RE, "");
289
+ const abs = name ? resolveGroupedPath(parent, name) : undefined;
290
+ currentFile = abs;
291
+ result.push({ kind: "file", depth, headerPath: abs });
100
292
  }
101
293
 
102
- return { model, display };
294
+ return result;
295
+ }
296
+
297
+ /**
298
+ * Split line indices into blank-line-separated groups, mirroring
299
+ * `splitGroupsByBlankLine`: when any blank line is present, break on runs of
300
+ * blanks; otherwise return a single group of the non-empty lines. Returning
301
+ * indices lets callers slice parallel arrays (raw lines, styled lines, contexts).
302
+ */
303
+ export function groupLineIndicesByBlank(rawLines: readonly string[]): number[][] {
304
+ const hasSeparators = rawLines.some(line => line.trim().length === 0);
305
+ const groups: number[][] = [];
306
+ if (hasSeparators) {
307
+ let current: number[] = [];
308
+ for (let i = 0; i < rawLines.length; i++) {
309
+ if (rawLines[i]!.trim().length === 0) {
310
+ if (current.length > 0) {
311
+ groups.push(current);
312
+ current = [];
313
+ }
314
+ continue;
315
+ }
316
+ current.push(i);
317
+ }
318
+ if (current.length > 0) groups.push(current);
319
+ } else {
320
+ const current: number[] = [];
321
+ for (let i = 0; i < rawLines.length; i++) {
322
+ if (rawLines[i]!.trim().length > 0) current.push(i);
323
+ }
324
+ if (current.length > 0) groups.push(current);
325
+ }
326
+ return groups;
103
327
  }