@oh-my-pi/pi-coding-agent 16.3.0 → 16.3.3

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 (163) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3855 -3798
  3. package/dist/types/cli/update-cli.d.ts +1 -0
  4. package/dist/types/cli/update-cli.test.d.ts +1 -0
  5. package/dist/types/commands/update.d.ts +5 -0
  6. package/dist/types/config/config-file.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +20 -0
  8. package/dist/types/dap/client.d.ts +9 -1
  9. package/dist/types/discovery/helpers.d.ts +2 -0
  10. package/dist/types/edit/file-snapshot-store.d.ts +9 -1
  11. package/dist/types/edit/hashline/execute.d.ts +1 -1
  12. package/dist/types/edit/hashline/params.d.ts +3 -6
  13. package/dist/types/edit/renderer.d.ts +1 -0
  14. package/dist/types/extensibility/plugins/parser.d.ts +2 -1
  15. package/dist/types/hindsight/client.d.ts +15 -11
  16. package/dist/types/hindsight/client.test.d.ts +1 -0
  17. package/dist/types/irc/bus.d.ts +5 -4
  18. package/dist/types/mcp/smithery-registry.d.ts +1 -0
  19. package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
  20. package/dist/types/modes/components/advisor-message.d.ts +4 -2
  21. package/dist/types/modes/components/tool-execution.d.ts +9 -6
  22. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  23. package/dist/types/modes/theme/theme.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +13 -13
  25. package/dist/types/session/indexed-session-storage.d.ts +2 -2
  26. package/dist/types/session/session-storage.d.ts +31 -3
  27. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
  28. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
  29. package/dist/types/ssh/connection-manager.d.ts +19 -0
  30. package/dist/types/ssh/sshfs-mount.d.ts +10 -1
  31. package/dist/types/subprocess/worker-client.d.ts +20 -6
  32. package/dist/types/task/discovery.d.ts +5 -2
  33. package/dist/types/task/renderer.d.ts +1 -0
  34. package/dist/types/tools/ast-grep.d.ts +4 -2
  35. package/dist/types/tools/bash.d.ts +27 -0
  36. package/dist/types/tools/browser/render.d.ts +2 -0
  37. package/dist/types/tools/debug.d.ts +1 -0
  38. package/dist/types/tools/eval-render.d.ts +2 -0
  39. package/dist/types/tools/glob.d.ts +4 -2
  40. package/dist/types/tools/grep.d.ts +4 -3
  41. package/dist/types/tools/path-utils.d.ts +7 -0
  42. package/dist/types/tools/renderers.d.ts +24 -0
  43. package/dist/types/tools/ssh.d.ts +4 -1
  44. package/dist/types/tts/index.d.ts +2 -0
  45. package/dist/types/tts/speakable.d.ts +47 -0
  46. package/dist/types/tts/speech-enhancer.d.ts +46 -0
  47. package/dist/types/tts/streaming-player.d.ts +1 -2
  48. package/dist/types/tts/tts-client.d.ts +11 -10
  49. package/dist/types/tts/tts-protocol.d.ts +7 -0
  50. package/dist/types/tts/vocalizer.d.ts +15 -8
  51. package/dist/types/utils/fetch-timeout.d.ts +4 -0
  52. package/dist/types/utils/git.d.ts +2 -0
  53. package/dist/types/web/search/providers/base.d.ts +1 -0
  54. package/dist/types/web/search/providers/gemini.d.ts +1 -0
  55. package/package.json +12 -12
  56. package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
  57. package/src/advisor/__tests__/advisor.test.ts +1 -1
  58. package/src/cli/gallery-fixtures/fs.ts +2 -2
  59. package/src/cli/gallery-fixtures/search.ts +2 -2
  60. package/src/cli/models-cli.ts +19 -0
  61. package/src/cli/update-cli.test.ts +28 -0
  62. package/src/cli/update-cli.ts +35 -8
  63. package/src/cli.ts +27 -5
  64. package/src/commands/update.ts +8 -2
  65. package/src/config/config-file.ts +6 -6
  66. package/src/config/model-resolver.ts +31 -10
  67. package/src/config/settings-schema.ts +21 -0
  68. package/src/cursor.ts +1 -1
  69. package/src/dap/client.ts +134 -36
  70. package/src/discovery/helpers.ts +8 -0
  71. package/src/edit/file-snapshot-store.ts +12 -1
  72. package/src/edit/hashline/diff.ts +4 -13
  73. package/src/edit/hashline/execute.ts +1 -1
  74. package/src/edit/hashline/params.ts +5 -12
  75. package/src/edit/renderer.ts +4 -2
  76. package/src/edit/streaming.ts +15 -5
  77. package/src/export/html/tool-views.generated.js +34 -34
  78. package/src/extensibility/custom-tools/loader.ts +3 -3
  79. package/src/extensibility/extensions/loader.ts +10 -3
  80. package/src/extensibility/legacy-pi-coding-agent-shim.ts +2 -2
  81. package/src/extensibility/plugins/installer.ts +12 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +87 -11
  83. package/src/extensibility/plugins/loader.ts +30 -1
  84. package/src/extensibility/plugins/manager.ts +32 -8
  85. package/src/extensibility/plugins/parser.ts +7 -5
  86. package/src/extensibility/tool-event-input.ts +1 -1
  87. package/src/hindsight/client.test.ts +33 -0
  88. package/src/hindsight/client.ts +42 -22
  89. package/src/internal-urls/docs-index.generated.txt +1 -1
  90. package/src/irc/bus.ts +5 -4
  91. package/src/main.ts +7 -1
  92. package/src/mcp/oauth-flow.ts +93 -4
  93. package/src/mcp/smithery-auth.ts +3 -0
  94. package/src/mcp/smithery-connect.ts +9 -0
  95. package/src/mcp/smithery-registry.test.ts +51 -0
  96. package/src/mcp/smithery-registry.ts +27 -4
  97. package/src/modes/components/__tests__/move-overlay.test.ts +72 -1
  98. package/src/modes/components/advisor-message.ts +13 -10
  99. package/src/modes/components/assistant-message.ts +1 -1
  100. package/src/modes/components/move-overlay.ts +35 -23
  101. package/src/modes/components/status-line/component.ts +11 -4
  102. package/src/modes/components/tool-execution.ts +119 -20
  103. package/src/modes/components/transcript-container.ts +26 -0
  104. package/src/modes/components/tree-selector.ts +10 -3
  105. package/src/modes/controllers/event-controller.ts +23 -3
  106. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  107. package/src/modes/interactive-mode.ts +1 -0
  108. package/src/modes/rpc/rpc-client.ts +29 -16
  109. package/src/modes/theme/shimmer.ts +49 -15
  110. package/src/modes/theme/theme.ts +7 -0
  111. package/src/prompts/goals/goal-mode-context.md +4 -0
  112. package/src/prompts/goals/goal-todo-context.md +10 -2
  113. package/src/prompts/system/speech-rewrite.md +15 -0
  114. package/src/prompts/system/tiny-title-system.md +1 -1
  115. package/src/prompts/system/title-system-marker.md +2 -1
  116. package/src/prompts/system/title-system.md +2 -1
  117. package/src/prompts/tools/ast-grep.md +3 -3
  118. package/src/prompts/tools/glob.md +1 -1
  119. package/src/prompts/tools/grep.md +1 -1
  120. package/src/sdk.ts +8 -5
  121. package/src/session/agent-session.ts +329 -79
  122. package/src/session/indexed-session-storage.ts +40 -3
  123. package/src/session/session-history-format.ts +6 -2
  124. package/src/session/session-manager.ts +83 -14
  125. package/src/session/session-storage.ts +112 -26
  126. package/src/slash-commands/helpers/usage-report.ts +6 -1
  127. package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
  128. package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
  129. package/src/ssh/connection-manager.ts +44 -11
  130. package/src/ssh/sshfs-mount.ts +27 -4
  131. package/src/subprocess/worker-client.ts +161 -10
  132. package/src/task/discovery.ts +25 -2
  133. package/src/task/executor.ts +24 -4
  134. package/src/task/render.ts +26 -12
  135. package/src/task/renderer.ts +1 -0
  136. package/src/task/worktree.ts +144 -16
  137. package/src/tiny/text.ts +109 -17
  138. package/src/tools/ast-grep.ts +20 -17
  139. package/src/tools/bash.ts +46 -9
  140. package/src/tools/browser/render.ts +2 -0
  141. package/src/tools/debug.ts +1 -0
  142. package/src/tools/eval-render.ts +5 -2
  143. package/src/tools/gh-renderer.ts +3 -0
  144. package/src/tools/glob.ts +24 -20
  145. package/src/tools/grep.ts +39 -37
  146. package/src/tools/path-utils.ts +55 -10
  147. package/src/tools/read.ts +23 -6
  148. package/src/tools/renderers.ts +24 -0
  149. package/src/tools/ssh.ts +25 -7
  150. package/src/tts/index.ts +2 -0
  151. package/src/tts/speakable.ts +382 -0
  152. package/src/tts/speech-enhancer.ts +204 -0
  153. package/src/tts/streaming-player.ts +71 -16
  154. package/src/tts/tts-client.ts +11 -10
  155. package/src/tts/tts-protocol.ts +14 -5
  156. package/src/tts/tts-worker.ts +52 -49
  157. package/src/tts/vocalizer.ts +277 -46
  158. package/src/utils/clipboard.ts +49 -12
  159. package/src/utils/fetch-timeout.ts +10 -0
  160. package/src/utils/git.ts +8 -0
  161. package/src/web/search/index.ts +8 -0
  162. package/src/web/search/providers/base.ts +1 -0
  163. package/src/web/search/providers/gemini.ts +23 -6
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);
@@ -1391,6 +1371,17 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1391
1371
  }, 0);
