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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -8,32 +8,25 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
9
  import { getFileReadCache } from "../edit/file-read-cache";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
- import { InternalUrlRouter } from "../internal-urls";
12
11
  import type { Theme } from "../modes/theme/theme";
13
12
  import searchDescription from "../prompts/tools/search.md" with { type: "text" };
14
13
  import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output";
15
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
14
+ import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
16
15
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
17
16
  import type { ToolSession } from ".";
18
17
  import { createFileRecorder, formatResultPath } from "./file-recorder";
19
18
  import { formatGroupedFiles } from "./grouped-file-output";
20
19
  import { formatMatchLine } from "./match-line-format";
21
20
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
21
+ import { resolveToolSearchScope } from "./path-utils";
22
22
  import {
23
- formatPathRelativeToCwd,
24
- hasGlobPathChars,
25
- normalizePathLikeInput,
26
- parseSearchPath,
27
- partitionExistingPaths,
28
- resolveExplicitSearchPaths,
29
- resolveToCwd,
30
- } from "./path-utils";
31
- import {
23
+ createCachedComponent,
32
24
  formatCodeFrameLine,
33
25
  formatCount,
34
26
  formatEmptyMessage,
35
27
  formatErrorMessage,
36
28
  PREVIEW_LIMITS,
29
+ splitGroupsByBlankLine,
37
30
  } from "./render-utils";
38
31
  import { ToolError } from "./tool-errors";
39
32
  import { toolResult } from "./tool-result";
@@ -47,17 +40,36 @@ const searchSchema = Type.Object({
47
40
  }),
48
41
  i: Type.Optional(Type.Boolean({ description: "case-insensitive search", default: false })),
49
42
  gitignore: Type.Optional(Type.Boolean({ description: "respect gitignore", default: true })),
50
- skip: Type.Optional(Type.Number({ description: "matches to skip", default: 0 })),
43
+ skip: Type.Optional(
44
+ Type.Number({
45
+ description:
46
+ "files to skip before collecting results — use to paginate when the prior call hit the file limit",
47
+ default: 0,
48
+ }),
49
+ ),
51
50
  });
52
51
 
53
52
  export type SearchToolInput = Static<typeof searchSchema>;
54
53
 
55
- export const DEFAULT_MATCH_LIMIT = 100;
54
+ /** Maximum number of distinct files surfaced in a single response. The
55
+ * agent paginates further pages via `skip`. */
56
+ export const DEFAULT_FILE_LIMIT = 20;
57
+ /** Per-file match cap for multi-file searches — keeps a single hot file
58
+ * from crowding out diverse hits. Applied in JS after grep returns. */
59
+ export const MULTI_FILE_PER_FILE_MATCHES = 20;
60
+ /** Per-file match cap for single-file searches — there's no diversity
61
+ * concern when the scope is one file. */
62
+ export const SINGLE_FILE_MATCHES = 200;
63
+ /** Hard safety ceiling on how many matches we fetch from native grep
64
+ * before JS-side grouping. Sized to comfortably cover the file window
65
+ * (DEFAULT_FILE_LIMIT files × MULTI_FILE_PER_FILE_MATCHES matches) plus
66
+ * pagination headroom so the caller can see total file count. */
67
+ const INTERNAL_TOTAL_CAP = 2000;
56
68
 
