@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.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 (140) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  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/commit/agentic/tools/propose-changelog.ts +8 -1
  14. package/src/commit/analysis/conventional.ts +8 -66
  15. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  16. package/src/commit/pipeline.ts +2 -2
  17. package/src/commit/shared-llm.ts +89 -0
  18. package/src/config/config-file.ts +210 -0
  19. package/src/config/model-equivalence.ts +8 -11
  20. package/src/config/model-registry.ts +13 -2
  21. package/src/config/model-resolver.ts +1 -4
  22. package/src/config/settings-schema.ts +71 -1
  23. package/src/config/settings.ts +1 -1
  24. package/src/config.ts +3 -219
  25. package/src/edit/renderer.ts +7 -1
  26. package/src/eval/js/executor.ts +3 -0
  27. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  28. package/src/eval/py/executor.ts +5 -0
  29. package/src/exa/factory.ts +2 -2
  30. package/src/exa/mcp-client.ts +74 -1
  31. package/src/exec/bash-executor.ts +5 -1
  32. package/src/export/html/template.generated.ts +1 -1
  33. package/src/export/html/template.js +0 -11
  34. package/src/extensibility/extensions/runner.ts +1 -1
  35. package/src/extensibility/extensions/types.ts +89 -223
  36. package/src/extensibility/hooks/types.ts +89 -314
  37. package/src/extensibility/shared-events.ts +343 -0
  38. package/src/extensibility/skills.ts +9 -0
  39. package/src/goals/index.ts +3 -0
  40. package/src/goals/runtime.ts +500 -0
  41. package/src/goals/state.ts +37 -0
  42. package/src/goals/tools/goal-tool.ts +237 -0
  43. package/src/hashline/anchors.ts +2 -2
  44. package/src/hindsight/mental-models.ts +1 -1
  45. package/src/internal-urls/agent-protocol.ts +1 -20
  46. package/src/internal-urls/artifact-protocol.ts +1 -19
  47. package/src/internal-urls/docs-index.generated.ts +5 -6
  48. package/src/internal-urls/registry-helpers.ts +25 -0
  49. package/src/main.ts +11 -2
  50. package/src/mcp/oauth-flow.ts +20 -0
  51. package/src/modes/acp/acp-agent.ts +79 -45
  52. package/src/modes/components/assistant-message.ts +14 -8
  53. package/src/modes/components/bash-execution.ts +24 -63
  54. package/src/modes/components/custom-message.ts +14 -40
  55. package/src/modes/components/eval-execution.ts +27 -57
  56. package/src/modes/components/execution-shared.ts +102 -0
  57. package/src/modes/components/hook-message.ts +17 -49
  58. package/src/modes/components/mcp-add-wizard.ts +26 -5
  59. package/src/modes/components/message-frame.ts +88 -0
  60. package/src/modes/components/model-selector.ts +1 -1
  61. package/src/modes/components/session-observer-overlay.ts +6 -2
  62. package/src/modes/components/session-selector.ts +1 -1
  63. package/src/modes/components/status-line/segments.ts +55 -4
  64. package/src/modes/components/status-line/types.ts +4 -0
  65. package/src/modes/components/status-line.ts +28 -10
  66. package/src/modes/components/tool-execution.ts +7 -8
  67. package/src/modes/controllers/command-controller-shared.ts +108 -0
  68. package/src/modes/controllers/command-controller.ts +13 -4
  69. package/src/modes/controllers/event-controller.ts +36 -7
  70. package/src/modes/controllers/input-controller.ts +13 -0
  71. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  72. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  73. package/src/modes/interactive-mode.ts +624 -52
  74. package/src/modes/print-mode.ts +16 -86
  75. package/src/modes/rpc/rpc-mode.ts +14 -87
  76. package/src/modes/runtime-init.ts +115 -0
  77. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  78. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  79. package/src/modes/theme/theme.ts +18 -6
  80. package/src/modes/types.ts +14 -3
  81. package/src/modes/utils/context-usage.ts +13 -13
  82. package/src/modes/utils/ui-helpers.ts +10 -3
  83. package/src/plan-mode/approved-plan.ts +35 -1
  84. package/src/prompts/goals/goal-budget-limit.md +16 -0
  85. package/src/prompts/goals/goal-continuation.md +28 -0
  86. package/src/prompts/goals/goal-mode-active.md +23 -0
  87. package/src/prompts/system/plan-mode-active.md +5 -5
  88. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  89. package/src/prompts/tools/bash.md +6 -0
  90. package/src/prompts/tools/goal.md +13 -0
  91. package/src/prompts/tools/hashline.md +102 -114
  92. package/src/prompts/tools/read.md +1 -0
  93. package/src/prompts/tools/resolve.md +6 -5
  94. package/src/sdk.ts +12 -5
  95. package/src/session/agent-session.ts +428 -106
  96. package/src/session/blob-store.ts +36 -3
  97. package/src/session/messages.ts +67 -2
  98. package/src/session/session-manager.ts +131 -12
  99. package/src/session/session-storage.ts +33 -15
  100. package/src/session/streaming-output.ts +309 -13
  101. package/src/slash-commands/builtin-registry.ts +18 -0
  102. package/src/ssh/ssh-executor.ts +5 -0
  103. package/src/system-prompt.ts +4 -2
  104. package/src/task/executor.ts +17 -7
  105. package/src/task/index.ts +3 -0
  106. package/src/task/render.ts +21 -15
  107. package/src/task/types.ts +4 -0
  108. package/src/tools/ast-edit.ts +21 -120
  109. package/src/tools/ast-grep.ts +21 -119
  110. package/src/tools/bash-interactive.ts +9 -1
  111. package/src/tools/bash.ts +27 -4
  112. package/src/tools/browser/attach.ts +3 -3
  113. package/src/tools/browser/launch.ts +81 -18
  114. package/src/tools/browser/registry.ts +1 -5
  115. package/src/tools/browser/tab-supervisor.ts +51 -14
  116. package/src/tools/conflict-detect.ts +15 -4
  117. package/src/tools/eval.ts +3 -1
  118. package/src/tools/find.ts +20 -38
  119. package/src/tools/gh.ts +7 -6
  120. package/src/tools/index.ts +22 -11
  121. package/src/tools/inspect-image.ts +3 -10
  122. package/src/tools/output-meta.ts +176 -37
  123. package/src/tools/path-utils.ts +125 -2
  124. package/src/tools/read.ts +516 -233
  125. package/src/tools/render-utils.ts +92 -0
  126. package/src/tools/renderers.ts +2 -0
  127. package/src/tools/resolve.ts +72 -44
  128. package/src/tools/search.ts +120 -186
  129. package/src/tools/write.ts +44 -9
  130. package/src/utils/file-mentions.ts +1 -1
  131. package/src/utils/image-loading.ts +7 -3
  132. package/src/utils/image-resize.ts +32 -43
  133. package/src/vim/parser.ts +0 -17
  134. package/src/vim/render.ts +1 -1
  135. package/src/vim/types.ts +1 -1
  136. package/src/web/search/providers/gemini.ts +35 -95
  137. package/src/prompts/tools/exit-plan-mode.md +0 -6
  138. package/src/tools/exit-plan-mode.ts +0 -97
  139. package/src/utils/fuzzy.ts +0 -108
  140. package/src/utils/image-convert.ts +0 -27
