@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.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 (86) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli.js +3610 -3566
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/helpers.d.ts +2 -0
  5. package/dist/types/irc/bus.d.ts +5 -4
  6. package/dist/types/session/agent-session.d.ts +13 -13
  7. package/dist/types/task/discovery.d.ts +5 -2
  8. package/dist/types/task/renderer.d.ts +1 -0
  9. package/dist/types/tools/ast-grep.d.ts +4 -2
  10. package/dist/types/tools/browser/render.d.ts +2 -0
  11. package/dist/types/tools/debug.d.ts +1 -0
  12. package/dist/types/tools/eval-render.d.ts +2 -0
  13. package/dist/types/tools/glob.d.ts +4 -2
  14. package/dist/types/tools/grep.d.ts +4 -3
  15. package/dist/types/tools/path-utils.d.ts +7 -0
  16. package/dist/types/tools/renderers.d.ts +11 -0
  17. package/dist/types/tools/ssh.d.ts +2 -1
  18. package/dist/types/tts/index.d.ts +2 -0
  19. package/dist/types/tts/speakable.d.ts +47 -0
  20. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  21. package/dist/types/tts/streaming-player.d.ts +1 -2
  22. package/dist/types/tts/tts-client.d.ts +11 -10
  23. package/dist/types/tts/tts-protocol.d.ts +7 -0
  24. package/dist/types/tts/vocalizer.d.ts +15 -8
  25. package/dist/types/utils/git.d.ts +2 -0
  26. package/package.json +12 -12
  27. package/src/advisor/__tests__/advisor.test.ts +1 -1
  28. package/src/cli/gallery-fixtures/fs.ts +2 -2
  29. package/src/cli/gallery-fixtures/search.ts +2 -2
  30. package/src/cli.ts +27 -5
  31. package/src/config/model-resolver.ts +31 -10
  32. package/src/config/settings-schema.ts +11 -0
  33. package/src/cursor.ts +1 -1
  34. package/src/discovery/helpers.ts +8 -0
  35. package/src/export/html/tool-views.generated.js +34 -34
  36. package/src/extensibility/custom-tools/loader.ts +3 -3
  37. package/src/extensibility/extensions/loader.ts +10 -3
  38. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  39. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  40. package/src/extensibility/plugins/loader.ts +30 -1
  41. package/src/irc/bus.ts +5 -4
  42. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  43. package/src/modes/components/assistant-message.ts +1 -1
  44. package/src/modes/components/move-overlay.ts +35 -23
  45. package/src/modes/components/tool-execution.ts +45 -12
  46. package/src/modes/components/tree-selector.ts +10 -3
  47. package/src/modes/controllers/event-controller.ts +16 -0
  48. package/src/prompts/goals/goal-mode-context.md +4 -0
  49. package/src/prompts/goals/goal-todo-context.md +10 -2
  50. package/src/prompts/system/speech-rewrite.md +15 -0
  51. package/src/prompts/system/tiny-title-system.md +1 -1
  52. package/src/prompts/system/title-system-marker.md +2 -1
  53. package/src/prompts/system/title-system.md +2 -1
  54. package/src/prompts/tools/ast-grep.md +3 -3
  55. package/src/prompts/tools/glob.md +1 -1
  56. package/src/prompts/tools/grep.md +1 -1
  57. package/src/sdk.ts +8 -5
  58. package/src/session/agent-session.ts +163 -49
  59. package/src/session/session-history-format.ts +6 -2
  60. package/src/task/discovery.ts +25 -2
  61. package/src/task/executor.ts +24 -4
  62. package/src/task/render.ts +26 -12
  63. package/src/task/renderer.ts +1 -0
  64. package/src/task/worktree.ts +144 -16
  65. package/src/tiny/text.ts +109 -17
  66. package/src/tools/ast-grep.ts +20 -17
  67. package/src/tools/bash.ts +16 -8
  68. package/src/tools/browser/render.ts +2 -0
  69. package/src/tools/debug.ts +1 -0
  70. package/src/tools/eval-render.ts +5 -2
  71. package/src/tools/gh-renderer.ts +3 -0
  72. package/src/tools/glob.ts +24 -20
  73. package/src/tools/grep.ts +18 -36
  74. package/src/tools/path-utils.ts +55 -10
  75. package/src/tools/read.ts +9 -2
  76. package/src/tools/renderers.ts +11 -0
  77. package/src/tools/ssh.ts +17 -7
  78. package/src/tts/index.ts +2 -0
  79. package/src/tts/speakable.ts +382 -0
  80. package/src/tts/speech-enhancer.ts +204 -0
  81. package/src/tts/streaming-player.ts +71 -16
  82. package/src/tts/tts-client.ts +11 -10
  83. package/src/tts/tts-protocol.ts +14 -5
  84. package/src/tts/tts-worker.ts +52 -49
  85. package/src/tts/vocalizer.ts +277 -46
  86. package/src/utils/git.ts +8 -0