57
69
  export interface SearchToolDetails {
58
70
  truncation?: TruncationResult;
59
- matchLimitReached?: number;
60
- resultLimitReached?: number;
71
+ fileLimitReached?: number;
72
+ perFileLimitReached?: number;
61
73
  linesTruncated?: boolean;
62
74
  meta?: OutputMeta;
63
75
  scopePath?: string;
@@ -122,82 +134,34 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
122
134
  const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
123
135
  const effectiveMultiline = patternHasNewline;
124
136
 
125
- const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
126
- let searchPath: string;
127
- let scopePath: string;
128
- let exactFilePaths: string[] | undefined;
129
- let multiTargets: Array<{ basePath: string; glob?: string }> | undefined;
130
- let globFilter: string | undefined;
131
- const rawPaths = paths.map(normalizePathLikeInput);
132
- if (rawPaths.some(rawPath => rawPath.length === 0)) {
133
- throw new ToolError("`paths` must contain non-empty paths or globs");
134
- }
135
- const internalRouter = InternalUrlRouter.instance();
136
- const resolvedPathInputs: string[] = [];
137
- // Absolute filesystem paths whose source is immutable (e.g. artifact://,
138
- // pi://, skill://). Hashline anchors are suppressed for these on a
139
- // per-file basis, leaving editable mixed-in files untouched.
140
- const immutableSourcePaths = new Set<string>();
141
- for (const rawPath of rawPaths) {
142
- if (!internalRouter.canHandle(rawPath)) {
143
- resolvedPathInputs.push(rawPath);
144
- continue;
145
- }
146
- if (hasGlobPathChars(rawPath)) {
147
- throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
148
- }
149
- const resource = await internalRouter.resolve(rawPath);
150
- if (!resource.sourcePath) {
151
- throw new ToolError(`Cannot search internal URL without a backing file: ${rawPath}`);
152
- }
153
- if (resource.immutable) {
154
- immutableSourcePaths.add(path.resolve(resource.sourcePath));
155
- }
156
- resolvedPathInputs.push(resource.sourcePath);
157
- }
137
+ const scope = await resolveToolSearchScope({
138
+ rawPaths: paths,
139
+ cwd: this.session.cwd,
140
+ internalUrlAction: "search",
141
+ trackImmutableSources: true,
142
+ surfaceExactFilePaths: true,
143
+ multipathStatHint: " (`paths` entries must each exist relative to cwd)",
144
+ });
145
+ const {
146
+ searchPath,
147
+ scopePath,
148
+ isDirectory,
149
+ multiTargets,
150
+ exactFilePaths,
151
+ missingPaths,
152
+ immutableSourcePaths,
153
+ } = scope;
154
+ const { globFilter } = scope;
158
155
  const baseDisplayMode = resolveFileDisplayMode(this.session);
159
156
  const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
160
- // Tolerate missing entries in a multi-path call: skip ones whose base
161
- // directory is gone, and only error if every entry is missing. Single
162
- // missing path keeps the original ENOENT semantics.
163
- let missingPaths: string[] = [];
164
- let effectivePaths = resolvedPathInputs;
165
- if (resolvedPathInputs.length > 1) {
166
- const partition = await partitionExistingPaths(resolvedPathInputs, this.session.cwd, parseSearchPath);
167
- if (partition.valid.length === 0) {
168
- throw new ToolError(`Path not found: ${partition.missing.join(", ")}`);
169
- }
170
- effectivePaths = partition.valid;
171
- missingPaths = partition.missing;
172
- }
173
- if (effectivePaths.length === 1) {
174
- const parsedPath = parseSearchPath(effectivePaths[0] ?? ".");
175
- searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
176
- globFilter = parsedPath.glob;
177
- scopePath = formatScopePath(searchPath);
178
- } else {
179
- const multiSearchPath = await resolveExplicitSearchPaths(effectivePaths, this.session.cwd, globFilter);
180
- if (!multiSearchPath) {
181
- throw new ToolError("`paths` must contain at least one path or glob");
182
- }
183
- searchPath = multiSearchPath.basePath;
184
- exactFilePaths = multiSearchPath.exactFilePaths;
185
- multiTargets = multiSearchPath.targets;
186
- globFilter = exactFilePaths || multiTargets ? undefined : multiSearchPath.glob;
187
- scopePath = multiSearchPath.scopePath;
188
- }
189
- let isDirectory: boolean;
190
- try {
191
- const stat = await Bun.file(searchPath).stat();
192
- isDirectory = stat.isDirectory();
193
- } catch {
194
- const hint = rawPaths.length > 1 ? " (`paths` entries must each exist relative to cwd)" : "";
195
- throw new ToolError(`Path not found: ${scopePath}${hint}`);
196
- }
197
157
 
198
158
  const effectiveOutputMode = GrepOutputMode.Content;
199
- const effectiveLimit = DEFAULT_MATCH_LIMIT;
200
- const internalLimit = Math.min(effectiveLimit * 5, 2000);
159
+ // Multi-scope = more than one file may match. We fetch up to
160
+ // INTERNAL_TOTAL_CAP matches from native grep, then in JS group by
161
+ // file, apply a per-file cap (so one hot file doesn't crowd the
162
+ // window), and round-robin emit from up to DEFAULT_FILE_LIMIT files.
163
+ const isMultiScope = isDirectory || Boolean(exactFilePaths) || Boolean(multiTargets);
164
+ const perFileMatchCap = isMultiScope ? MULTI_FILE_PER_FILE_MATCHES : SINGLE_FILE_MATCHES;
201
165
 
202
166
  // Run grep
203
167
  let result: GrepResult;
@@ -221,7 +185,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
221
185
  hidden: true,
222
186
  gitignore: useGitignore,
223
187
  cache: false,
224
- maxCount: exactFilePaths ? undefined : internalLimit,
188
+ maxCount: INTERNAL_TOTAL_CAP,
225
189
  contextBefore: normalizedContextBefore,
226
190
  contextAfter: normalizedContextAfter,
227
191
  maxColumns: DEFAULT_MAX_COLUMN,
@@ -238,11 +202,10 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
238
202
  matches.push({ ...match, path: rebased });
239
203
  }
