@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
@@ -3,7 +3,7 @@ import { formatHashlineHeader } from "@oh-my-pi/hashline";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import { type AstReplaceChange, type AstReplaceFileChange, astEdit } from "@oh-my-pi/pi-natives";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
- import { Text } from "@oh-my-pi/pi-tui";
6
+ import { replaceTabs, Text } from "@oh-my-pi/pi-tui";
7
7
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
9
  import { getFileSnapshotStore } from "../edit/file-snapshot-store";
@@ -11,26 +11,24 @@ import { normalizeToLF } from "../edit/normalize";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import type { Theme } from "../modes/theme/theme";
13
13
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
14
- import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
+ import { Ellipsis, fileHyperlink, framedBlock, renderStatusLine, truncateToWidth } from "../tui";
15
15
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
16
16
  import type { ToolSession } from ".";
17
17
  import { truncateForPrompt } from "./approval";
18
18
  import { createFileRecorder, formatResultPath } from "./file-recorder";
19
- import { formatGroupedFiles } from "./grouped-file-output";
19
+ import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
20
20
  import type { OutputMeta } from "./output-meta";
21
21
  import { isInternalUrlPath, resolveToolSearchScope } from "./path-utils";
22
22
  import {
23
23
  appendParseErrorsBulletList,
24
24
  capParseErrors,
25
- createCachedComponent,
26
25
  formatCodeFrameLine,
27
26
  formatCount,
28
- formatEmptyMessage,
29
- formatErrorMessage,
27
+ formatErrorDetail,
28
+ formatMoreItems,
30
29
  formatParseErrors,
31
30
  formatParseErrorsCountLabel,
32
31
  PREVIEW_LIMITS,
33
- splitGroupsByBlankLine,
34
32
  } from "./render-utils";
35
33
  import { queueResolveHandler } from "./resolve";
36
34
  import { ToolError } from "./tool-errors";
@@ -161,6 +159,9 @@ export interface AstEditToolDetails {
161
159
  /** Absolute base directory used during the edit. Used by the renderer to resolve
162
160
  * display-relative paths to absolute paths for OSC 8 hyperlinks. */
163
161
  searchPath?: string;
162
+ /** Session cwd at edit time. Display header paths are cwd-relative, so the
163
+ * renderer resolves them against this; `searchPath` is the scope target. */
164
+ cwd?: string;
164
165
  }
165
166
 
166
167
  export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolDetails> {
@@ -274,6 +275,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
274
275
  ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
275
276
  scopePath,
276
277
  searchPath: resolvedSearchPath,
278
+ cwd: this.session.cwd,
277
279
  files: fileList,
278
280
  fileReplacements: [],
279
281
  };
