@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -50,6 +50,24 @@ function getStatusIcon(status: AgentProgress["status"], theme: Theme, spinnerFra
50
50
  }
51
51
  }
52
52
 
53
+ /** Append tool-count, token, and cost stats to a status line string. */
54
+ function appendAgentStats(
55
+ line: string,
56
+ opts: { toolCount?: number; tokens: number; cost: number },
57
+ theme: Theme,
58
+ ): string {
59
+ if (opts.toolCount) {
60
+ line += `${theme.sep.dot}${theme.fg("dim", `${opts.toolCount} tools`)}`;
61
+ }
62
+ if (opts.tokens > 0) {
63
+ line += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(opts.tokens)} tokens`)}`;
64
+ }
65
+ if (opts.cost > 0) {
66
+ line += `${theme.sep.dot}${theme.fg("statusLineCost", `$${opts.cost.toFixed(2)}`)}`;
67
+ }
68
+ return line;
69
+ }
70
+
53
71
  function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): string {
54
72
  if (findings.length === 0) return theme.fg("dim", "Findings: none");
55
73
 
@@ -526,19 +544,9 @@ function renderAgentProgress(
526
544
  const taskPreview = truncateToWidth(progress.assignment ?? progress.task, 40);
527
545
  statusLine += ` ${theme.fg("muted", taskPreview)}`;
528
546
  }
529
- if (progress.toolCount > 0) {
530
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`;
531
- }
532
- if (progress.tokens > 0) {
533
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`;
534
- }
547
+ statusLine = appendAgentStats(statusLine, progress, theme);
535
548
  } else if (progress.status === "completed") {
536
- if (progress.toolCount > 0) {
537
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${progress.toolCount} tools`)}`;
538
- }
539
- if (progress.tokens > 0) {
540
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(progress.tokens)} tokens`)}`;
541
- }
549
+ statusLine = appendAgentStats(statusLine, progress, theme);
542
550
  }
543
551
 
544
552
  lines.push(statusLine);
@@ -768,9 +776,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
768
776
  iconColor,
769
777
  theme,