240
204
  }
241
- const offsetMatches = matches.slice(normalizedSkip);
242
205
  result = {
243
- matches: offsetMatches,
244
- totalMatches: exactFilePaths ? offsetMatches.length : totalMatches,
245
- filesWithMatches: new Set(offsetMatches.map(match => match.path)).size,
206
+ matches,
207
+ totalMatches: exactFilePaths ? matches.length : totalMatches,
208
+ filesWithMatches: new Set(matches.map(match => match.path)).size,
246
209
  filesSearched: exactFilePaths ? exactFilePaths.length : filesSearched,
247
210
  limitReached,
248
211
  };
@@ -257,8 +220,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
257
220
  hidden: true,
258
221
  gitignore: useGitignore,
259
222
  cache: false,
260
- maxCount: internalLimit,
261
- offset: normalizedSkip > 0 ? normalizedSkip : undefined,
223
+ maxCount: INTERNAL_TOTAL_CAP,
262
224
  contextBefore: normalizedContextBefore,
263
225
  contextAfter: normalizedContextAfter,
264
226
  maxColumns: DEFAULT_MAX_COLUMN,
@@ -277,42 +239,51 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
277
239
  const formatPath = (filePath: string): string =>
278
240
  formatResultPath(filePath, isDirectory, searchPath, this.session.cwd);
279
241
 