@@ -464,6 +466,32 @@ interface AstEditRenderArgs {
464
466
 
465
467
  const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
466
468
 
469
+ /**
470
+ * Flatten pre-styled change groups into frame body lines. Groups are separated
471
+ * by a blank line and carry no tree guides — the frame border is the container,
472
+ * so nested `├─ │` gutters would just be noise. Collapsed mode always shows at
473
+ * least the first group, then fills up to `budget` lines before summarizing the
474
+ * rest as `… N more changes`.
475
+ */
476
+ function buildChangeBody(groups: string[][], expanded: boolean, budget: number, theme: Theme): string[] {
477
+ const lines: string[] = [];
478
+ let shown = 0;
479
+ for (let i = 0; i < groups.length; i++) {
480
+ const group = groups[i]!;
481
+ const separator = shown > 0 ? 1 : 0;
482
+ const remainingAfter = groups.length - (i + 1);
483
+ const reserved = !expanded && remainingAfter > 0 ? 1 : 0;
484
+ // Always emit the first group; budget only gates subsequent ones.
485
+ if (!expanded && shown > 0 && lines.length + separator + group.length + reserved > budget) break;
486
+ if (separator) lines.push("");
487
+ lines.push(...group);
488
+ shown++;
489
+ }
490
+ const remaining = groups.length - shown;
491
+ if (!expanded && remaining > 0) lines.push(theme.fg("muted", formatMoreItems(remaining, "change")));
492
+ return lines;
493
+ }
494
+
467
495
  export const astEditToolRenderer = {
468
496
  inline: true,
469
497
  renderCall(args: AstEditRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
@@ -473,8 +501,9 @@ export const astEditToolRenderer = {
473
501
  if (rewriteCount > 1) meta.push(`${rewriteCount} rewrites`);
474
502
 
475
503
  const description = rewriteCount === 1 ? args.ops?.[0]?.pat : rewriteCount ? `${rewriteCount} rewrites` : "?";
476
- const text = renderStatusLine({ icon: "pending", title: "AST Edit", description, meta }, uiTheme);
477
- return new Text(text, 0, 0);
504
+ const header = renderStatusLine({ icon: "pending", title: "AST Edit", description, meta }, uiTheme);
505
+ // Pending call has no body yet — a lone status line is sleeker than an empty frame.
506
+ return new Text(header, 0, 0);
478
507
  },
479
508
 
480
509
  renderResult(
@@ -487,7 +516,14 @@ export const astEditToolRenderer = {
487
516
 
488
517
  if (result.isError) {
489
518
  const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
490
- return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
519
+ const header = renderStatusLine({ icon: "error", title: "AST Edit" }, uiTheme);
520
+ return framedBlock(uiTheme, width => ({
521
+ header,
522
+ sections: [{ lines: formatErrorDetail(errorText, uiTheme).split("\n") }],
523
+ state: "error",
524
+ borderColor: "error",
525
+ width,
526
+ }));
491
527
  }
492
528
 
493
529
  const totalReplacements = details?.totalReplacements ?? 0;
@@ -502,9 +538,18 @@ export const astEditToolRenderer = {
502
538
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
503
539
  if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
504
540
  const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
505
- const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
506
- appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme, details?.parseErrorsTotal);
507
- return new Text(lines.join("\n"), 0, 0);
541
+ // The "0 replacements" count already rides on the status line; only parse
542
+ // errors are worth a body, so frame solely when there are some.
543
+ const bodyLines: string[] = [];
544
+ appendParseErrorsBulletList(bodyLines, details?.parseErrors, uiTheme, details?.parseErrorsTotal);
545
+ if (bodyLines.length === 0) return new Text(header, 0, 0);
546
+ return framedBlock(uiTheme, width => ({
547
+ header,
548
+ sections: [{ lines: bodyLines }],
549
+ state: "warning",
550
+ borderColor: "borderMuted",
551
+ width,
552
+ }));
508
553
  }
509
554
 
510
555
  const summaryParts = [formatCount("replacement", totalReplacements), formatCount("file", filesTouched)];
@@ -516,10 +561,33 @@ export const astEditToolRenderer = {
516
561
  const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
517
562
 
518
563
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
519
- const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
520
- const changeGroups = allGroups.filter(
521
- group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
522
- );
564
+ const allLines = textContent.split("\n");
565
+ // Resolve hyperlinks over the whole output so nested directory headers
566
+ // reconstruct across the blank-line groups the tree list collapses by.
567
+ const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
568
+ const styledLines = allLines.map((line, index) => {
569
+ const ctx = contexts[index]!;
570
+ // Swap the inner code-frame gutter `│` for a space so it does not nest a
571
+ // second vertical bar inside the frame border.
572
+ const display = replaceTabs(line.replace("│", " "));
573
+ if (ctx.kind === "dir") {
574
+ const styled = uiTheme.fg("accent", display);
575
+ return ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled;
576
+ }
577
+ if (ctx.kind === "file") {
578
+ const styled = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", display);
579
+ return ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled;
580
+ }
581
+ if (display.startsWith("+")) return uiTheme.fg("toolDiffAdded", display);
582
+ if (display.startsWith("-")) return uiTheme.fg("toolDiffRemoved", display);
583
+ return uiTheme.fg("toolOutput", display);
584
+ });
585
+ const changeGroups = groupLineIndicesByBlank(allLines)
586
+ .filter(indices => {
587
+ const first = allLines[indices[0]!]!;
588
+ return !first.startsWith("Safety cap reached") && !first.startsWith("Parse issues:");
589
+ })
590
+ .map(indices => indices.map(index => styledLines[index]!));
523
591
 
524
592
  const badge = { label: "proposed", color: "warning" as const };
525
593
  const header = renderStatusLine(
@@ -536,60 +604,19 @@ export const astEditToolRenderer = {
536
604
  uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors, details.parseErrorsTotal)),
537
605
  );
538
606
  }
539
- return createCachedComponent(
540
- () => options.expanded,
541
- width => {
542
- const searchBase = details?.searchPath;
543
- const changeLines = renderTreeList(
544
- {
545
- items: changeGroups,
546
- expanded: options.expanded,
547
- maxCollapsed: changeGroups.length,
548
- maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
549
- itemType: "change",
550
- renderItem: group => {
551
- let contextDir = searchBase ?? "";
552
- return group.map(line => {
553
- if (line.startsWith("## ")) {
554
- // Strip ` (3 replacements)` and `#hash` suffixes from formatGroupedFiles.
555
- const fileName = line
556
- .slice(3)
557
- .trimEnd()
558
- .replace(/\s+\([^)]*\)\s*$/, "")
559
- .replace(/#[0-9a-f]+$/, "");
560
- const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
561
- const styled = uiTheme.fg("dim", line);
562
- return absPath ? fileHyperlink(absPath, styled) : styled;
563
- }
564
- if (line.startsWith("# ")) {
565
- const raw = line
566
- .slice(2)
567
- .trimEnd()
568
- .replace(/\s+\([^)]*\)\s*$/, "");
569
- const isDirectory = raw.endsWith("/");
570
- const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
571
- if (isDirectory) {
572
- if (searchBase) {
573
- contextDir = name === "." ? searchBase : path.join(searchBase, name);
574
- }
575
- return uiTheme.fg("accent", line);
576
- }
577
- // Root-level file with optional `#hash` and ` (3 replacements)` suffixes.
578
- const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
579
- const styled = uiTheme.fg("accent", line);
580
- return absPath ? fileHyperlink(absPath, styled) : styled;
581
- }
582
- if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line);
583
- if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line);
584
- return uiTheme.fg("toolOutput", line);
585
- });
586
- },
587
- },
588
- uiTheme,
589
- );
590
- return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
591
- },
592
- );
607
+ return framedBlock(uiTheme, width => {
608
+ const changeLines = buildChangeBody(changeGroups, Boolean(options.expanded), COLLAPSED_CHANGE_LIMIT, uiTheme);
609
+ const innerWidth = Math.max(1, width - 3);
610
+ const bodyLines = [...changeLines, ...extraLines].map(l => truncateToWidth(l, innerWidth, Ellipsis.Omit));
611
+ while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
612
+ return {
613
+ header,
614
+ sections: bodyLines.length > 0 ? [{ lines: bodyLines }] : [],
615
+ state: options.isPartial ? "pending" : "success",
616
+ borderColor: "borderMuted",
617
+ width,
618
+ };
619
+ });
593
620
  },