@@ -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
  };
@@ -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();
package/src/tools/bash.ts CHANGED
@@ -9,7 +9,7 @@ import { type BashResult, executeBash } from "../exec/bash-executor";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import { InternalUrlRouter } from "../internal-urls";
11
11
  import { truncateToVisualLines } from "../modes/components/visual-truncate";
12
- import type { Theme } from "../modes/theme/theme";
12
+ import { highlightCode, type Theme } from "../modes/theme/theme";
13
13
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
14
14
  import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
15
15
  import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
@@ -484,8 +484,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
484
484
  const env = normalizeBashEnv(rawEnv);
485
485
 
486
486
  // Extract leading `cd <path> && ...` into cwd when the model ignores the cwd parameter.
487
+ // Constrained to a single line so a `&&` that sits on a later line of a multiline
488
+ // script can't pull the entire script into the "cwd" capture.
487
489
  if (!cwd) {
488
- const cdMatch = command.match(/^cd\s+((?:[^&\\]|\\.)+?)\s*&&\s*/);
490
+ const cdMatch = command.match(/^cd[ \t]+((?:[^&\\\n\r]|\\.)+?)[ \t]*&&[ \t]*/);
489
491
  if (cdMatch) {
490
492
  cwd = cdMatch[1].trim().replace(/^["']|["']$/g, "");
491
493
  command = command.slice(cdMatch[0].length);
@@ -892,6 +894,27 @@ export function formatBashCommand(args: BashRenderArgs): string {
892
894
  return displayWorkdir ? `${prompt} cd ${displayWorkdir} && ${renderedCommand}` : `${prompt} ${renderedCommand}`;
893
895
  }
894
896
 
897
+ /**
898
+ * Returns the bash command formatted for the result body: the dim `$ cd … &&`
899
+ * prefix joined with syntax-highlighted command lines. The prefix is applied
900
+ * only to the first line so multi-line commands display cleanly — terminals
901
+ * reset SGR state at line boundaries, which made the previous single-string
902
+ * `theme.fg("dim", ...)` form render only the first line as dim.
903
+ */
904
+ export function formatBashCommandLines(args: BashRenderArgs, uiTheme: Theme): string[] {
905
+ const command = replaceTabs(args.command || "…");
906
+ const cwd = getProjectDir();
907
+ const displayWorkdir = formatToolWorkingDirectory(args.cwd, cwd);
908
+ const envAssignments = formatBashEnvAssignments(getBashEnvForDisplay(args));
909
+ const prefixParts = ["$"];
910
+ if (displayWorkdir) prefixParts.push(`cd ${displayWorkdir} &&`);
911
+ if (envAssignments) prefixParts.push(envAssignments);
912
+ const prefix = uiTheme.fg("dim", `${prefixParts.join(" ")} `);
913
+ const highlightedLines = highlightCode(command, "bash");
914
+ if (highlightedLines.length === 0) return [prefix.trimEnd()];
915
+ return highlightedLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
916
+ }
917
+
895
918
  function toBashRenderArgs<TArgs>(args: TArgs | undefined, config: ShellRendererConfig<TArgs>): BashRenderArgs {
896
919
  return {
897
920
  command: config.resolveCommand?.(args),
@@ -922,7 +945,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
922
945
  args?: TArgs,
923
946
  ): Component {
924
947
  const renderArgs = toBashRenderArgs(args, config);
925
- const cmdText = args ? formatBashCommand(renderArgs) : undefined;
948
+ const cmdLines = args ? formatBashCommandLines(renderArgs, uiTheme) : undefined;
926
949
  const isError = result.isError === true;
927
950
  const icon = options.isPartial ? "pending" : isError ? "error" : "success";
928
951
  const title = config.resolveTitle(args, options);
@@ -1000,7 +1023,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1000
1023
  header,
1001
1024
  state: options.isPartial ? "pending" : isError ? "error" : "success",
1002
1025
  sections: [
1003
- { lines: cmdText ? [uiTheme.fg("dim", cmdText)] : [] },
1026
+ { lines: cmdLines ?? [] },
1004
1027
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1005
1028
  ],
1006
1029
  width,
@@ -3,7 +3,7 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
3
  import type { Browser, Page } from "puppeteer-core";
4
4
  import { ToolError, throwIfAborted } from "../tool-errors";
5
5
 
6
- export const ATTACH_TARGET_SKIP_PATTERN =
6
+ const ATTACH_TARGET_SKIP_PATTERN =
7
7
  /request[\s_-]?handler|devtools|background[\s_-]?(?:page|host)|service[\s_-]?worker/i;
8
8
 
9
9
  /**
@@ -62,7 +62,7 @@ export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: Abo
62
62
  * accepts both `--flag=value` and `--flag value`). Returns null if absent or
63
63
  * malformed.
64
64
  */
65
- export function findCdpPortInArgs(args: string[]): number | null {
65
+ function findCdpPortInArgs(args: string[]): number | null {
66
66
  for (const arg of args) {
67
67
  const m = /^--remote-debugging-port=(\d+)$/.exec(arg);
68
68
  if (m) {
@@ -80,7 +80,7 @@ export function findCdpPortInArgs(args: string[]): number | null {
80
80
  }
81
81
 
82
82
  /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
83
- export async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
83
+ async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
84
84
  const probeTimeout = AbortSignal.timeout(1500);
85
85
  const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
86
86
  try {