1392
1372
  let lastEmittedLine: number | undefined;
1393
1373
  const gutterPad = " ".repeat(lineNumberWidth + 1);
1374
+ // Track match/context lines whose displayed text was
1375
+ // column-truncated by the native (see `crates/pi-natives/src/grep.rs`
1376
+ // `truncate_line`, marker `...` at max_columns). Excluded from
1377
+ // seenLines so a follow-up edit anchored at that line still
1378
+ // requires a full-width re-read — the model saw only the
1379
+ // prefix. The native currently propagates `truncated` only on
1380
+ // the match line; context lines fall back to a length check
1381
+ // against `DEFAULT_MAX_COLUMN` as a conservative heuristic.
1382
+ const clippedLines = new Set<number>();
1383
+ const isNativeTruncated = (line: string): boolean =>
1384
+ line.length >= DEFAULT_MAX_COLUMN && line.endsWith("...");
1394
1385
  for (const match of fileMatches) {
1395
1386
  const pushLine = (lineNumber: number, line: string, isMatch: boolean) => {
1396
1387
  if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
@@ -1404,22 +1395,31 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1404
1395
  if (match.contextBefore) {
1405
1396
  for (const ctx of match.contextBefore) {
1406
1397
  pushLine(ctx.lineNumber, ctx.line, false);
1398
+ if (isNativeTruncated(ctx.line)) clippedLines.add(ctx.lineNumber);
1407
1399
  }
1408
1400
  }
1409
1401
  pushLine(match.lineNumber, match.line, true);
1410
1402
  if (match.truncated) {
1411
1403
  linesTruncated = true;
1404
+ clippedLines.add(match.lineNumber);
1412
1405
  }
1413
1406
  if (match.contextAfter) {
1414
1407
  for (const ctx of match.contextAfter) {
1415
1408
  pushLine(ctx.lineNumber, ctx.line, false);
1409
+ if (isNativeTruncated(ctx.line)) clippedLines.add(ctx.lineNumber);
1416
1410
  }
1417
1411
  }
1418
1412
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
1419
1413
  }
1420
1414
  if (hashContext?.tag) {
1421
1415
  const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
1422
- recordSeenLinesFromBody(this.session, absoluteFilePath, hashContext.tag, modelOut.join("\n"));
1416
+ recordSeenLinesFromBody(
1417
+ this.session,
1418
+ absoluteFilePath,
1419
+ hashContext.tag,
1420
+ modelOut.join("\n"),
1421
+ clippedLines,
1422
+ );
1423
1423
  }
1424
1424
  return { model: modelOut, display: displayOut };
1425
1425
  };