594
621
  mergeCallAndResult: true,
595
622
  };
@@ -14,7 +14,7 @@ import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWi
14
14
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
15
  import type { ToolSession } from ".";
16
16
  import { createFileRecorder, formatResultPath } from "./file-recorder";
17
- import { formatGroupedFiles } from "./grouped-file-output";
17
+ import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
18
18
  import { formatMatchLine } from "./match-line-format";
19
19
  import type { OutputMeta } from "./output-meta";
20
20
  import { resolveToolSearchScope } from "./path-utils";
@@ -29,7 +29,6 @@ import {
29
29
  formatParseErrors,
30
30
  formatParseErrorsCountLabel,
31
31
  PREVIEW_LIMITS,
32
- splitGroupsByBlankLine,
33
32
  } from "./render-utils";
34
33
  import { ToolError } from "./tool-errors";
35
34
  import { toolResult } from "./tool-result";
@@ -118,6 +117,9 @@ export interface AstGrepToolDetails {
118
117
  /** Absolute base directory used during search. Used by the renderer to resolve
119
118
  * display-relative paths to absolute paths for OSC 8 hyperlinks. */
120
119
  searchPath?: string;
120
+ /** Session cwd at search time. Display header/match paths are cwd-relative, so
121
+ * the renderer resolves them against this; `searchPath` is the scope target. */
122
+ cwd?: string;
121
123
  }
122
124
 
123
125
  export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolDetails> {
@@ -207,6 +209,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
207
209
  ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}),
208
210
  scopePath,
209
211
  searchPath: resolvedSearchPath,
212
+ cwd: this.session.cwd,
210
213
  files: fileList,
211
214
  fileMatches: [],
212
215
  };