280
- // Build output
281
- const roundRobinSelect = (matches: GrepMatch[], limit: number): GrepMatch[] => {
282
- if (matches.length <= limit) return matches;
283
- const fileOrder: string[] = [];
284
- const byFile = new Map<string, GrepMatch[]>();
285
- for (const match of matches) {
286
- if (!byFile.has(match.path)) {
287
- fileOrder.push(match.path);
288
- byFile.set(match.path, []);
289
- }
290
- byFile.get(match.path)!.push(match);
242
+ // Group matches by file in encounter order. Detect per-file overflow
243
+ // BEFORE truncation so the renderer can surface that a hot file was
244
+ // trimmed for diversity.
245
+ const fileOrder: string[] = [];
246
+ const matchesByPath = new Map<string, GrepMatch[]>();
247
+ for (const match of result.matches) {
248
+ if (!matchesByPath.has(match.path)) {
249
+ fileOrder.push(match.path);
250
+ matchesByPath.set(match.path, []);
291
251
  }
292
- const selected: GrepMatch[] = [];
293
- const indices = new Map<string, number>(fileOrder.map(file => [file, 0]));
294
- while (selected.length < limit) {
295
- let anyAdded = false;
296
- for (const file of fileOrder) {
297
- if (selected.length >= limit) break;
298
- const fileMatches = byFile.get(file)!;
299
- const idx = indices.get(file)!;
300
- if (idx < fileMatches.length) {
301
- selected.push(fileMatches[idx]);
302
- indices.set(file, idx + 1);
252
+ matchesByPath.get(match.path)!.push(match);
253
+ }
254
+ let perFileLimitReached = false;
255
+ for (const file of fileOrder) {
256
+ const list = matchesByPath.get(file)!;
257
+ if (list.length > perFileMatchCap) {
258
+ perFileLimitReached = true;
259
+ list.length = perFileMatchCap;
260
+ }
261
+ }
262
+ const totalFiles = fileOrder.length;
263
+ // Single-file scopes can't paginate — there is one file by definition.
264
+ const canPaginate = isMultiScope;
265
+ const skipFiles = canPaginate ? Math.min(normalizedSkip, totalFiles) : 0;
266
+ const windowFiles = canPaginate ? fileOrder.slice(skipFiles, skipFiles + DEFAULT_FILE_LIMIT) : fileOrder;
267
+ const fileLimitReached = canPaginate && totalFiles > skipFiles + DEFAULT_FILE_LIMIT;
268
+ const selectedMatches: GrepMatch[] = [];
269
+ if (windowFiles.length > 0) {
270
+ const lists = windowFiles.map(file => matchesByPath.get(file) ?? []);
271
+ const cursors = new Array<number>(lists.length).fill(0);
272
+ let anyAdded = true;
273
+ while (anyAdded) {
274
+ anyAdded = false;
275
+ for (let i = 0; i < lists.length; i++) {
276
+ if (cursors[i] < lists[i].length) {
277
+ selectedMatches.push(lists[i][cursors[i]++]);
303
278
  anyAdded = true;
304
279
  }
305
280
  }
306
- if (!anyAdded) break;
307
281
  }
308
- return selected;
309
- };
310
- const selectedMatches = isDirectory
311
- ? roundRobinSelect(result.matches, effectiveLimit)
312
- : result.matches.slice(0, effectiveLimit);
313
- const matchLimitReached = result.matches.length > effectiveLimit;
314
- const nextSkip = normalizedSkip + selectedMatches.length;
315
- const limitMessage = `Result limit reached; narrow paths or use skip=${nextSkip}.`;
282
+ }
283
+ const nextSkip = skipFiles + windowFiles.length;
284
+ const limitMessage = fileLimitReached
285
+ ? `Showing files ${skipFiles + 1}-${nextSkip} of ${totalFiles}. Use skip=${nextSkip} for the next page, or narrow paths/pattern.`
286
+ : "";
316
287
  const { record: recordFile, list: fileList } = createFileRecorder();
317
288
  const fileMatchCounts = new Map<string, number>();
318
289
  const missingPathsNote =
@@ -405,7 +376,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
405
376
  displayLines.push(...rendered.display);
406
377
  }
407
378
  }
408
- if (matchLimitReached || result.limitReached) {
379
+ if (limitMessage) {
409
380
  outputLines.push("", limitMessage);
410
381
  }
411
382
  if (missingPathsNote) {
@@ -414,7 +385,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
414
385
  const rawOutput = outputLines.join("\n");
415
386
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
416
387
  const output = truncation.content;
417
- const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated || linesTruncated);
388
+ const truncated = Boolean(
389
+ fileLimitReached || perFileLimitReached || result.limitReached || truncation.truncated || linesTruncated,
390
+ );
418
391
  const details: SearchToolDetails = {
419
392
  scopePath,
420
393
  matchCount: selectedMatches.length,
@@ -425,8 +398,8 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
425
398
  count: fileMatchCounts.get(path) ?? 0,
426
399
  })),
427
400
  truncated,
428
- matchLimitReached: matchLimitReached ? effectiveLimit : undefined,
429
- resultLimitReached: result.limitReached ? internalLimit : undefined,
401
+ fileLimitReached: fileLimitReached ? DEFAULT_FILE_LIMIT : undefined,
402
+ perFileLimitReached: perFileLimitReached ? perFileMatchCap : undefined,
430
403
  displayContent: displayLines.join("\n"),
431
404
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
432
405
  };
@@ -499,16 +472,13 @@ export const searchToolRenderer = {
499
472
  { icon: "success", title: "Search", description, meta: [formatCount("item", lines.length)] },
500
473
  uiTheme,
501
474
  );