@@ -1505,6 +1505,8 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1505
1505
 
1506
1506
  interface GrepRenderArgs {
1507
1507
  pattern: string;
1508
+ path?: string | string[];
1509
+ /** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
1508
1510
  paths?: string | string[];
1509
1511
  case?: boolean;
1510
1512
  gitignore?: boolean;
@@ -1674,7 +1676,7 @@ function grepStatusIcon(uiTheme: Theme): string {
1674
1676
  export const grepToolRenderer = {
1675
1677
  inline: true,
1676
1678
  renderCall(args: GrepRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
1677
- const paths = toPathList(args.paths);
1679
+ const paths = toPathList(args.path ?? args.paths);
1678
1680
  const meta: string[] = [];
1679
1681
  if (paths.length) meta.push(`in ${paths.join(", ")}`);
1680
1682
  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;
@@ -1552,6 +1559,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1552
1559
  const displayLineByNumber = new Map<number, string>();
1553
1560
  const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize);
1554
1561
  let columnTruncated = 0;
1562
+ const clippedLines = new Set<number>();
1555
1563
  let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
1556
1564
 
1557
1565
  for (const range of ranges) {
@@ -1599,6 +1607,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1599
1607
  if (!cloned) cloned = collectedLines.slice();
1600
1608
  cloned[i] = text;
1601
1609
  columnTruncated = maxColumns;
1610
+ clippedLines.add(range.startLine + i);
1602
1611
  }
1603
1612
  }
1604
1613
  if (cloned) displayLines = cloned;
@@ -1626,7 +1635,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1626
1635
  if (visibleText !== undefined) return visibleText;
1627
1636
  if (maxColumns <= 0) return sourceText;
1628
1637
  const truncated = truncateLine(sourceText, maxColumns);
1629
- if (truncated.wasTruncated) columnTruncated = maxColumns;
1638
+ if (truncated.wasTruncated) {
1639
+ columnTruncated = maxColumns;
1640
+ clippedLines.add(lineNumber);
1641
+ }
1630
1642
  return truncated.text;
1631
1643
  },
1632
1644
  },
@@ -1644,7 +1656,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1644
1656
  if (shouldAddHashLines && outputText) {
1645
1657
  const tag = await recordFileSnapshot(this.session, absolutePath);
1646
1658
  if (tag) {
1647
- recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
1659
+ recordSeenLinesFromBody(this.session, absolutePath, tag, outputText, clippedLines);
1648
1660
  outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1649
1661
  }
1650
1662
  }
@@ -2483,6 +2495,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2483
2495
  // ellipsis-truncated text made every long-line file uneditable on
2484
2496
  // the next edit attempt.
2485
2497
  let displayLines: string[] = collectedLines;
2498
+ const clippedLines = new Set<number>();
2486
2499
  if (!rawSelector && maxColumns > 0) {
2487
2500
  let cloned: string[] | undefined;
2488
2501
  for (let i = 0; i < collectedLines.length; i++) {
@@ -2491,6 +2504,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2491
2504
  if (!cloned) cloned = collectedLines.slice();
2492
2505
  cloned[i] = text;
2493
2506
  columnTruncated = maxColumns;
2507
+ clippedLines.add(startLineDisplay + i);
2494
2508
  }
2495
2509
  }
2496
2510
  if (cloned) displayLines = cloned;
@@ -2573,7 +2587,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2573
2587
  if (visibleText !== undefined) return visibleText;
2574
2588
  if (maxColumns <= 0) return sourceText;
2575
2589
  const truncated = truncateLine(sourceText, maxColumns);
2576
- if (truncated.wasTruncated) columnTruncated = maxColumns;
2590
+ if (truncated.wasTruncated) {
2591
+ columnTruncated = maxColumns;
2592
+ clippedLines.add(lineNumber);
2593
+ }
2577
2594
  return truncated.text;
2578
2595
  },
2579
2596
  },
@@ -2647,7 +2664,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2647
2664
  }
2648
2665
 
2649
2666
  if (hashContext?.tag) {
2650
- recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
2667
+ recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText, clippedLines);
2651
2668
  }
2652
2669
 
2653
2670
  if (capturedDisplayContent) {
@@ -63,6 +63,30 @@ 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);
77
+ /**
78
+ * Whether replacing a streamed pending placeholder with the first result
79
+ * requires a full viewport repaint. Use for merged renderers whose pending
80
+ * streamed args may have committed placeholder rows that the result render
81
+ * re-anchors instead of preserving.
82
+ */
83
+ forceFirstResultViewportRepaint?: boolean;
84
+ /**
85
+ * Whether settling a provisional partial result into the final render requires
86
+ * a full viewport repaint. Use when the result renderer changes chrome or
87
+ * frame topology at `options.isPartial: true -> false`.
88
+ */
89
+ forceResultViewportRepaintOnSettle?: boolean;
66
90
  };
67
91
 
68
92
  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
  },
@@ -392,4 +402,12 @@ export const sshToolRenderer = {
392
402
  // land below and strand a duplicate pending header above the final frame
393
403
  // ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)).
394
404
  provisionalPartialResult: true,
405
+ // Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
406
+ // `$ …`), then the first partial result inserts the `Output` section and
407
+ // re-anchors the frame. Force a full repaint at that seam so placeholder rows
408
+ // do not survive in viewport/native scrollback.
409
+ forceFirstResultViewportRepaint: true,
410
+ // The provisional pending-result frame settles into the final `⇄ SSH: [host]`
411
+ // frame, so clear/replay the viewport at that topology flip too.
412
+ forceResultViewportRepaintOnSettle: true,
395
413
  };
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";