@@ -381,15 +384,41 @@ export const astGrepToolRenderer = {
381
384
  if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
382
385
  const description = args?.pat;
383
386
  const header = renderStatusLine(
384
- { icon: limitReached ? "warning" : "success", title: "AST Grep", description, meta },
387
+ {
388
+ ...(limitReached
389
+ ? { icon: "warning" as const }
390
+ : { iconOverride: uiTheme.fg("accent", uiTheme.symbol("icon.search")) }),
391
+ title: "AST Grep",
392
+ description,
393
+ meta,
394
+ },
385
395
  uiTheme,
386
396
  );
387
397
 
388
398
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
389
- const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
390
- const matchGroups = allGroups.filter(
391
- group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
392
- );
399
+ const allLines = textContent.split("\n");
400
+ // Resolve hyperlinks over the whole output so nested directory headers
401
+ // reconstruct across the blank-line groups the tree list collapses by.
402
+ const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
403
+ const styledLines = allLines.map((line, index) => {
404
+ const ctx = contexts[index]!;
405
+ if (ctx.kind === "dir") {
406
+ const styled = uiTheme.fg("accent", line);
407
+ return ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled;
408
+ }
409
+ if (ctx.kind === "file") {
410
+ const styled = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
411
+ return ctx.headerPath ? fileHyperlink(ctx.headerPath, styled) : styled;
412
+ }
413
+ if (line.startsWith(" meta:")) return uiTheme.fg("dim", line);
414
+ return uiTheme.fg("toolOutput", line);
415
+ });
416
+ const matchGroups = groupLineIndicesByBlank(allLines)
417
+ .filter(indices => {
418
+ const first = allLines[indices[0]!]!;
419
+ return !first.startsWith("Result limit reached") && !first.startsWith("Parse issues:");
420
+ })
421
+ .map(indices => indices.map(index => styledLines[index]!));
393
422
 
394
423
  const extraLines: string[] = [];
395
424
  if (limitReached) {
@@ -404,7 +433,6 @@ export const astGrepToolRenderer = {
404
433
  return createCachedComponent(
405
434
  () => options.expanded,
406
435
  width => {
407
- const searchBase = details?.searchPath;
408
436
  const matchLines = renderTreeList(
409
437
  {
410
438
  items: matchGroups,
@@ -412,41 +440,7 @@ export const astGrepToolRenderer = {
412
440
  maxCollapsed: matchGroups.length,
413
441
  maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
414
442
  itemType: "match",
415
- renderItem: group => {
416
- let contextDir = searchBase ?? "";
417
- return group.map(line => {
418
- if (line.startsWith("## ")) {
419
- const fileName = line
420
- .slice(3)
421
- .trimEnd()
422
- .replace(/\s+\([^)]*\)\s*$/, "")
423
- .replace(/#[0-9a-f]+$/, "");
424
- const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined;
425
- const styled = uiTheme.fg("dim", line);
426
- return absPath ? fileHyperlink(absPath, styled) : styled;
427
- }
428
- if (line.startsWith("# ")) {
429
- const raw = line
430
- .slice(2)
431
- .trimEnd()
432
- .replace(/\s+\([^)]*\)\s*$/, "");
433
- const isDirectory = raw.endsWith("/");
434
- const name = isDirectory ? raw.replace(/\/$/, "") : raw.replace(/#[0-9a-f]+$/, "");
435
- if (isDirectory) {
436
- if (searchBase) {
437
- contextDir = name === "." ? searchBase : path.join(searchBase, name);
438
- }
439
- return uiTheme.fg("accent", line);
440
- }
441
- // Root-level file (single # without trailing slash) from formatGroupedFiles.
442
- const absPath = searchBase && name ? path.join(searchBase, name) : undefined;
443
- const styled = uiTheme.fg("accent", line);
444
- return absPath ? fileHyperlink(absPath, styled) : styled;
445
- }
446
- if (line.startsWith(" meta:")) return uiTheme.fg("dim", line);
447
- return uiTheme.fg("toolOutput", line);
448
- });
449
- },
443
+ renderItem: group => group,
450
444
  },
451
445
  uiTheme,
452
446
  );
package/src/tools/bash.ts CHANGED
@@ -287,6 +287,35 @@ function formatExitCodeNotice(exitCode: number): string {
287
287
  return `Command exited with code ${exitCode}`;
288
288
  }
289
289
 
290
+ const RAW_OUTPUT_ARTIFACT_PREFIX = "[raw output: artifact://";
291
+ const RAW_OUTPUT_ARTIFACT_SUFFIX = "]";
292
+
293
+ function stripRawOutputArtifactNotice(text: string): { text: string; artifactId?: string } {
294
+ const trimmed = text.trimEnd();
295
+ const lineStart = trimmed.lastIndexOf("\n");
296
+ const candidateStart = lineStart === -1 ? 0 : lineStart + 1;
297
+ if (
298
+ !trimmed.startsWith(RAW_OUTPUT_ARTIFACT_PREFIX, candidateStart) ||
299
+ !trimmed.endsWith(RAW_OUTPUT_ARTIFACT_SUFFIX)
300
+ ) {
301
+ return { text };
302
+ }
303
+
304
+ const idStart = candidateStart + RAW_OUTPUT_ARTIFACT_PREFIX.length;
305
+ const idEnd = trimmed.length - RAW_OUTPUT_ARTIFACT_SUFFIX.length;
306
+ if (idStart === idEnd) return { text };
307
+ for (let i = idStart; i < idEnd; i++) {
308
+ const code = trimmed.charCodeAt(i);
309
+ if (code < 48 || code > 57) return { text };
310
+ }
311
+
312
+ const artifactId = trimmed.slice(idStart, idEnd);
313
+ return {
314
+ text: trimmed.slice(0, lineStart === -1 ? 0 : lineStart).trimEnd(),
315
+ artifactId,
316
+ };
317
+ }
318
+
290
319
  /**
291
320
  * Strip the trailing occurrence of `notice` (plus a single surrounding newline
292
321
  * on each side) so the TUI can echo the value via a styled footer label
@@ -1028,6 +1057,7 @@ export interface ShellRendererConfig<TArgs> {
1028
1057
  resolveCommand?: (args: TArgs | undefined) => string | undefined;
1029
1058
  resolveCwd?: (args: TArgs | undefined) => string | undefined;
1030
1059
  resolveEnv?: (args: TArgs | undefined) => Record<string, string> | undefined;
1060
+ showHeader?: boolean;
1031
1061
  }
1032
1062
 
1033
1063
  function getPartialJson<TArgs>(args: TArgs | undefined): string | undefined {
@@ -1079,9 +1109,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1079
1109
  return {
1080
1110
  renderCall(args: TArgs, options: RenderResultOptions, uiTheme: Theme): Component {
1081
1111
  const renderArgs = toBashRenderArgs(args, config);
1082
- const title = config.resolveTitle(args, options);
1083
1112
  const cmdLines = formatBashCommandLines(renderArgs, uiTheme);
1084
- const header = renderStatusLine({ icon: "pending", title }, uiTheme);
1113
+ const header =
1114
+ config.showHeader === false
1115
+ ? undefined
1116
+ : renderStatusLine({ icon: "pending", title: config.resolveTitle(args, options) }, uiTheme);
1085
1117
  const outputBlock = new CachedOutputBlock();
1086
1118
  return markFramedBlockComponent({
1087
1119
  render: (width: number): string[] =>
@@ -1115,8 +1147,10 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1115
1147
  const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
1116
1148
  const isError = result.isError === true;
1117
1149
  const icon = options.isPartial ? "pending" : isError ? "error" : "success";
1118
- const title = config.resolveTitle(args, options);
1119
- const header = renderStatusLine({ icon, title }, uiTheme);
1150
+ const header =
1151
+ config.showHeader === false
1152
+ ? undefined
1153
+ : renderStatusLine({ icon, title: config.resolveTitle(args, options) }, uiTheme);
1120
1154
  const details = result.details;
1121
1155
  const outputBlock = new CachedOutputBlock();
1122
1156
 
@@ -1133,7 +1167,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1133
1167
  const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
1134
1168
  const strippedOutput = stripOutputNotice(rawOutput, details?.meta);
1135
1169
  const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
1136
- const output = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
1170
+ const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
1171
+ const rawOutputArtifact = stripRawOutputArtifactNotice(withoutWall);
1172
+ const output = rawOutputArtifact.text;
1137
1173
  const displayOutput = output.trimEnd();
1138
1174
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
1139
1175
 
@@ -1152,6 +1188,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1152
1188
  : `Timeout: ${timeoutSeconds}s`,
1153
1189
  );
1154
1190
  }
1191
+ if (rawOutputArtifact.artifactId) {
1192
+ statsParts.push(`Artifact: ${rawOutputArtifact.artifactId}`);
1193
+ }
1155
1194
  if (isError && typeof details?.exitCode === "number") {
1156
1195
  statsParts.push(`Exit: ${details.exitCode}`);
1157
1196
  }
@@ -1215,7 +1254,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1215
1254
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1216
1255
  ],
1217
1256
  width,
1218
- animate: options.isPartial && shimmerEnabled(),
1257
+ // Don't animate once the command has been backgrounded: the block
1258
+ // gets committed to scrollback and finalizes later via the async
1259
+ // update path, so a mid-sweep frame would freeze a stray dark
1260
+ // border segment.
1261
+ animate: options.isPartial && shimmerEnabled() && details?.async?.state !== "running",
1219
1262
  },
1220
1263
  uiTheme,
1221
1264
  );
@@ -1235,4 +1278,5 @@ export const bashToolRenderer = createShellRenderer<BashRenderArgs>({
1235
1278
  resolveCommand: args => args?.command,
1236
1279
  resolveCwd: args => args?.cwd,
1237
1280
  resolveEnv: args => args?.env,
1281
+ showHeader: false,
1238
1282
  });
@@ -22,6 +22,7 @@ import {
22
22
  type DapFunctionBreakpointRecord,
23
23
  type DapInstructionBreakpointRecord,
24
24
  type DapModule,
25
+ type DapResolvedAdapter,
25
26
  type DapScope,
26
27
  type DapSessionSummary,
27
28
  type DapSource,
@@ -30,6 +31,8 @@ import {
30
31
  type DapVariable,
31
32
  dapSessionManager,
32
33
  getAvailableAdapters,
34
+ type LaunchProgramKind,
35
+ resolveLaunchOverrides,
33
36
  selectAttachAdapter,
34
37
  selectLaunchAdapter,
35
38
  } from "../dap";
@@ -489,16 +492,23 @@ function getConfiguredAdapters(cwd: string): string {
489
492
  const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name);
490
493
  return adapters.length > 0 ? adapters.join(", ") : "none";
491
494
  }
492
- async function validateLaunchProgram(program: string, cwd: string): Promise<void> {
493
- let isDirectory: boolean;
495
+
496
+ async function classifyLaunchProgram(program: string): Promise<LaunchProgramKind> {
494
497
  try {
495
- isDirectory = (await fs.stat(program)).isDirectory();
498
+ return (await fs.stat(program)).isDirectory() ? "directory" : "file";
496
499
  } catch (error) {
497
- if (isEnoent(error)) return;
500
+ if (isEnoent(error)) return "missing";
498
501
  throw error;
499
502
  }
500
- if (!isDirectory) return;
503
+ }
501
504
 
505
+ function validateLaunchProgram(
506
+ program: string,
507
+ cwd: string,
508
+ programKind: LaunchProgramKind,
509
+ adapter: DapResolvedAdapter,
510
+ ): void {
511
+ if (programKind !== "directory" || adapter.acceptsDirectoryProgram) return;
502
512
  const displayPath = formatPathRelativeToCwd(program, cwd, { trailingSlash: true });
503
513
  throw new ToolError(
504
514
  `launch program resolves to a directory: ${displayPath}. Pass an executable file path, or for Python use adapter "debugpy" with program set to the .py file.`,
@@ -676,8 +686,8 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
676
686
  }
677
687
  const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
678
688
  const program = resolveToCwd(params.program, commandCwd);
679
- await validateLaunchProgram(program, commandCwd);
680
- const adapter = selectLaunchAdapter(program, commandCwd, params.adapter);
689
+ const programKind = await classifyLaunchProgram(program);
690
+ const adapter = selectLaunchAdapter(program, commandCwd, params.adapter, programKind);
681
691
  if (!adapter) {
682
692
  if (params.adapter === "debugpy") {
683
693
  throw new ToolError("adapter 'debugpy' is not available: python not found in PATH");
@@ -686,8 +696,10 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
686
696
  `No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
687
697
  );
688
698
  }
699
+ validateLaunchProgram(program, commandCwd, programKind, adapter);
700
+ const extraLaunchArguments = resolveLaunchOverrides(adapter, program, programKind);
689
701
  const snapshot = await dapSessionManager.launch(
690
- { adapter, program, args: params.args, cwd: commandCwd },
702
+ { adapter, program, args: params.args, cwd: commandCwd, extraLaunchArguments },
691
703
  combinedSignal,
692
704
  timeoutSec * 1000,
693
705
  );