package/src/tools/bash.ts CHANGED
@@ -1151,22 +1151,30 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1151
1151
  renderCall(args: TArgs, options: RenderResultOptions, uiTheme: Theme): Component {
1152
1152
  const renderArgs = toBashRenderArgs(args, config);
1153
1153
  const cmdLines = formatBashCommandLines(renderArgs, uiTheme);
1154
- const header =
1155
- config.showHeader === false
1156
- ? undefined
1157
- : renderStatusLine({ icon: "pending", title: config.resolveTitle(args, options) }, uiTheme);
1158
1154
  const outputBlock = new CachedOutputBlock();
1159
1155
  return markFramedBlockComponent({
1160
- render: (width: number): readonly string[] =>
1161
- outputBlock.render(
1156
+ render: (width: number): readonly string[] => {
1157
+ const header =
1158
+ config.showHeader === false
1159
+ ? undefined
1160
+ : renderStatusLine(
1161
+ {
1162
+ icon: options.spinnerFrame !== undefined ? "running" : "pending",
1163
+ spinnerFrame: options.spinnerFrame,
1164
+ title: config.resolveTitle(args, options),
1165
+ },
1166
+ uiTheme,
1167
+ );
1168
+ return outputBlock.render(
1162
1169
  {
1163
1170
  header,
1164
- state: "pending",
1171
+ state: options.spinnerFrame !== undefined ? "running" : "pending",
1165
1172
  sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
1166
1173
  width,
1167
1174
  },
1168
1175
  uiTheme,
1169
- ),
1176
+ );
1177
+ },
1170
1178
  invalidate: () => {
1171
1179
  outputBlock.invalidate();
1172
1180
  },
@@ -187,6 +187,8 @@ function extractTextOutput(content: Array<{ type: string; text?: string }> | und
187
187
  }
188
188
 
189
189
  export const browserToolRenderer = {
190
+ animatedPendingPreview: (args: unknown) => (args as BrowserRenderArgs).action === "run",
191
+ animatedPartialResult: (args: unknown) => (args as BrowserRenderArgs).action === "run",
190
192
  renderCall(args: BrowserRenderArgs, options: RenderResultOptions, theme: Theme): Component {
191
193
  const action = args.action;
192
194
  if (action === "run") {
@@ -583,6 +583,7 @@ function summarizeDebugCall(args: DebugRenderArgs): string {
583
583
  }
584
584
 
585
585
  export const debugToolRenderer = {
586
+ animatedPartialResult: true,
586
587
  renderCall(args: DebugRenderArgs, _options: RenderResultOptions, theme: Theme): Component {
587
588
  const text = renderStatusLine({ icon: "pending", title: "Debug", description: summarizeDebugCall(args) }, theme);
588
589
  return new Text(text, 0, 0);
@@ -489,6 +489,8 @@ function formatCellOutputLines(
489
489
  }
490
490
 
491
491
  export const evalToolRenderer = {
492
+ animatedPendingPreview: true,
493
+ animatedPartialResult: true,
492
494
  renderCall(args: EvalRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
493
495
  const cells = getRenderCells(args);
494
496
 
@@ -502,7 +504,7 @@ export const evalToolRenderer = {
502
504
 
503
505
  return markFramedBlockComponent({
504
506
  render: (width: number): readonly string[] => {
505
- const key = `${options.expanded ? 1 : 0}|${previewWindowRows()}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
507
+ const key = `${options.expanded ? 1 : 0}|${options.spinnerFrame ?? "-"}|${previewWindowRows()}|${cells.map(c => `${c.language}:${c.title ?? ""}:${c.code.length}`).join("|")}`;
506
508
  if (cached && cached.key === key && cached.width === width) {
507
509
  return cached.result;
508
510
  }
@@ -518,7 +520,8 @@ export const evalToolRenderer = {
518
520
  index: i,
519
521
  total: cells.length,
520
522
  title: cell.title,
521
- status: "pending",
523
+ status: options.spinnerFrame !== undefined ? "running" : "pending",
524
+ spinnerFrame: options.spinnerFrame,
522
525
  width,
523
526
  // Viewport-sized tail window following the newest streamed code
524
527
  // line; renderResult keeps the same cap so the cell never snaps
@@ -416,6 +416,9 @@ function renderWatchCall(args: GithubToolRenderArgs, options: RenderResultOption
416
416
  }
417
417
 
418
418
  export const githubToolRenderer = {
419
+ // No animatedPendingPreview: renderCall materializes plain Text components
420
+ // once per display rebuild (no render closure), so a live spinner interval
421
+ // would request 30fps repaints while the visible glyph stays frozen.
419
422
  renderCall(args: GithubToolRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
420
423
  const op = typeof args.op === "string" && args.op.trim().length > 0 ? args.op.trim() : undefined;
421
424
  if (op === "run_watch") {
package/src/tools/glob.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  partitionExistingPaths,
27
27
  resolveExplicitFindPatterns,
28
28
  resolveToCwd,
29
+ toPathList,
29
30
  } from "./path-utils";
30
31
  import {
31
32
  createCachedComponent,
@@ -38,11 +39,9 @@ import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
38
39
  import { toolResult } from "./tool-result";
39
40
 
40
41
  const findSchema = type({
41
- paths: type("string")
42
- .describe("glob including search path")
43
- .array()
44
- .atLeastLength(1)
45
- .describe("globs including search paths"),
42
+ "path?": type("string").describe(
43
+ 'glob, file, or directory to search — a single path or a semicolon-delimited list ("src/**/*.ts; test/**/*.ts"). Omitted -> searches the workspace root (".")',
44
+ ),
46
45
  "hidden?": type("boolean").describe("include hidden files"),
47
46
  "gitignore?": type("boolean").describe("respect gitignore"),
48
47
  "limit?": type("number").describe("max results"),
@@ -110,19 +109,19 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
110
109
  readonly examples: readonly ToolExample<typeof findSchema.infer>[] = [
111
110
  {
112
111
  caption: "Glob files",
113
- call: { paths: ["src/**/*.ts"] },
112
+ call: { path: "src/**/*.ts" },
114
113
  },
115
114
  {
116
- caption: "Multiple targets — separate array elements",
117
- call: { paths: ["src/**/*.ts", "test/**/*.ts"] },
115
+ caption: "Multiple targets — semicolon-delimited list",
116
+ call: { path: "src/**/*.ts; test/**/*.ts" },
118
117
  },
119
118
  {
120
119
  caption: "Glob gitignored files like .env",
121
- call: { paths: [".env*"], gitignore: false },
120
+ call: { path: ".env*", gitignore: false },
122
121
  },
123
122
  {
124
123
  caption: "Glob directories matching a name (returns both files and dirs; directories are suffixed with `/`)",
125
- call: { paths: ["**/tests"] },
124
+ call: { path: "**/tests" },
126
125
  },
127
126
  ];
128
127
  readonly strict = true;
@@ -144,13 +143,15 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
144
143
  onUpdate?: AgentToolUpdateCallback<GlobToolDetails>,
145
144
  _context?: AgentToolContext,
146
145
  ): Promise<AgentToolResult<GlobToolDetails>> {
147
- const { paths, limit, hidden, gitignore } = params;
146
+ const { path: pathInput, limit, hidden, gitignore } = params;
148
147
 
149
148
  return untilAborted(signal, async () => {
150
149
  const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
150
+ const scopedPaths = toPathList(pathInput);
151
+ const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
151
152
  const rawPatternInputs = this.#customOps
152
- ? paths
153
- : await expandDelimitedPathEntries(paths, this.session.cwd, { splitter: parseFindPattern });
153
+ ? effectivePaths
154
+ : await expandDelimitedPathEntries(effectivePaths, this.session.cwd, { splitter: parseFindPattern });
154
155
  const rawPatterns = rawPatternInputs.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
155
156
  const internalRouter = InternalUrlRouter.instance();
156
157
  const normalizedPatterns: string[] = [];
@@ -180,7 +181,7 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
180
181
  normalizedPatterns.push(resource.sourcePath);
181
182
  }
182
183
  if (normalizedPatterns.some(pattern => pattern.length === 0)) {
183
- throw new ToolError("`paths` must contain non-empty globs or paths");
184
+ throw new ToolError("`path` must contain non-empty globs or paths");
184
185
  }
185
186
 
186
187
  // Tolerate missing entries in a multi-path call: skip ones whose base
@@ -468,12 +469,15 @@ export class GlobTool implements AgentTool<typeof findSchema, GlobToolDetails> {
468
469
  // =============================================================================
469
470
 
470
471
  interface GlobRenderArgs {
472
+ path?: string | string[];
473
+ /** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
471
474
  paths?: string | string[];
472
475
  limit?: number;
473
476
  }
474
477
 
475
- function formatGlobRenderPaths(paths: GlobRenderArgs["paths"]): string | undefined {
476
- return Array.isArray(paths) ? paths.join(", ") : paths;
478
+ function formatGlobRenderPaths(args: GlobRenderArgs | undefined): string | undefined {
479
+ const list = toPathList(args?.path ?? args?.paths);
480
+ return list.length > 0 ? list.join(", ") : undefined;
477
481
  }
478
482
 
479
483
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
@@ -493,7 +497,7 @@ export const globToolRenderer = {
493
497
  icon: "pending",
494
498
  title: "Glob",
495
499
  titleColor: "toolTitle",
496
- description: formatGlobRenderPaths(args.paths) || "*",
500
+ description: formatGlobRenderPaths(args) || "*",
497
501
  meta,
498
502
  },
499
503
  uiTheme,
@@ -533,7 +537,7 @@ export const globToolRenderer = {
533
537
  iconOverride: globStatusIcon(uiTheme),
534
538
  title: "Glob",
535
539
  titleColor: "toolTitle",
536
- description: formatGlobRenderPaths(args?.paths),
540
+ description: formatGlobRenderPaths(args),
537
541
  meta: [formatCount("file", lines.length)],
538
542
  },
539
543
  uiTheme,
@@ -573,7 +577,7 @@ export const globToolRenderer = {
573
577
  icon: "warning",
574
578
  title: "Glob",
575
579
  titleColor: "toolTitle",
576
- description: formatGlobRenderPaths(args?.paths),
580
+ description: formatGlobRenderPaths(args),
577
581
  meta: ["0 files"],
578
582
  },
579
583
  uiTheme,
@@ -590,7 +594,7 @@ export const globToolRenderer = {
590
594
  ...(truncated ? { icon: "warning" as const } : { iconOverride: globStatusIcon(uiTheme) }),
591
595
  title: "Glob",
592
596
  titleColor: "toolTitle",
593
- description: formatGlobRenderPaths(args?.paths),
597
+ description: formatGlobRenderPaths(args),
594
598
  meta,
595
599
  },
596
600
  uiTheme,
package/src/tools/grep.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  selectorLineRanges,
55
55
  splitInternalUrlSel,
56
56
  splitPathAndSel,
57
+ toPathList,
57
58
  } from "./path-utils";
58
59
  import {
59
60
  createCachedComponent,
@@ -69,15 +70,13 @@ import { ToolError } from "./tool-errors";
69
70
  import { toolResult } from "./tool-result";
70
71
 
71
72
  const searchPathEntry = type("string").describe(
72
- 'file, directory, glob, internal URL, or "<file>:<lines>" selector (e.g. "src/foo.ts:50-100", "src/foo.ts:50+10", "src/foo.ts:50-100,200-300")',
73
+ 'file, directory, glob, internal URL, or "<file>:<lines>" selector to search (e.g. "src/foo.ts:50-100", "src/foo.ts:50+10", "src/foo.ts:50-100,200-300")',
73
74
  );
74
75
  const searchSchema = type({
75
76
  pattern: type("string").describe("regex pattern"),
76
- "paths?": searchPathEntry
77
- .or(searchPathEntry.array())
78
- .describe(
79
- 'file, directory, glob, internal URL, or array of those to search; append `:<lines>` to scope a file to specific line ranges. Omitted or empty -> searches the workspace root (".")',
80
- ),
77
+ "path?": searchPathEntry.describe(
78
+ 'file, directory, glob, internal URL, or "<file>:<lines>" selector to search; pass several as a semicolon-delimited list ("src; tests"). Omitted -> searches the workspace root (".")',
79
+ ),
81
80
  "case?": type("boolean").describe("case-sensitive search"),
82
81
  "gitignore?": type("boolean").describe("respect gitignore"),
83
82
  "skip?": type("number")
@@ -86,27 +85,6 @@ const searchSchema = type({
86
85
  });
87
86
 
88
87
  export type GrepToolInput = typeof searchSchema.infer;
89
- function parseStringEncodedPathArray(input: string): string[] | null {
90
- const trimmed = input.trim();
91
- if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
92
-
93
- let parsed: unknown;
94
- try {
95
- parsed = JSON.parse(trimmed);
96
- } catch {
97
- return null;
98
- }
99
-
100
- if (!Array.isArray(parsed) || parsed.some(entry => typeof entry !== "string")) {
101
- return null;
102
- }
103
- return parsed;
104
- }
105
-
106
- export function toPathList(input: string | string[] | undefined): string[] {
107
- if (typeof input === "string") return parseStringEncodedPathArray(input) ?? [input];
108
- return input ?? [];
109
- }
110
88
 
111
89
  /** Maximum number of distinct files surfaced in a single response. The
112
90
  * agent paginates further pages via `skip`. */
@@ -184,7 +162,7 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
184
162
  // `:conflicts:1-1`) instead of silently widening the search or dropping a chunk.
185
163
  if (!isReadSelectorGrammar(internalSplit.sel)) {
186
164
  throw new ToolError(
187
- `paths entry "${entry}" has an invalid selector ":${internalSplit.sel}" — use ":N-M" line ranges, ":raw"/":conflicts", a range plus ":raw", or percent-encode a literal ":" as %3A`,
165
+ `path entry "${entry}" has an invalid selector ":${internalSplit.sel}" — use ":N-M" line ranges, ":raw"/":conflicts", a range plus ":raw", or percent-encode a literal ":" as %3A`,
188
166
  );
189
167
  }
190
168
  specs.push({ original: entry, clean: internalSplit.path, ranges: selectorLineRanges(internalSplit.sel) });
@@ -197,7 +175,7 @@ function parsePathSpecs(rawEntries: readonly string[]): GrepPathSpec[] {
197
175
  const parsed = parseLineRanges(split.sel);
198
176
  if (!parsed) {
199
177
  throw new ToolError(
200
- `paths entry "${entry}" — only line-range selectors like ":50-100" are supported (no ":raw"/":conflicts")`,
178
+ `path entry "${entry}" — only line-range selectors like ":50-100" are supported (no ":raw"/":conflicts")`,
201
179
  );
202
180
  }
203
181
  if (hasGlobPathChars(split.path)) {
@@ -858,8 +836,10 @@ type SearchParams = typeof searchSchema.infer;
858
836
 
859
837
  export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails> {
860
838
  readonly name = "grep";
861
- readonly approval = (args: unknown): ToolTier =>
862
- toPathList((args as { paths?: string | string[] }).paths).some(pathTargetsSsh) ? "exec" : "read";
839
+ readonly approval = (args: unknown): ToolTier => {
840
+ const a = args as { path?: string | string[]; paths?: string | string[] };
841
+ return toPathList(a.path ?? a.paths).some(pathTargetsSsh) ? "exec" : "read";
842
+ };
863
843
  readonly label = "Grep";
864
844
  readonly loadMode = "discoverable";
865
845
  readonly summary = "Grep file contents using ripgrep (fast regex search)";
@@ -882,7 +862,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
882
862
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
883
863
  _toolContext?: AgentToolContext,
884
864
  ): Promise<AgentToolResult<GrepToolDetails>> {
885
- const { pattern, paths: rawPaths, case: caseSensitive, gitignore, skip } = params;
865
+ const { pattern, path: rawPath, case: caseSensitive, gitignore, skip } = params;
886
866
 
887
867
  return untilAborted(signal, async () => {
888
868
  // Preserve the pattern verbatim — leading/trailing whitespace is
@@ -897,7 +877,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
897
877
  if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
898
878
  throw new ToolError("Skip must be a non-negative number");
899
879
  }
900
- const scopedPaths = toPathList(rawPaths);
880
+ const scopedPaths = toPathList(rawPath);
901
881
  const effectivePaths = scopedPaths.length > 0 ? scopedPaths : ["."];
902
882
  const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
903
883
  const pathSpecs = parsePathSpecs(rawEntries);
@@ -972,7 +952,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
972
952
  trackImmutableSources: true,
973
953
  surfaceExactFilePaths: true,
974
954
  fanOutFileTargets: true,
975
- multipathStatHint: " (`paths` entries must each exist relative to cwd)",
955
+ multipathStatHint: " (`path` list entries must each exist relative to cwd)",
976
956
  settings: this.session.settings,
977
957
  signal,
978
958
  localProtocolOptions: this.session.localProtocolOptions,
@@ -1047,7 +1027,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1047
1027
  ? ` (archive members were not searchable: ${archiveUnreadable.join(", ")})`
1048
1028
  : "";
1049
1029
  throw new ToolError(
1050
- `Path not found: ${missingPaths.join(", ")}; pass each path as its own array element${archiveHint}`,
1030
+ `Path not found: ${missingPaths.join(", ")}; list each target in the semicolon-delimited \`path\`${archiveHint}`,
1051
1031
  );
1052
1032
  }
1053
1033
  const baseDisplayMode = resolveFileDisplayMode(this.session);
@@ -1505,6 +1485,8 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1505
1485
 
1506
1486
  interface GrepRenderArgs {
1507
1487
  pattern: string;
1488
+ path?: string | string[];
1489
+ /** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
1508
1490
  paths?: string | string[];
1509
1491
  case?: boolean;
1510
1492
  gitignore?: boolean;
@@ -1674,7 +1656,7 @@ function grepStatusIcon(uiTheme: Theme): string {
1674
1656
  export const grepToolRenderer = {
1675
1657
  inline: true,
1676
1658
  renderCall(args: GrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
1677
- const paths = toPathList(args.paths);
1659
+ const paths = toPathList(args.path ?? args.paths);
1678
1660
  const meta: string[] = [];
1679
1661
  if (paths.length) meta.push(`in ${paths.join(", ")}`);
1680
1662
  if (args.case === false) meta.push("case:insensitive");
@@ -510,6 +510,39 @@ export function normalizePathLikeInput(input: string): string {
510
510
  return stripOuterDoubleQuotes(input.trim());
511
511
  }
512
512
 
513
+ /**
514
+ * Parse a JSON-encoded array of path strings (e.g. `'["a.ts","b.ts"]'`).
515
+ * Returns `null` when the input is not a bracketed JSON string array, so the
516
+ * caller can fall back to treating the input as a single literal path.
517
+ */
518
+ function parseStringEncodedPathArray(input: string): string[] | null {
519
+ const trimmed = input.trim();
520
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
521
+
522
+ let parsed: unknown;
523
+ try {
524
+ parsed = JSON.parse(trimmed);
525
+ } catch {
526
+ return null;
527
+ }
528
+
529
+ if (!Array.isArray(parsed) || parsed.some(entry => typeof entry !== "string")) {
530
+ return null;
531
+ }
532
+ return parsed;
533
+ }
534
+
535
+ /**
536
+ * Normalize a path argument that may arrive as a single string, a JSON-encoded
537
+ * string array (`'["a.ts"]'`), or an actual array into a flat `string[]`.
538
+ * Delimited single strings (`"a.ts b.ts"`) are left for
539
+ * {@link expandDelimitedPathEntries} to split.
540
+ */
541
+ export function toPathList(input: string | string[] | undefined): string[] {
542
+ if (typeof input === "string") return parseStringEncodedPathArray(input) ?? [input];
543
+ return input ?? [];
544
+ }
545
+
513
546
  const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
514
547
 
515
548
  export function hasGlobPathChars(filePath: string): boolean {
@@ -592,12 +625,21 @@ async function delimitedPathPartResolves(entry: string, cwd: string, splitter: P
592
625
  }
593
626
  }
594
627
 
628
+ /**
629
+ * How many split parts must resolve to an existing path for the split to win.
630
+ * Semicolon is the documented list delimiter, so it splits unconditionally
631
+ * (`"none"`) — an all-missing list must still fan out so multi-path missing
632
+ * semantics can name every entry. Comma is legacy recovery (`"some"`), and
633
+ * whitespace/mixed are aggressive heuristics gated on every part existing.
634
+ */
635
+ type DelimitedResolveRequirement = "all" | "some" | "none";
636
+
595
637
  async function tryDelimitedPathSplit(
596
638
  entry: string,
597
639
  cwd: string,
598
640
  splitter: PathEntrySplitter,
599
641
  mode: DelimitedPathSplitMode,
600
- requireAllParts: boolean,
642
+ requirement: DelimitedResolveRequirement,
601
643
  ): Promise<string[] | null> {
602
644
  const rawParts = splitTopLevelDelimitedPath(entry, mode);
603
645
  if (rawParts.length < 2) return null;
@@ -606,9 +648,12 @@ async function tryDelimitedPathSplit(
606
648
  if (parts.length === 0) return null;
607
649
  if (parts.length < 2 && rawParts.length === parts.length) return null;
608
650
 
609
- const resolved = await Promise.all(parts.map(part => delimitedPathPartResolves(part, cwd, splitter)));
610
- const valid = requireAllParts ? resolved.every(Boolean) : resolved.some(Boolean);
611
- return valid ? parts : null;
651
+ if (requirement !== "none") {
652
+ const resolved = await Promise.all(parts.map(part => delimitedPathPartResolves(part, cwd, splitter)));
653
+ const valid = requirement === "all" ? resolved.every(Boolean) : resolved.some(Boolean);
654
+ if (!valid) return null;
655
+ }
656
+ return parts;
612
657
  }
613
658
 
614
659
  /**
@@ -632,10 +677,10 @@ export async function splitDelimitedPathEntry(
632
677
  }
633
678
 
634
679
  return (
635
- (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "semicolon", false)) ??
636
- (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "comma", false)) ??
637
- (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "whitespace", true)) ??
638
- (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "mixed", true))
680
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "semicolon", "none")) ??
681
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "comma", "some")) ??
682
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "whitespace", "all")) ??
683
+ (await tryDelimitedPathSplit(normalizedEntry, cwd, splitter, "mixed", "all"))
639
684
  );
640
685
  }
641
686
 
@@ -1094,11 +1139,11 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1094
1139
  const { rawPaths: inputs, cwd, internalUrlAction } = opts;
1095
1140
  const normalizedRawPaths = inputs.map(normalizePathLikeInput);
1096
1141
  if (normalizedRawPaths.some(rawPath => rawPath.length === 0)) {
1097
- throw new ToolError("`paths` must contain non-empty paths or globs");
1142
+ throw new ToolError("Search scope entries must be non-empty paths or globs");
1098
1143
  }
1099
1144
  const rawPaths = await expandDelimitedPathEntries(normalizedRawPaths, cwd);
1100
1145
  if (rawPaths.some(rawPath => rawPath.length === 0)) {
1101
- throw new ToolError("`paths` must contain non-empty paths or globs");
1146
+ throw new ToolError("Search scope entries must be non-empty paths or globs");
1102
1147
  }
1103
1148
  // Strict external-URL schemes. `file://` is intentionally absent: it has
1104
1149
  // local-path semantics (expandPath strips it downstream), so it flows through
package/src/tools/read.ts CHANGED
@@ -316,7 +316,14 @@ function formatMergedBraceLine(
316
316
 
317
317
  function countTextLines(text: string): number {
318
318
  if (text.length === 0) return 0;
319
- return text.split("\n").length;
319
+ // Count newlines directly instead of allocating an array via split("\n").
320
+ // Called on every read of file content; the result is identical (N newlines
321
+ // ⇒ N+1 lines for non-empty text).
322
+ let lines = 1;
323
+ for (let i = 0; i < text.length; i++) {
324
+ if (text.charCodeAt(i) === 10) lines++;
325
+ }
326
+ return lines;
320
327
  }
321
328
 
322
329
  function contiguousLineNumbers(startLine: number, count: number): number[] {
@@ -1317,7 +1324,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1317
1324
  startLine: startNum,
1318
1325
  lineNumbers: Array.from({ length: lineCount }, (_, i) => startNum + i),
1319
1326
  };
1320
- if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, countTextLines(content));
1327
+ if (shouldAddHashLines) seenLines = contiguousLineNumbers(startNum, lineCount);
1321
1328
  const formatted = formatTextWithMode(content, startNum, shouldAddHashLines, shouldAddLineNumbers);
1322
1329
  if (!hashContext || emittedHashlineHeader) return formatted;
1323
1330
  emittedHashlineHeader = true;
@@ -63,6 +63,17 @@ export type ToolRenderer = {
63
63
  * with the final render and may commit like any settled stream.
64
64
  */
65
65
  provisionalPartialResult?: boolean;
66
+ /**
67
+ * Whether the renderer's pending-call path visibly consumes
68
+ * `options.spinnerFrame`. Used to avoid scheduling repaint ticks for live
69
+ * partial calls whose bytes cannot change between spinner frames.
70
+ */
71
+ animatedPendingPreview?: boolean | ((args: unknown) => boolean);
72
+ /**
73
+ * Whether the renderer's partial-result path visibly consumes
74
+ * `options.spinnerFrame`.
75
+ */
76
+ animatedPartialResult?: boolean | ((args: unknown) => boolean);
66
77
  };
67
78
 
68
79
  export const toolRenderers: Record<string, ToolRenderer> = {
package/src/tools/ssh.ts CHANGED
@@ -261,23 +261,33 @@ function formatSshCommandLines(command: string, uiTheme: Theme): string[] {
261
261
  }
262
262
 
263
263
  export const sshToolRenderer = {
264
- renderCall(args: SshRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
264
+ animatedPendingPreview: true,
265
+ renderCall(args: SshRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
265
266
  const host = args.host || "…";
266
267
  const command = args.command ?? "";
267
- const header = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}]` }, uiTheme);
268
268
  const cmdLines = formatSshCommandLines(command, uiTheme);
269
269
  const outputBlock = new CachedOutputBlock();
270
270
  return markFramedBlockComponent({
271
- render: (width: number): readonly string[] =>
272
- outputBlock.render(
271
+ render: (width: number): readonly string[] => {
272
+ const header = renderStatusLine(
273
+ {
274
+ icon: options.spinnerFrame !== undefined ? "running" : "pending",
275
+ spinnerFrame: options.spinnerFrame,
276
+ title: "SSH",
277
+ description: `[${host}]`,
278
+ },
279
+ uiTheme,
280
+ );
281
+ return outputBlock.render(
273
282
  {
274
283
  header,
275
- state: "pending",
276
- sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: _options.expanded }) }],
284
+ state: options.spinnerFrame !== undefined ? "running" : "pending",
285
+ sections: [{ lines: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
277
286
  width,
278
287
  },
279
288
  uiTheme,
280
- ),
289
+ );
290
+ },
281
291
  invalidate: () => {
282
292
  outputBlock.invalidate();
283
293
  },
package/src/tts/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from "./downloader";
2
2
  export * from "./models";
3
3
  export * from "./runtime";
4
+ export * from "./speakable";
5
+ export * from "./speech-enhancer";
4
6
  export * from "./tts-client";
5
7
  export * from "./tts-protocol";
6
8
  export * from "./tts-worker";