@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.
- package/CHANGELOG.md +75 -1
- package/dist/types/cli/dry-balance-cli.d.ts +15 -1
- package/dist/types/commit/analysis/conventional.d.ts +2 -2
- package/dist/types/commit/analysis/summary.d.ts +2 -2
- package/dist/types/commit/changelog/generate.d.ts +2 -2
- package/dist/types/commit/changelog/index.d.ts +2 -2
- package/dist/types/commit/map-reduce/index.d.ts +3 -3
- package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
- package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
- package/dist/types/commit/model-selection.d.ts +10 -4
- package/dist/types/config/api-key-resolver.d.ts +34 -0
- package/dist/types/config/model-registry.d.ts +17 -1
- package/dist/types/config/settings-schema.d.ts +9 -0
- package/dist/types/dap/config.d.ts +14 -1
- package/dist/types/dap/types.d.ts +10 -0
- package/dist/types/lsp/utils.d.ts +3 -2
- package/dist/types/modes/components/chat-block.d.ts +64 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/overlay-box.d.ts +17 -0
- package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
- package/dist/types/modes/components/plan-toc.d.ts +41 -0
- package/dist/types/modes/components/read-tool-group.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +11 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +0 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
- package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +15 -5
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +18 -5
- package/dist/types/modes/utils/copy-targets.d.ts +21 -1
- package/dist/types/plan-mode/approved-plan.d.ts +27 -8
- package/dist/types/plan-mode/plan-protection.d.ts +4 -4
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +21 -0
- package/dist/types/session/messages.d.ts +12 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/slash-commands/types.d.ts +4 -6
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/index.d.ts +1 -0
- package/dist/types/task/render.d.ts +3 -2
- package/dist/types/tools/archive-reader.d.ts +5 -0
- package/dist/types/tools/ast-edit.d.ts +3 -0
- package/dist/types/tools/ast-grep.d.ts +3 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/find.d.ts +8 -4
- package/dist/types/tools/grouped-file-output.d.ts +95 -12
- package/dist/types/tools/memory-render.d.ts +4 -1
- package/dist/types/tools/plan-mode-guard.d.ts +8 -9
- package/dist/types/tools/render-utils.d.ts +5 -9
- package/dist/types/tools/search.d.ts +4 -0
- package/dist/types/tools/sqlite-reader.d.ts +1 -0
- package/dist/types/tools/todo.d.ts +3 -2
- package/dist/types/tools/write.d.ts +3 -0
- package/dist/types/tui/output-block.d.ts +16 -4
- package/dist/types/tui/status-line.d.ts +3 -0
- package/dist/types/utils/enhanced-paste.d.ts +20 -0
- package/dist/types/web/search/providers/kimi.d.ts +1 -1
- package/package.json +9 -9
- package/src/auto-thinking/classifier.ts +5 -1
- package/src/cli/dry-balance-cli.ts +52 -17
- package/src/cli/gallery-cli.ts +4 -1
- package/src/cli/gallery-fixtures/misc.ts +29 -0
- package/src/commit/analysis/conventional.ts +2 -2
- package/src/commit/analysis/summary.ts +2 -2
- package/src/commit/changelog/generate.ts +2 -2
- package/src/commit/changelog/index.ts +2 -2
- package/src/commit/map-reduce/index.ts +3 -3
- package/src/commit/map-reduce/map-phase.ts +2 -2
- package/src/commit/map-reduce/reduce-phase.ts +2 -2
- package/src/commit/model-selection.ts +33 -9
- package/src/commit/pipeline.ts +4 -4
- package/src/config/api-key-resolver.ts +58 -0
- package/src/config/model-registry.ts +25 -2
- package/src/config/settings-schema.ts +10 -0
- package/src/config/settings.ts +20 -2
- package/src/dap/config.ts +41 -2
- package/src/dap/defaults.json +1 -0
- package/src/dap/session.ts +1 -0
- package/src/dap/types.ts +10 -0
- package/src/debug/index.ts +40 -54
- package/src/edit/renderer.ts +82 -78
- package/src/eval/__tests__/llm-bridge.test.ts +90 -31
- package/src/eval/llm-bridge.ts +8 -3
- package/src/goals/tools/goal-tool.ts +36 -26
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/lsp/utils.ts +3 -2
- package/src/main.ts +9 -7
- package/src/memories/index.ts +12 -5
- package/src/mnemopi/backend.ts +5 -1
- package/src/modes/acp/acp-agent.ts +33 -26
- package/src/modes/components/assistant-message.ts +2 -9
- package/src/modes/components/chat-block.ts +111 -0
- package/src/modes/components/copy-selector.ts +1 -44
- package/src/modes/components/custom-editor.ts +23 -0
- package/src/modes/components/custom-message.ts +1 -3
- package/src/modes/components/execution-shared.ts +1 -2
- package/src/modes/components/hook-message.ts +1 -3
- package/src/modes/components/overlay-box.ts +108 -0
- package/src/modes/components/plan-review-overlay.ts +799 -0
- package/src/modes/components/plan-toc.ts +138 -0
- package/src/modes/components/read-tool-group.ts +20 -4
- package/src/modes/components/skill-message.ts +0 -1
- package/src/modes/components/tips.txt +1 -0
- package/src/modes/components/todo-reminder.ts +0 -2
- package/src/modes/components/tool-execution.ts +68 -88
- package/src/modes/components/transcript-container.ts +84 -24
- package/src/modes/components/user-message.ts +1 -2
- package/src/modes/controllers/command-controller-shared.ts +7 -6
- package/src/modes/controllers/command-controller.ts +57 -55
- package/src/modes/controllers/event-controller.ts +41 -40
- package/src/modes/controllers/extension-ui-controller.ts +10 -73
- package/src/modes/controllers/input-controller.ts +124 -119
- package/src/modes/controllers/mcp-command-controller.ts +69 -60
- package/src/modes/controllers/selector-controller.ts +23 -25
- package/src/modes/controllers/streaming-reveal.ts +212 -0
- package/src/modes/controllers/tan-command-controller.ts +173 -0
- package/src/modes/interactive-mode.ts +169 -94
- package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
- package/src/modes/theme/theme-schema.json +1 -1
- package/src/modes/theme/theme.ts +8 -4
- package/src/modes/types.ts +18 -7
- package/src/modes/utils/copy-targets.ts +133 -27
- package/src/modes/utils/ui-helpers.ts +44 -46
- package/src/plan-mode/approved-plan.ts +66 -43
- package/src/plan-mode/plan-protection.ts +4 -4
- package/src/prompts/system/background-tan-dispatch.md +8 -0
- package/src/prompts/system/plan-mode-active.md +67 -58
- package/src/prompts/system/plan-mode-approved.md +1 -1
- package/src/sdk.ts +11 -37
- package/src/session/agent-session.ts +82 -6
- package/src/session/messages.ts +26 -0
- package/src/session/session-manager.ts +13 -5
- package/src/slash-commands/builtin-registry.ts +36 -9
- package/src/slash-commands/types.ts +4 -6
- package/src/task/executor.ts +5 -2
- package/src/task/index.ts +4 -0
- package/src/task/render.ts +212 -147
- package/src/tools/archive-reader.ts +64 -0
- package/src/tools/ask.ts +119 -164
- package/src/tools/ast-edit.ts +98 -71
- package/src/tools/ast-grep.ts +37 -43
- package/src/tools/bash.ts +50 -6
- package/src/tools/debug.ts +20 -8
- package/src/tools/fetch.ts +297 -7
- package/src/tools/find.ts +44 -30
- package/src/tools/gh-renderer.ts +81 -42
- package/src/tools/grouped-file-output.ts +272 -48
- package/src/tools/image-gen.ts +150 -103
- package/src/tools/inspect-image-renderer.ts +63 -41
- package/src/tools/inspect-image.ts +8 -1
- package/src/tools/job.ts +3 -4
- package/src/tools/memory-render.ts +4 -1
- package/src/tools/plan-mode-guard.ts +21 -39
- package/src/tools/read.ts +23 -16
- package/src/tools/render-utils.ts +21 -37
- package/src/tools/resolve.ts +14 -0
- package/src/tools/search-tool-bm25.ts +36 -23
- package/src/tools/search.ts +80 -78
- package/src/tools/sqlite-reader.ts +9 -12
- package/src/tools/todo.ts +118 -52
- package/src/tools/write.ts +81 -62
- package/src/tui/output-block.ts +60 -13
- package/src/tui/status-line.ts +5 -1
- package/src/utils/commit-message-generator.ts +9 -1
- package/src/utils/enhanced-paste.ts +202 -0
- package/src/utils/title-generator.ts +2 -1
- package/src/web/search/providers/anthropic.ts +25 -19
- package/src/web/search/providers/exa.ts +11 -3
- package/src/web/search/providers/kimi.ts +28 -17
- package/src/web/search/providers/parallel.ts +35 -24
- package/src/web/search/providers/synthetic.ts +8 -6
- package/src/web/search/providers/tavily.ts +9 -8
- package/src/web/search/providers/zai.ts +8 -6
package/src/tools/gh-renderer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type Component, padding, Text,
|
|
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 = [
|
|
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
|
|
271
|
+
function buildWatchSections(
|
|
272
272
|
watch: GhRunWatchViewDetails,
|
|
273
273
|
theme: Theme,
|
|
274
274
|
options: RenderResultOptions,
|
|
275
275
|
width: number,
|
|
276
|
-
): string[] {
|
|
277
|
-
const
|
|
276
|
+
): Array<{ label?: string; lines: string[] }> {
|
|
277
|
+
const main: string[] = [];
|
|
278
278
|
|
|
279
279
|
if (watch.note) {
|
|
280
|
-
|
|
280
|
+
main.push(theme.fg("dim", replaceTabs(watch.note)));
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
if (watch.mode === "run" && watch.run) {
|
|
284
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
+
main.push("");
|
|
293
293
|
}
|
|
294
|
-
|
|
294
|
+
main.push(...renderRunBlock(run, width, theme));
|
|
295
295
|
});
|
|
296
296
|
}
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
lines
|
|
300
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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", "
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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` (
|
|
12
|
-
*
|
|
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
|
|
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
|
-
* #
|
|
36
|
-
* ##
|
|
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
|
-
*
|
|
40
|
-
* ## other.ts
|
|
155
|
+
* ## nested/
|
|
156
|
+
* ### child.ts
|
|
41
157
|
* …body…
|
|
42
158
|
*
|
|
43
|
-
* Files in the project root
|
|
44
|
-
*
|
|
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
|
|
167
|
+
const sections = new Map<string, GroupedFileSection>();
|
|
168
|
+
const inputs: PathTreeInput[] = [];
|
|
51
169
|
for (const filePath of files) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
63
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
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
|
}
|