502
- let cached: RenderCache | undefined;
503
- return {
504
- render(width: number): string[] {
505
- const { expanded } = options;
506
- const key = new Hasher().bool(expanded).u32(width).digest();
507
- if (cached?.key === key) return cached.lines;
475
+ return createCachedComponent(
476
+ () => options.expanded,
477
+ width => {
508
478
  const listLines = renderTreeList(
509
479
  {
510
480
  items: lines,
511
- expanded,
481
+ expanded: options.expanded,
512
482
  maxCollapsed: COLLAPSED_TEXT_LIMIT,
513
483
  maxCollapsedLines: COLLAPSED_TEXT_LIMIT,
514
484
  itemType: "item",
@@ -516,23 +486,16 @@ export const searchToolRenderer = {
516
486
  },
517
487
  uiTheme,
518
488
  );
519
- const result = [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
520
- cached = { key, lines: result };
521
- return result;
522
- },
523
- invalidate() {
524
- cached = undefined;
489
+ return [header, ...listLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
525
490
  },
526
- };
491
+ );
527
492
  }
528
493
 
529
494
  const matchCount = details?.matchCount ?? 0;
530
495
  const fileCount = details?.fileCount ?? 0;
531
496
  const truncation = details?.meta?.truncation;
532
497
  const limits = details?.meta?.limits;
533
- const truncated = Boolean(
534
- details?.truncated || truncation || limits?.matchLimit || limits?.resultLimit || limits?.columnTruncated,
535
- );
498
+ const truncated = Boolean(details?.truncated || truncation || limits?.columnTruncated);
536
499
 
537
500
  const missingPathsList = details?.missingPaths ?? [];
538
501
  const missingNote =
@@ -561,34 +524,13 @@ export const searchToolRenderer = {
561
524
  );
562
525
 
563
526
  const textContent = result.details?.displayContent ?? result.content?.find(c => c.type === "text")?.text ?? "";
564
- const rawLines = textContent.split("\n");
565
- const hasSeparators = rawLines.some(line => line.trim().length === 0);
566
- const matchGroups: string[][] = [];
567
- if (hasSeparators) {
568
- let current: string[] = [];
569
- for (const line of rawLines) {
570
- if (line.trim().length === 0) {
571
- if (current.length > 0) {
572
- matchGroups.push(current);
573
- current = [];
574
- }
575
- continue;
576
- }
577
- current.push(line);
578
- }
579
- if (current.length > 0) matchGroups.push(current);
580
- } else {
581
- const nonEmpty = rawLines.filter(line => line.trim().length > 0);
582
- if (nonEmpty.length > 0) {
583
- matchGroups.push(nonEmpty);
584
- }
585
- }
527
+ const matchGroups = splitGroupsByBlankLine(textContent.split("\n"));
586
528
 
587
- const renderedMatchLimit = details?.matchLimitReached ?? limits?.matchLimit?.reached;
588
- const renderedResultLimit = details?.resultLimitReached ?? limits?.resultLimit?.reached;
529
+ const renderedFileLimit = details?.fileLimitReached;
530
+ const renderedPerFileLimit = details?.perFileLimitReached;
589
531
  const truncationReasons: string[] = [];
590
- if (renderedMatchLimit) truncationReasons.push(`first ${renderedMatchLimit} matches`);
591
- if (renderedResultLimit) truncationReasons.push(`first ${renderedResultLimit} results`);
532
+ if (renderedFileLimit) truncationReasons.push(`first ${renderedFileLimit} files (skip to paginate)`);
533
+ if (renderedPerFileLimit) truncationReasons.push(`first ${renderedPerFileLimit} matches per file`);
592
534
  if (truncation) truncationReasons.push(truncation.truncatedBy === "lines" ? "line limit" : "size limit");
593
535
  if (limits?.columnTruncated) truncationReasons.push(`line length ${limits.columnTruncated.maxColumn}`);
594
536
  if (truncation?.artifactId) truncationReasons.push(formatFullOutputReference(truncation.artifactId));
@@ -599,17 +541,14 @@ export const searchToolRenderer = {
599
541
  }
600
542
  if (missingNote) extraLines.push(missingNote);
601
543
 
602
- let cached: RenderCache | undefined;
603
- return {
604
- render(width: number): string[] {
605
- const { expanded } = options;
606
- const key = new Hasher().bool(expanded).u32(width).digest();
607
- if (cached?.key === key) return cached.lines;
544
+ return createCachedComponent(
545
+ () => options.expanded,
546
+ width => {
608
547
  const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0);
609
548
  const matchLines = renderTreeList(
610
549
  {
611
550
  items: matchGroups,
612
- expanded,
551
+ expanded: options.expanded,
613
552
  maxCollapsed: matchGroups.length,
614
553
  maxCollapsedLines: collapsedMatchLineBudget,
615
554
  itemType: "match",
@@ -622,14 +561,9 @@ export const searchToolRenderer = {
622
561
  },
623
562
  uiTheme,
624
563
  );
625
- const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
626
- cached = { key, lines: result };
627
- return result;
564
+ return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
628
565
  },
629
- invalidate() {
630
- cached = undefined;
631
- },
632
- };
566
+ );
633
567
  },
634
568
  mergeCallAndResult: true,
635
569
  };
package/src/tools/ssh.ts CHANGED
@@ -16,7 +16,7 @@ import { executeSSH } from "../ssh/ssh-executor";
16
16
  import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import type { ToolSession } from ".";
19
- import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
19
+ import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
20
20
  import { ToolError } from "./tool-errors";
21
21
  import { toolResult } from "./tool-result";
22
22
  import { clampTimeout } from "./tool-timeouts";
@@ -253,7 +253,8 @@ export const sshToolRenderer = {
253
253
  render: (width: number): string[] => {
254
254
  // REACTIVE: read mutable options at render time
255
255
  const { expanded, renderContext } = options;
256
- const output = textContent.trimEnd();
256
+ // Strip LLM-facing notice so we don't echo it next to the styled warning.
257
+ const output = stripOutputNotice(textContent, details?.meta).trimEnd();
257
258
  const outputLines: string[] = [];
258
259
 
259
260
  if (output) {
@@ -8,6 +8,8 @@ import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
9
  import { stripHashlinePrefixes } from "../edit";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
+ import { InternalUrlRouter } from "../internal-urls";
12
+ import { parseInternalUrl } from "../internal-urls/parse";
11
13
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
12
14
  import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
13
15
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
@@ -85,6 +87,21 @@ function stripWriteContent(session: ToolSession, content: string): { text: strin
85
87
  return { text: cleaned.join("\n"), stripped: true };
86
88
  }
87
89
 
90
+ /**
91
+ * Append a trailing note line to the first text block of a tool result.
92
+ * Mutates `result` in place (the result object is owned by this call).
93
+ */
94
+ function appendNoteToResult(result: AgentToolResult<WriteToolDetails>, note: string): void {
95
+ const firstText = result.content.find(
96
+ (block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string",
97
+ );
98
+ if (firstText) {
99
+ firstText.text = firstText.text.length > 0 ? `${firstText.text}\n${note}` : note;
100
+ } else {
101
+ result.content.push({ type: "text", text: note });
102
+ }
103
+ }
104
+
88
105
  // ═══════════════════════════════════════════════════════════════════════════
89
106
  // Tool Class
90
107
  // ═══════════════════════════════════════════════════════════════════════════
@@ -175,7 +192,6 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
175
192
  readonly concurrency = "exclusive";
176
193
  readonly loadMode = "discoverable";
177
194
  readonly summary = "Write content to a file (creates or overwrites)";
178
- readonly intent = (args: Partial<WriteToolInput>) => (args.path ? `writing ${args.path}` : "writing");
179
195
 
180
196
  readonly #writethrough: WritethroughCallback;
181
197
 
@@ -489,6 +505,26 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
489
505
  };
490
506
  }
491
507
 
508
+ /**
509
+ * Look up a single conflict entry by id and dispatch to {@link #resolveConflict}.
510
+ * Throws a clear `not found` error when the id has been invalidated.
511
+ */
512
+ async #resolveSingleConflictById(
513
+ id: number,
514
+ replacementContent: string,
515
+ stripped: boolean,
516
+ signal: AbortSignal | undefined,
517
+ context: AgentToolContext | undefined,
518
+ ): Promise<AgentToolResult<WriteToolDetails>> {
519
+ const entry = getConflictHistory(this.session).get(id);
520
+ if (!entry) {
521
+ throw new ToolError(
522
+ `Conflict #${id} not found. Conflict ids are registered when \`read\` surfaces a marker block; re-read the file to get a current id.`,
523
+ );
524
+ }
525
+ return this.#resolveConflict(entry, replacementContent, stripped, signal, context);
526
+ }
527
+
492
528
  /**
493
529
  * Bulk-resolve every registered conflict via `conflict://*`.
494
530
  *
@@ -624,6 +660,24 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
624
660
  return untilAborted(signal, async () => {
625
661
  // Strip hashline display prefixes (LINE+ID|) if the model copied them from read output
626
662
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
663
+ const internalRouter = InternalUrlRouter.instance();
664
+ if (internalRouter.canHandle(path)) {
665
+ const parsed = parseInternalUrl(path);
666
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
667
+ const handler = internalRouter.getHandler(scheme);
668
+ if (handler?.write) {
669
+ await handler.write(parsed, cleanContent, { cwd: this.session.cwd, signal });
670
+ let resultText = `Successfully wrote ${cleanContent.length} bytes to ${path}`;
671
+ if (stripped) {
672
+ resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
673
+ }
674
+ return { content: [{ type: "text", text: resultText }], details: {} };
675
+ }
676
+ // Schemes without a `write` hook fall through to existing logic
677
+ // (local:// resolves to a backing file via plan-mode-guard) or are
678
+ // rejected downstream when no backing file exists.
679
+ }
680
+
627
681
  const conflictUri = parseConflictUri(path);
628
682
  if (conflictUri) {
629
683
  if (conflictUri.scope) {
@@ -631,16 +685,17 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
631
685
  `Conflict URI scope '/${conflictUri.scope}' is read-only — read \`conflict://${conflictUri.id}/${conflictUri.scope}\` to inspect that side. To write, drop the scope (\`conflict://${conflictUri.id}\`) and put the chosen content (or shorthand like \`@${conflictUri.scope}\`) in \`content\`.`,
632
686
  );
633
687
  }
634
- if (conflictUri.id === "*") {
635
- return this.#resolveAllConflicts(cleanContent, stripped, signal, context);
636
- }
637
- const entry = getConflictHistory(this.session).get(conflictUri.id);
638
- if (!entry) {
639
- throw new ToolError(
640
- `Conflict #${conflictUri.id} not found. Conflict ids are registered when \`read\` surfaces a marker block; re-read the file to get a current id.`,
688
+ const result =
689
+ conflictUri.id === "*"
690
+ ? await this.#resolveAllConflicts(cleanContent, stripped, signal, context)
691
+ : await this.#resolveSingleConflictById(conflictUri.id, cleanContent, stripped, signal, context);
692
+ if (conflictUri.recoveredPrefix !== undefined) {
693
+ appendNoteToResult(
694
+ result,
695
+ `Note: stripped erroneous '${conflictUri.recoveredPrefix}:' prefix from path; conflict URIs are global (use \`conflict://${conflictUri.id}\`, not \`<file>:conflict://${conflictUri.id}\`).`,
641
696
  );
642
697
  }
643
- return this.#resolveConflict(entry, cleanContent, stripped, signal, context);
698
+ return result;
644
699
  }
645
700
  const resolvedArchivePath = await this.#resolveArchiveWritePath(path);
646
701
  if (resolvedArchivePath) {
@@ -10,6 +10,7 @@ import path from "node:path";
10
10
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
11
11
  import type { ImageContent } from "@oh-my-pi/pi-ai";
12
12
  import { glob } from "@oh-my-pi/pi-natives";
13
+ import { fuzzyMatch } from "@oh-my-pi/pi-tui";
13
14
  import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
14
15
  import { formatHashLines } from "../hashline/hash";
15
16
  import type { FileMentionMessage } from "../session/messages";
@@ -20,7 +21,6 @@ import {
20
21
  truncateHeadBytes,
21
22
  } from "../session/streaming-output";
22
23
  import { resolveReadPath } from "../tools/path-utils";
23
- import { fuzzyMatch } from "./fuzzy";
24
24
  import { formatDimensionNote, resizeImage } from "./image-resize";
25
25
 
26
26
  /** Regex to match @filepath patterns in text */
@@ -2,7 +2,6 @@ import * as fs from "node:fs/promises";
2
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
3
3
  import { formatBytes, readImageMetadata, SUPPORTED_IMAGE_MIME_TYPES } from "@oh-my-pi/pi-utils";
4
4
  import { resolveReadPath } from "../tools/path-utils";
5
- import { convertToPng } from "./image-convert";
6
5
  import { formatDimensionNote, resizeImage } from "./image-resize";
7
6
 
8
7
  export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024;
@@ -42,8 +41,13 @@ export async function ensureSupportedImageInput(image: ImageContent): Promise<Im
42
41
  if (SUPPORTED_INPUT_IMAGE_MIME_TYPES.has(image.mimeType)) {
43
42
  return image;
44
43
  }
45
- const converted = await convertToPng(image.data, image.mimeType);
46
- return converted ? { type: "image", data: converted.data, mimeType: converted.mimeType } : null;
44
+ try {
45
+ const bytes = Buffer.from(image.data, "base64");
46
+ const data = await new Bun.Image(bytes).png().toBase64();
47
+ return { type: "image", data, mimeType: "image/png" };
48
+ } catch {
49
+ return null;
50
+ }
47
51
  }
48
52
 
49
53
  export async function loadImageInput(options: LoadImageInputOptions): Promise<LoadedImageInput | null> {