770
778
  )}`;
771
- if (result.tokens > 0) {
772
- statusLine += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(result.tokens)} tokens`)}`;
773
- }
779
+ statusLine = appendAgentStats(statusLine, { tokens: result.tokens, cost: result.usage?.cost.total ?? 0 }, theme);
774
780
  statusLine += `${theme.sep.dot}${theme.fg("dim", formatDuration(result.durationMs))}`;
775
781
 
776
782
  if (result.truncated) {
package/src/task/types.ts CHANGED
@@ -217,7 +217,10 @@ export interface AgentProgress {
217
217
  recentTools: Array<{ tool: string; args: string; endMs: number }>;
218
218
  recentOutput: string[];
219
219
  toolCount: number;
220
+ /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
220
221
  tokens: number;
222
+ /** Cumulative billing cost in USD, accumulated incrementally from message_end events. */
223
+ cost: number;
221
224
  durationMs: number;
222
225
  modelOverride?: string | string[];
223
226
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
@@ -239,6 +242,7 @@ export interface SingleResult {
239
242
  stderr: string;
240
243
  truncated: boolean;
241
244
  durationMs: number;
245
+ /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
242
246
  tokens: number;
243
247
  modelOverride?: string | string[];
244
248
  error?: string;
@@ -7,33 +7,27 @@ import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
9
  import { computeLineHash, HL_BODY_SEP } from "../hashline/hash";
10
- import { InternalUrlRouter } from "../internal-urls";
11
10
  import type { Theme } from "../modes/theme/theme";
12
11
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
13
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
+ import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
13
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
15
14
  import type { ToolSession } from ".";
16
15
  import { createFileRecorder, formatResultPath } from "./file-recorder";
17
16
  import { formatGroupedFiles } from "./grouped-file-output";
18
17
  import type { OutputMeta } from "./output-meta";
18
+ import { resolveToolSearchScope } from "./path-utils";
19
19
  import {
20
- formatPathRelativeToCwd,
21
- hasGlobPathChars,
22
- normalizePathLikeInput,
23
- parseSearchPath,
24
- partitionExistingPaths,
25
- resolveExplicitSearchPaths,
26
- resolveToCwd,
27
- } from "./path-utils";
28
- import {
20
+ appendParseErrorsBulletList,
21
+ createCachedComponent,
29
22
  dedupeParseErrors,
30
23
  formatCodeFrameLine,
31
24
  formatCount,
32
25
  formatEmptyMessage,
33
26
  formatErrorMessage,
34
27
  formatParseErrors,
35
- PARSE_ERRORS_LIMIT,
28
+ formatParseErrorsCountLabel,
36
29
  PREVIEW_LIMITS,
30
+ splitGroupsByBlankLine,
37
31
  } from "./render-utils";
38
32
  import { queueResolveHandler } from "./resolve";
39
33
  import { ToolError } from "./tool-errors";
@@ -205,63 +199,12 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
205
199
  const normalizedRewrites = Object.fromEntries(ops);
206
200
  const maxFiles = $envpos("PI_MAX_AST_FILES", 1000);
207
201
 
208
- const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
209
- let searchPath: string;
210
- let scopePath: string;
211
- let globFilter: string | undefined;
212
- let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
213
- const rawPaths = params.paths.map(normalizePathLikeInput);
214
- if (rawPaths.some(rawPath => rawPath.length === 0)) {
215
- throw new ToolError("`paths` must contain non-empty paths or globs");
216
- }
217
- const internalRouter = InternalUrlRouter.instance();
218
- const resolvedPathInputs: string[] = [];
219
- for (const rawPath of rawPaths) {
220
- if (!internalRouter.canHandle(rawPath)) {
221
- resolvedPathInputs.push(rawPath);
222
- continue;
223
- }
224
- if (hasGlobPathChars(rawPath)) {
225
- throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
226
- }
227
- const resource = await internalRouter.resolve(rawPath);
228
- if (!resource.sourcePath) {
229
- throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
230
- }
231
- resolvedPathInputs.push(resource.sourcePath);
232
- }
233
- let effectivePathInputs = resolvedPathInputs;
234
- if (resolvedPathInputs.length > 1) {
235
- const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
236
- if (partition.valid.length === 0) {
237
- throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
238
- }
239
- effectivePathInputs = partition.valid;
240
- }
241
- if (effectivePathInputs.length === 1) {
242
- const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
243
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
244
- globFilter = parsedPath.glob;
245
- scopePath = formatScopePath(searchPath);
246
- } else {
247
- const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
248
- if (!multiSearchPath) {
249
- throw new ToolError("`paths` must contain at least one path or glob");
250
- }
251
- searchPath = multiSearchPath.basePath;
252
- globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
253
- multiTargets = multiSearchPath.targets;
254
- scopePath = multiSearchPath.scopePath;
255
- }
256
- const resolvedSearchPath = searchPath;
257
- scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
258
- let isDirectory: boolean;
259
- try {
260
- const stat = await Bun.file(resolvedSearchPath).stat();
261
- isDirectory = stat.isDirectory();
262
- } catch {
263
- throw new ToolError(`Path not found: ${scopePath}`);
264
- }
202
+ const scope = await resolveToolSearchScope({
203
+ rawPaths: params.paths,
204
+ cwd: this.session.cwd,
205
+ internalUrlAction: "rewrite",
206
+ });
207
+ const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
265
208
 
266
209
  const result = await runAstEditOnce(multiTargets, resolvedSearchPath, globFilter, {
267
210
  rewrites: normalizedRewrites,
@@ -502,15 +445,7 @@ export const astEditToolRenderer = {
502
445
  if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
503
446
  const header = renderStatusLine({ icon: "warning", title: "AST Edit", description, meta }, uiTheme);
504
447
  const lines = [header, formatEmptyMessage("No replacements made", uiTheme)];
505
- if (details?.parseErrors?.length) {
506
- const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
507
- for (const err of capped) {
508
- lines.push(uiTheme.fg("warning", ` - ${err}`));
509
- }
510
- if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
511
- lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
512
- }
513
- }
448
+ appendParseErrorsBulletList(lines, details?.parseErrors, uiTheme);
514
449
  return new Text(lines.join("\n"), 0, 0);
515
450
  }
516
451
 
@@ -523,28 +458,7 @@ export const astEditToolRenderer = {
523
458
  const description = rewriteCount === 1 ? args?.ops?.[0]?.pat : undefined;
524
459
 
525
460
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
526
- const rawLines = textContent.split("\n");
527
- const hasSeparators = rawLines.some(line => line.trim().length === 0);
528
- const allGroups: string[][] = [];
529
- if (hasSeparators) {
530
- let current: string[] = [];
531
- for (const line of rawLines) {
532
- if (line.trim().length === 0) {
533
- if (current.length > 0) {
534
- allGroups.push(current);
535
- current = [];
536
- }
537
- continue;
538
- }
539
- current.push(line);
540
- }
541
- if (current.length > 0) allGroups.push(current);
542
- } else {
543
- const nonEmpty = rawLines.filter(line => line.trim().length > 0);
544
- if (nonEmpty.length > 0) {
545
- allGroups.push(nonEmpty);
546
- }
547
- }
461
+ const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
548
462
  const changeGroups = allGroups.filter(
549
463
  group => !group[0]?.startsWith("Safety cap reached") && !group[0]?.startsWith("Parse issues:"),
550
464
  );
@@ -560,23 +474,15 @@ export const astEditToolRenderer = {
560
474
  extraLines.push(uiTheme.fg("warning", "limit reached; narrow path"));
561
475
  }
562
476
  if (details?.parseErrors?.length) {
563
- const total = details.parseErrors.length;
564
- const label =
565
- total > PARSE_ERRORS_LIMIT
566
- ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
567
- : `${total} parse issue${total !== 1 ? "s" : ""}`;
568
- extraLines.push(uiTheme.fg("warning", label));
477
+ extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
569
478
  }
570
- let cached: RenderCache | undefined;
571
- return {
572
- render(width: number): string[] {
573
- const { expanded } = options;
574
- const key = new Hasher().bool(expanded).u32(width).digest();
575
- if (cached?.key === key) return cached.lines;
479
+ return createCachedComponent(
480
+ () => options.expanded,
481
+ width => {
576
482
  const changeLines = renderTreeList(
577
483
  {
578
484
  items: changeGroups,
579
- expanded,
485
+ expanded: options.expanded,
580
486
  maxCollapsed: changeGroups.length,
581
487
  maxCollapsedLines: COLLAPSED_CHANGE_LIMIT,
582
488
  itemType: "change",
@@ -591,14 +497,9 @@ export const astEditToolRenderer = {
591
497
  },
592
498
  uiTheme,
593
499
  );
594
- const rendered = [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
595
- cached = { key, lines: rendered };
596
- return rendered;
500
+ return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
597
501
  },
598
- invalidate() {
599
- cached = undefined;
600
- },
601
- };
502
+ );
602
503
  },
603
504
  mergeCallAndResult: true,
604
505
  };
@@ -6,34 +6,28 @@ import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
8
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
- import { InternalUrlRouter } from "../internal-urls";
10
9
  import type { Theme } from "../modes/theme/theme";
11
10
  import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" };
12
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
11
+ import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
13
12
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
14
13
  import type { ToolSession } from ".";
15
14
  import { createFileRecorder, formatResultPath } from "./file-recorder";
16
15
  import { formatGroupedFiles } from "./grouped-file-output";
17
16
  import { formatMatchLine } from "./match-line-format";
18
17
  import type { OutputMeta } from "./output-meta";
18
+ import { resolveToolSearchScope } from "./path-utils";
19
19
  import {
20
- formatPathRelativeToCwd,
21
- hasGlobPathChars,
22
- normalizePathLikeInput,
23
- parseSearchPath,
24
- partitionExistingPaths,
25
- resolveExplicitSearchPaths,
26
- resolveToCwd,
27
- } from "./path-utils";
28
- import {
20
+ appendParseErrorsBulletList,
21
+ createCachedComponent,
29
22
  dedupeParseErrors,
30
23
  formatCodeFrameLine,
31
24
  formatCount,
32
25
  formatEmptyMessage,
33
26
  formatErrorMessage,
34
27
  formatParseErrors,
35
- PARSE_ERRORS_LIMIT,
28
+ formatParseErrorsCountLabel,
36
29
  PREVIEW_LIMITS,
30
+ splitGroupsByBlankLine,
37
31
  } from "./render-utils";
38
32
  import { ToolError } from "./tool-errors";
39
33
  import { toolResult } from "./tool-result";
@@ -150,64 +144,12 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
150
144
  if (!Number.isFinite(skip) || skip < 0) {
151
145
  throw new ToolError("skip must be a non-negative number");
152
146
  }
153
- const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
154
- let searchPath: string;
155
- let scopePath: string;
156
- let globFilter: string | undefined;
157
- let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
158
- const rawPaths = params.paths.map(normalizePathLikeInput);
159
- if (rawPaths.some(rawPath => rawPath.length === 0)) {
160
- throw new ToolError("`paths` must contain non-empty paths or globs");
161
- }
162
- const internalRouter = InternalUrlRouter.instance();
163
- const resolvedPathInputs: string[] = [];
164
- for (const rawPath of rawPaths) {
165
- if (!internalRouter.canHandle(rawPath)) {
166
- resolvedPathInputs.push(rawPath);
167
- continue;
168
- }
169
- if (hasGlobPathChars(rawPath)) {
170
- throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
171
- }
172
- const resource = await internalRouter.resolve(rawPath);
173
- if (!resource.sourcePath) {
174
- throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
175
- }
176
- resolvedPathInputs.push(resource.sourcePath);
177
- }
178
- let effectivePathInputs = resolvedPathInputs;
179
- if (resolvedPathInputs.length > 1) {
180
- const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
181
- if (partition.valid.length === 0) {
182
- throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
183
- }
184
- effectivePathInputs = partition.valid;
185
- }
186
- if (effectivePathInputs.length === 1) {
187
- const parsedPath = parseSearchPath(effectivePathInputs[0] ?? ".");
188
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
189
- globFilter = parsedPath.glob;
190
- scopePath = formatScopePath(searchPath);
191
- } else {
192
- const multiSearchPath = await resolveExplicitSearchPaths(effectivePathInputs, this.session.cwd, globFilter);
193
- if (!multiSearchPath) {
194
- throw new ToolError("`paths` must contain at least one path or glob");
195
- }
196
- searchPath = multiSearchPath.basePath;
197
- globFilter = multiSearchPath.targets ? undefined : multiSearchPath.glob;
198
- multiTargets = multiSearchPath.targets;
199
- scopePath = multiSearchPath.scopePath;
200
- }
201
-
202
- const resolvedSearchPath = searchPath;
203
- scopePath = scopePath ?? formatScopePath(resolvedSearchPath);
204
- let isDirectory: boolean;
205
- try {
206
- const stat = await Bun.file(resolvedSearchPath).stat();
207
- isDirectory = stat.isDirectory();
208
- } catch {
209
- throw new ToolError(`Path not found: ${scopePath}`);
210
- }
147
+ const scope = await resolveToolSearchScope({
148
+ rawPaths: params.paths,
149
+ cwd: this.session.cwd,
150
+ internalUrlAction: "search",
151
+ });
152
+ const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
211
153
 
212
154
  const DEFAULT_AST_LIMIT = 50;
213
155
  const result = multiTargets
@@ -388,13 +330,7 @@ export const astGrepToolRenderer = {
388
330
  const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
389
331
  if (details?.parseErrors?.length) {
390
332
  lines.push(uiTheme.fg("warning", "Query may be mis-scoped; narrow `paths` before concluding absence"));
391
- const capped = details.parseErrors.slice(0, PARSE_ERRORS_LIMIT);
392
- for (const err of capped) {
393
- lines.push(uiTheme.fg("warning", ` - ${err}`));
394
- }
395
- if (details.parseErrors.length > PARSE_ERRORS_LIMIT) {
396
- lines.push(uiTheme.fg("dim", ` … ${details.parseErrors.length - PARSE_ERRORS_LIMIT} more`));
397
- }
333
+ appendParseErrorsBulletList(lines, details.parseErrors, uiTheme);
398
334
  }
399
335
  return new Text(lines.join("\n"), 0, 0);
400
336
  }
@@ -411,28 +347,7 @@ export const astGrepToolRenderer = {
411
347
  );
412
348
 
413
349
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
414
- const rawLines = textContent.split("\n");
415
- const hasSeparators = rawLines.some(line => line.trim().length === 0);
416
- const allGroups: string[][] = [];
417
- if (hasSeparators) {
418
- let current: string[] = [];
419
- for (const line of rawLines) {
420
- if (line.trim().length === 0) {
421
- if (current.length > 0) {
422
- allGroups.push(current);
423
- current = [];
424
- }
425
- continue;
426
- }
427
- current.push(line);
428
- }
429
- if (current.length > 0) allGroups.push(current);
430
- } else {
431
- const nonEmpty = rawLines.filter(line => line.trim().length > 0);
432
- if (nonEmpty.length > 0) {
433
- allGroups.push(nonEmpty);
434
- }
435
- }
350
+ const allGroups = splitGroupsByBlankLine(textContent.split("\n"));
436
351
  const matchGroups = allGroups.filter(
437
352
  group => !group[0]?.startsWith("Result limit reached") && !group[0]?.startsWith("Parse issues:"),
438
353
  );
@@ -442,24 +357,16 @@ export const astGrepToolRenderer = {
442
357
  extraLines.push(uiTheme.fg("warning", "limit reached; narrow paths or increase limit"));
443
358
  }
444
359
  if (details?.parseErrors?.length) {
445
- const total = details.parseErrors.length;
446
- const label =
447
- total > PARSE_ERRORS_LIMIT
448
- ? `${PARSE_ERRORS_LIMIT} / ${total} parse issues`
449
- : `${total} parse issue${total !== 1 ? "s" : ""}`;
450
- extraLines.push(uiTheme.fg("warning", label));
360
+ extraLines.push(uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors)));
451
361
  }
452
362
 
453
- let cached: RenderCache | undefined;
454
- return {
455
- render(width: number): string[] {
456
- const { expanded } = options;
457
- const key = new Hasher().bool(expanded).u32(width).digest();
458
- if (cached?.key === key) return cached.lines;
363
+ return createCachedComponent(
364
+ () => options.expanded,
365
+ width => {
459
366
  const matchLines = renderTreeList(
460
367
  {
461
368
  items: matchGroups,
462
- expanded,
369
+ expanded: options.expanded,
463
370
  maxCollapsed: matchGroups.length,
464
371
  maxCollapsedLines: COLLAPSED_MATCH_LIMIT,
465
372
  itemType: "match",
@@ -473,14 +380,9 @@ export const astGrepToolRenderer = {
473
380
  },
474
381
  uiTheme,
475
382
  );
476
- const rendered = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
477
- cached = { key, lines: rendered };
478
- return rendered;
383
+ return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
479
384
  },
480
- invalidate() {
481
- cached = undefined;
482
- },
483
- };
385
+ );
484
386
  },
485
387
  mergeCallAndResult: true,
486
388
  };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Conservative transforms applied to a bash command before execution.
3
+ *
4
+ * Two fixups are applied, each anchored to the end of a top-level segment
5
+ * (segments split on `;`, `&&`, `||`, and background `&`):
6
+ *
7
+ * 1. Trailing `| head [args]` / `| tail [args]` (and the `|&` variant) — these
8
+ * pipes exist purely to limit output length. The harness already truncates
9
+ * bash output and exposes the full result via an artifact, so they only
10
+ * hide content the agent wanted.
11
+ *
12
+ * 2. A redundant trailing `2>&1` left on a segment that has no remaining pipe
13
+ * or other redirect. The harness already merges stderr into stdout, so the
14
+ * duplication is purely cosmetic — and often a leftover after fixup (1)
15
+ * drops a downstream pipe.
16
+ *
17
+ * The heavy lifting (tokenization, quoting, heredoc handling, command
18
+ * substitution, nested compound commands) lives in Rust under
19
+ * `pi_shell::fixup`, driven by the real `brush-parser` AST. This module is a
20
+ * thin sync wrapper plus user-facing notice formatting.
21
+ */
22
+ import { applyBashFixups as nativeApplyBashFixups } from "@oh-my-pi/pi-natives";
23
+
24
+ export interface BashFixupResult {
25
+ /** Possibly-rewritten command. */
26
+ command: string;
27
+ /** Substrings that were stripped, in the order they were removed. */
28
+ stripped: string[];
29
+ }
30
+
31
+ /**
32
+ * Apply both fixups to a bash command. On any parse failure, multi-line input,
33
+ * or no-op transform, returns the input verbatim with `stripped: []`.
34
+ */
35
+ export function applyBashFixups(command: string): BashFixupResult {
36
+ return nativeApplyBashFixups(command);
37
+ }
38
+
39
+ /**
40
+ * Human-readable notice for the fixups that fired. Mirrors the shape of
41
+ * `formatTimeoutClampNotice` so it can ride alongside the other bash notices.
42
+ */
43
+ export function formatBashFixupNotice(stripped: readonly string[]): string | undefined {
44
+ if (!stripped.length) return undefined;
45
+ const quoted = stripped.map(s => `\`${s}\``).join(", ");
46
+ return `<system-warning>Stripped redundant ${quoted} — bash output is already truncated and stderr is already merged into stdout. NEVER use these patterns.</system-warning>`;
47
+ }
@@ -12,10 +12,12 @@ import {
12
12
  } from "@oh-my-pi/pi-tui";
13
13
  import type { Terminal as XtermTerminalType } from "@xterm/headless";
14
14
  import xterm from "@xterm/headless";
15
+ import { Settings } from "../config/settings";
15
16
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
16
17
  import type { Theme } from "../modes/theme/theme";
17
18
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
18
19
  import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
20
+ import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
19
21
  import { formatStatusIcon, replaceTabs } from "./render-utils";
20
22
 
21
23
  export interface BashInteractiveResult extends OutputSummary {
@@ -294,7 +296,13 @@ export async function runInteractiveBashPty(
294
296
  artifactId?: string;
295
297
  },
296
298
  ): Promise<BashInteractiveResult> {
297
- const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
299
+ const settings = await Settings.init();
300
+ const sink = new OutputSink({
301
+ artifactPath: options.artifactPath,
302
+ artifactId: options.artifactId,
303
+ headBytes: resolveOutputSinkHeadBytes(settings),
304
+ maxColumns: resolveOutputMaxColumns(settings),
305
+ });
298
306
  const result = await ui.custom<BashInteractiveResult>(
299
307
  (tui, uiTheme, _keybindings, done) => {
300
308
  const session = new PtySession();