@oh-my-pi/pi-coding-agent 3.25.0 → 3.31.0

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 (157) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/complete.ts +2 -4
  16. package/src/core/tools/edit-diff.ts +11 -4
  17. package/src/core/tools/edit.ts +7 -13
  18. package/src/core/tools/find.ts +111 -50
  19. package/src/core/tools/gemini-image.ts +128 -147
  20. package/src/core/tools/grep.ts +397 -415
  21. package/src/core/tools/index.test.ts +5 -1
  22. package/src/core/tools/index.ts +6 -8
  23. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  24. package/src/core/tools/ls.ts +12 -10
  25. package/src/core/tools/lsp/client.ts +58 -9
  26. package/src/core/tools/lsp/config.ts +205 -656
  27. package/src/core/tools/lsp/defaults.json +465 -0
  28. package/src/core/tools/lsp/index.ts +55 -32
  29. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  30. package/src/core/tools/lsp/types.ts +1 -0
  31. package/src/core/tools/lsp/utils.ts +1 -1
  32. package/src/core/tools/read.ts +152 -76
  33. package/src/core/tools/render-utils.ts +70 -10
  34. package/src/core/tools/review.ts +38 -126
  35. package/src/core/tools/task/artifacts.ts +5 -4
  36. package/src/core/tools/task/executor.ts +204 -67
  37. package/src/core/tools/task/index.ts +129 -92
  38. package/src/core/tools/task/name-generator.ts +1544 -214
  39. package/src/core/tools/task/parallel.ts +30 -3
  40. package/src/core/tools/task/render.ts +85 -39
  41. package/src/core/tools/task/types.ts +34 -11
  42. package/src/core/tools/task/worker.ts +152 -27
  43. package/src/core/tools/web-fetch.ts +220 -1657
  44. package/src/core/tools/web-scrapers/academic.test.ts +239 -0
  45. package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
  46. package/src/core/tools/web-scrapers/arxiv.ts +88 -0
  47. package/src/core/tools/web-scrapers/aur.ts +175 -0
  48. package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
  49. package/src/core/tools/web-scrapers/bluesky.ts +284 -0
  50. package/src/core/tools/web-scrapers/brew.ts +177 -0
  51. package/src/core/tools/web-scrapers/business.test.ts +82 -0
  52. package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
  53. package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
  54. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  55. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  56. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  57. package/src/core/tools/web-scrapers/coingecko.ts +184 -0
  58. package/src/core/tools/web-scrapers/crates-io.ts +128 -0
  59. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  60. package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
  61. package/src/core/tools/web-scrapers/devto.ts +177 -0
  62. package/src/core/tools/web-scrapers/discogs.ts +308 -0
  63. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  64. package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
  65. package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
  66. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  67. package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
  68. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  69. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  70. package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
  71. package/src/core/tools/web-scrapers/github-gist.ts +68 -0
  72. package/src/core/tools/web-scrapers/github.ts +455 -0
  73. package/src/core/tools/web-scrapers/gitlab.ts +456 -0
  74. package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
  75. package/src/core/tools/web-scrapers/hackage.ts +94 -0
  76. package/src/core/tools/web-scrapers/hackernews.ts +208 -0
  77. package/src/core/tools/web-scrapers/hex.ts +121 -0
  78. package/src/core/tools/web-scrapers/huggingface.ts +385 -0
  79. package/src/core/tools/web-scrapers/iacr.ts +86 -0
  80. package/src/core/tools/web-scrapers/index.ts +250 -0
  81. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  82. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  83. package/src/core/tools/web-scrapers/lobsters.ts +186 -0
  84. package/src/core/tools/web-scrapers/mastodon.ts +310 -0
  85. package/src/core/tools/web-scrapers/maven.ts +152 -0
  86. package/src/core/tools/web-scrapers/mdn.ts +174 -0
  87. package/src/core/tools/web-scrapers/media.test.ts +138 -0
  88. package/src/core/tools/web-scrapers/metacpan.ts +253 -0
  89. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  90. package/src/core/tools/web-scrapers/npm.ts +114 -0
  91. package/src/core/tools/web-scrapers/nuget.ts +205 -0
  92. package/src/core/tools/web-scrapers/nvd.ts +243 -0
  93. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  94. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  95. package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
  96. package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
  97. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  98. package/src/core/tools/web-scrapers/osv.ts +189 -0
  99. package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
  100. package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
  101. package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
  102. package/src/core/tools/web-scrapers/packagist.ts +174 -0
  103. package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
  104. package/src/core/tools/web-scrapers/pubmed.ts +178 -0
  105. package/src/core/tools/web-scrapers/pypi.ts +129 -0
  106. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  107. package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
  108. package/src/core/tools/web-scrapers/reddit.ts +104 -0
  109. package/src/core/tools/web-scrapers/repology.ts +262 -0
  110. package/src/core/tools/web-scrapers/research.test.ts +107 -0
  111. package/src/core/tools/web-scrapers/rfc.ts +209 -0
  112. package/src/core/tools/web-scrapers/rubygems.ts +117 -0
  113. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  114. package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
  115. package/src/core/tools/web-scrapers/security.test.ts +103 -0
  116. package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
  117. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  118. package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
  119. package/src/core/tools/web-scrapers/social.test.ts +259 -0
  120. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  121. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  122. package/src/core/tools/web-scrapers/spotify.ts +218 -0
  123. package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
  124. package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
  125. package/src/core/tools/web-scrapers/standards.test.ts +122 -0
  126. package/src/core/tools/web-scrapers/terraform.ts +304 -0
  127. package/src/core/tools/web-scrapers/tldr.ts +51 -0
  128. package/src/core/tools/web-scrapers/twitter.ts +96 -0
  129. package/src/core/tools/web-scrapers/types.ts +234 -0
  130. package/src/core/tools/web-scrapers/utils.ts +162 -0
  131. package/src/core/tools/web-scrapers/vimeo.ts +152 -0
  132. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  133. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  134. package/src/core/tools/web-scrapers/wikidata.ts +357 -0
  135. package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
  136. package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
  137. package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
  138. package/src/core/tools/web-scrapers/youtube.ts +371 -0
  139. package/src/core/tools/write.ts +21 -18
  140. package/src/core/voice.ts +3 -2
  141. package/src/lib/worktree/collapse.ts +2 -1
  142. package/src/lib/worktree/git.ts +2 -18
  143. package/src/main.ts +59 -3
  144. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  145. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  146. package/src/modes/interactive/components/hook-editor.ts +2 -1
  147. package/src/modes/interactive/components/model-selector.ts +19 -4
  148. package/src/modes/interactive/interactive-mode.ts +41 -38
  149. package/src/modes/interactive/theme/theme.ts +58 -58
  150. package/src/modes/rpc/rpc-mode.ts +10 -9
  151. package/src/prompts/review-request.md +27 -0
  152. package/src/prompts/reviewer.md +64 -68
  153. package/src/prompts/tools/output.md +22 -3
  154. package/src/prompts/tools/task.md +32 -33
  155. package/src/utils/clipboard.ts +2 -1
  156. package/src/utils/tools-manager.ts +110 -8
  157. package/examples/extensions/subagent/agents/reviewer.md +0 -35
@@ -1,13 +1,6 @@
1
1
  import { sendNotification, sendRequest } from "./client";
2
2
  import type { Diagnostic, ExpandMacroResult, LspClient, RelatedTest, Runnable, WorkspaceEdit } from "./types";
3
- import { fileToUri } from "./utils";
4
-
5
- /**
6
- * Wait for specified milliseconds.
7
- */
8
- async function sleep(ms: number): Promise<void> {
9
- return new Promise((resolve) => setTimeout(resolve, ms));
10
- }
3
+ import { fileToUri, sleep } from "./utils";
11
4
 
12
5
  /**
13
6
  * Run flycheck (cargo check) and collect diagnostics.
@@ -19,10 +12,56 @@ async function sleep(ms: number): Promise<void> {
19
12
  */
20
13
  export async function flycheck(client: LspClient, file?: string): Promise<Diagnostic[]> {
21
14
  const textDocument = file ? { uri: fileToUri(file) } : null;
15
+
16
+ const countDiagnostics = (diagnostics: Map<string, Diagnostic[]>): number => {
17
+ let count = 0;
18
+ for (const diags of diagnostics.values()) {
19
+ count += diags.length;
20
+ }
21
+ return count;
22
+ };
23
+
24
+ // Capture current diagnostic version before triggering flycheck
25
+ const initialDiagnosticsVersion = client.diagnosticsVersion;
26
+ const initialDiagnosticsCount = countDiagnostics(client.diagnostics);
27
+
22
28
  await sendNotification(client, "rust-analyzer/runFlycheck", { textDocument });
23
29
 
24
- // Wait for diagnostics to accumulate (2 seconds as per reference)
25
- await sleep(2000);
30
+ // Bounded polling: wait for diagnostics to stabilize or timeout
31
+ // Poll every 100ms for up to 8 seconds (80 iterations)
32
+ const pollIntervalMs = 100;
33
+ const maxPollIterations = 80;
34
+ const stabilityThreshold = 3; // Consider stable after 3 iterations without change
35
+ const minStableDurationMs = 2000; // Avoid early exit when diagnostics are re-published unchanged.
36
+ const startTime = Date.now();
37
+ let lastDiagnosticsVersion = initialDiagnosticsVersion;
38
+ let lastDiagnosticsCount = initialDiagnosticsCount;
39
+ let stableIterations = 0;
40
+
41
+ for (let i = 0; i < maxPollIterations; i++) {
42
+ await sleep(pollIntervalMs);
43
+
44
+ const currentDiagnosticsVersion = client.diagnosticsVersion;
45
+ const currentDiagnosticsCount = countDiagnostics(client.diagnostics);
46
+
47
+ // Check if diagnostics have stabilized
48
+ if (currentDiagnosticsVersion === lastDiagnosticsVersion && currentDiagnosticsCount === lastDiagnosticsCount) {
49
+ stableIterations++;
50
+ const elapsedMs = Date.now() - startTime;
51
+ const countChangedFromStart = currentDiagnosticsCount !== initialDiagnosticsCount;
52
+ if (
53
+ currentDiagnosticsVersion !== initialDiagnosticsVersion &&
54
+ stableIterations >= stabilityThreshold &&
55
+ (countChangedFromStart || elapsedMs >= minStableDurationMs)
56
+ ) {
57
+ break;
58
+ }
59
+ } else {
60
+ stableIterations = 0;
61
+ lastDiagnosticsVersion = currentDiagnosticsVersion;
62
+ lastDiagnosticsCount = currentDiagnosticsCount;
63
+ }
64
+ }
26
65
 
27
66
  // Collect all diagnostics from client
28
67
  const allDiags: Diagnostic[] = [];
@@ -403,6 +403,7 @@ export interface LspClient {
403
403
  process: Subprocess;
404
404
  requestId: number;
405
405
  diagnostics: Map<string, Diagnostic[]>;
406
+ diagnosticsVersion: number;
406
407
  openFiles: Map<string, OpenFile>;
407
408
  pendingRequests: Map<number, PendingRequest>;
408
409
  messageBuffer: Uint8Array;
@@ -492,7 +492,7 @@ export function extractHoverText(
492
492
  * Sleep for the specified number of milliseconds.
493
493
  */
494
494
  export function sleep(ms: number): Promise<void> {
495
- return new Promise((resolve) => setTimeout(resolve, ms));
495
+ return Bun.sleep(ms);
496
496
  }
497
497
 
498
498
  /**
@@ -1,11 +1,9 @@
1
- import { existsSync } from "node:fs";
2
1
  import path from "node:path";
3
2
  import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
4
3
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
5
4
  import type { Component } from "@oh-my-pi/pi-tui";
6
5
  import { Text } from "@oh-my-pi/pi-tui";
7
6
  import { Type } from "@sinclair/typebox";
8
- import { globSync } from "glob";
9
7
  import { getLanguageFromPath, highlightCode, type Theme } from "../../modes/interactive/theme/theme";
10
8
  import readDescription from "../../prompts/tools/read.md" with { type: "text" };
11
9
  import { formatDimensionNote, resizeImage } from "../../utils/image-resize";
@@ -13,7 +11,7 @@ import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
13
11
  import { ensureTool } from "../../utils/tools-manager";
14
12
  import type { RenderResultOptions } from "../custom-tools/types";
15
13
  import type { ToolSession } from "../sdk";
16
- import { untilAborted } from "../utils";
14
+ import { ScopeSignal, untilAborted } from "../utils";
17
15
  import { createLsTool } from "./ls";
18
16
  import { resolveReadPath, resolveToCwd } from "./path-utils";
19
17
  import { replaceTabs, shortenPath, wrapBrackets } from "./render-utils";
@@ -55,20 +53,16 @@ function isPathWithin(basePath: string, targetPath: string): boolean {
55
53
  return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
56
54
  }
57
55
 
58
- async function findExistingDirectory(startDir: string): Promise<string | null> {
56
+ async function findExistingDirectory(startDir: string, signal?: AbortSignal): Promise<string | null> {
59
57
  let current = startDir;
60
58
  const root = path.parse(startDir).root;
61
59
 
62
60
  while (true) {
61
+ signal?.throwIfAborted();
63
62
  try {
64
- if (existsSync(current)) {
65
- // Check if directory by trying to read it as dir
66
- try {
67
- await Bun.$`test -d ${current}`.quiet();
68
- return current;
69
- } catch {
70
- // Not a directory, continue
71
- }
63
+ const stat = await Bun.file(current).stat();
64
+ if (stat.isDirectory()) {
65
+ return current;
72
66
  }
73
67
  } catch {
74
68
  // Keep walking up.
@@ -149,8 +143,56 @@ function similarityScore(a: string, b: string): number {
149
143
  return 1 - distance / maxLen;
150
144
  }
151
145
 
146
+ async function captureCommandOutput(
147
+ command: string,
148
+ args: string[],
149
+ signal?: AbortSignal,
150
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null; aborted: boolean }> {
151
+ const child = Bun.spawn([command, ...args], {
152
+ stdin: "ignore",
153
+ stdout: "pipe",
154
+ stderr: "pipe",
155
+ });
156
+
157
+ using scope = new ScopeSignal(signal ? { signal } : undefined);
158
+ scope.catch(() => {
159
+ child.kill();
160
+ });
161
+
162
+ const stdoutReader = (child.stdout as ReadableStream<Uint8Array>).getReader();
163
+ const stderrReader = (child.stderr as ReadableStream<Uint8Array>).getReader();
164
+ const stdoutDecoder = new TextDecoder();
165
+ const stderrDecoder = new TextDecoder();
166
+ let stdout = "";
167
+ let stderr = "";
168
+
169
+ await Promise.all([
170
+ (async () => {
171
+ while (true) {
172
+ const { done, value } = await stdoutReader.read();
173
+ if (done) break;
174
+ stdout += stdoutDecoder.decode(value, { stream: true });
175
+ }
176
+ stdout += stdoutDecoder.decode();
177
+ })(),
178
+ (async () => {
179
+ while (true) {
180
+ const { done, value } = await stderrReader.read();
181
+ if (done) break;
182
+ stderr += stderrDecoder.decode(value, { stream: true });
183
+ }
184
+ stderr += stderrDecoder.decode();
185
+ })(),
186
+ ]);
187
+
188
+ const exitCode = await child.exited;
189
+
190
+ return { stdout, stderr, exitCode, aborted: scope.aborted };
191
+ }
192
+
152
193
  async function listCandidateFiles(
153
194
  searchRoot: string,
195
+ signal?: AbortSignal,
154
196
  ): Promise<{ files: string[]; truncated: boolean; error?: string }> {
155
197
  let fdPath: string | undefined;
156
198
  try {
@@ -167,22 +209,48 @@ async function listCandidateFiles(
167
209
 
168
210
  const gitignoreFiles = new Set<string>();
169
211
  const rootGitignore = path.join(searchRoot, ".gitignore");
170
- if (existsSync(rootGitignore)) {
212
+ if (await Bun.file(rootGitignore).exists()) {
171
213
  gitignoreFiles.add(rootGitignore);
172
214
  }
173
215
 
174
216
  try {
175
- const nestedGitignores = globSync("**/.gitignore", {
176
- cwd: searchRoot,
177
- dot: true,
178
- absolute: true,
179
- ignore: ["**/node_modules/**", "**/.git/**"],
180
- });
181
- for (const file of nestedGitignores) {
182
- gitignoreFiles.add(file);
217
+ const gitignoreArgs = [
218
+ "--type",
219
+ "f",
220
+ "--color=never",
221
+ "--hidden",
222
+ "--absolute-path",
223
+ "--glob",
224
+ ".gitignore",
225
+ "--exclude",
226
+ "node_modules",
227
+ "--exclude",
228
+ ".git",
229
+ searchRoot,
230
+ ];
231
+ const { stdout, aborted } = await captureCommandOutput(fdPath, gitignoreArgs, signal);
232
+ if (aborted) {
233
+ throw new Error("Operation aborted");
183
234
  }
184
- } catch {
185
- // Ignore glob errors.
235
+ const output = stdout.trim();
236
+ if (output) {
237
+ const nestedGitignores = output
238
+ .split("\n")
239
+ .map((line) => line.replace(/\r$/, "").trim())
240
+ .filter((line) => line.length > 0);
241
+ for (const file of nestedGitignores) {
242
+ const normalized = file.replace(/\\/g, "/");
243
+ if (normalized.includes("/node_modules/") || normalized.includes("/.git/")) {
244
+ continue;
245
+ }
246
+ gitignoreFiles.add(file);
247
+ }
248
+ }
249
+ } catch (error) {
250
+ if (error instanceof Error && error.message === "Operation aborted") {
251
+ throw error;
252
+ }
253
+ // Ignore gitignore scan errors.
186
254
  }
187
255
 
188
256
  for (const gitignorePath of gitignoreFiles) {
@@ -191,16 +259,16 @@ async function listCandidateFiles(
191
259
 
192
260
  args.push(".", searchRoot);
193
261
 
194
- const result = Bun.spawnSync([fdPath, ...args], {
195
- stdin: "ignore",
196
- stdout: "pipe",
197
- stderr: "pipe",
198
- });
262
+ const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(fdPath, args, signal);
263
+
264
+ if (aborted) {
265
+ throw new Error("Operation aborted");
266
+ }
199
267
 
200
- const output = result.stdout.toString().trim();
268
+ const output = stdout.trim();
201
269
 
202
- if (result.exitCode !== 0 && !output) {
203
- const errorMsg = result.stderr.toString().trim() || `fd exited with code ${result.exitCode}`;
270
+ if (exitCode !== 0 && !output) {
271
+ const errorMsg = stderr.trim() || `fd exited with code ${exitCode ?? -1}`;
204
272
  return { files: [], truncated: false, error: errorMsg };
205
273
  }
206
274
 
@@ -219,9 +287,10 @@ async function listCandidateFiles(
219
287
  async function findReadPathSuggestions(
220
288
  rawPath: string,
221
289
  cwd: string,
290
+ signal?: AbortSignal,
222
291
  ): Promise<{ suggestions: string[]; scopeLabel?: string; truncated?: boolean; error?: string } | null> {
223
292
  const resolvedPath = resolveToCwd(rawPath, cwd);
224
- const searchRoot = await findExistingDirectory(path.dirname(resolvedPath));
293
+ const searchRoot = await findExistingDirectory(path.dirname(resolvedPath), signal);
225
294
  if (!searchRoot) {
226
295
  return null;
227
296
  }
@@ -233,7 +302,7 @@ async function findReadPathSuggestions(
233
302
  }
234
303
  }
235
304
 
236
- const { files, truncated, error } = await listCandidateFiles(searchRoot);
305
+ const { files, truncated, error } = await listCandidateFiles(searchRoot, signal);
237
306
  const scopeLabel = formatScopeLabel(searchRoot, cwd);
238
307
 
239
308
  if (error && files.length === 0) {
@@ -259,6 +328,7 @@ async function findReadPathSuggestions(
259
328
  const seen = new Set<string>();
260
329
 
261
330
  for (const file of files) {
331
+ signal?.throwIfAborted();
262
332
  const cleaned = file.replace(/\r$/, "").trim();
263
333
  if (!cleaned) continue;
264
334
 
@@ -311,23 +381,26 @@ async function findReadPathSuggestions(
311
381
  return { suggestions, scopeLabel, truncated };
312
382
  }
313
383
 
314
- function convertWithMarkitdown(filePath: string): { content: string; ok: boolean; error?: string } {
315
- const cmd = Bun.which("markitdown");
384
+ async function convertWithMarkitdown(
385
+ filePath: string,
386
+ signal?: AbortSignal,
387
+ ): Promise<{ content: string; ok: boolean; error?: string }> {
388
+ const cmd = await ensureTool("markitdown", true);
316
389
  if (!cmd) {
317
- return { content: "", ok: false, error: "markitdown not found" };
390
+ return { content: "", ok: false, error: "markitdown not found (uv/pip unavailable)" };
318
391
  }
319
392
 
320
- const result = Bun.spawnSync([cmd, filePath], {
321
- stdin: "ignore",
322
- stdout: "pipe",
323
- stderr: "pipe",
324
- });
393
+ const { stdout, stderr, exitCode, aborted } = await captureCommandOutput(cmd, [filePath], signal);
394
+
395
+ if (aborted) {
396
+ throw new Error("Operation aborted");
397
+ }
325
398
 
326
- if (result.exitCode === 0 && result.stdout && result.stdout.length > 0) {
327
- return { content: result.stdout.toString(), ok: true };
399
+ if (exitCode === 0 && stdout.length > 0) {
400
+ return { content: stdout, ok: true };
328
401
  }
329
402
 
330
- return { content: "", ok: false, error: result.stderr.toString() || "Conversion failed" };
403
+ return { content: "", ok: false, error: stderr.trim() || "Conversion failed" };
331
404
  }
332
405
 
333
406
  const readSchema = Type.Object({
@@ -360,21 +433,12 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
360
433
  let isDirectory = false;
361
434
  let fileSize = 0;
362
435
  try {
363
- if (!existsSync(absolutePath)) {
364
- throw { code: "ENOENT" };
365
- }
366
- const file = Bun.file(absolutePath);
367
- fileSize = file.size;
368
- // Check if directory
369
- try {
370
- await Bun.$`test -d ${absolutePath}`.quiet();
371
- isDirectory = true;
372
- } catch {
373
- isDirectory = false;
374
- }
436
+ const stat = await Bun.file(absolutePath).stat();
437
+ fileSize = stat.size;
438
+ isDirectory = stat.isDirectory();
375
439
  } catch (error) {
376
440
  if (isNotFoundError(error)) {
377
- const suggestions = await findReadPathSuggestions(readPath, session.cwd);
441
+ const suggestions = await findReadPathSuggestions(readPath, session.cwd, signal);
378
442
  let message = `File not found: ${readPath}`;
379
443
 
380
444
  if (suggestions?.suggestions.length) {
@@ -410,7 +474,6 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
410
474
  let details: ReadToolDetails | undefined;
411
475
 
412
476
  if (mimeType) {
413
- // Check image file size before reading to prevent OOM during serialization
414
477
  if (fileSize > MAX_IMAGE_SIZE) {
415
478
  const sizeStr = formatSize(fileSize);
416
479
  const maxStr = formatSize(MAX_IMAGE_SIZE);
@@ -424,32 +487,45 @@ export function createReadTool(session: ToolSession): AgentTool<typeof readSchem
424
487
  // Read as image (binary)
425
488
  const file = Bun.file(absolutePath);
426
489
  const buffer = await file.arrayBuffer();
427
- const base64 = Buffer.from(buffer).toString("base64");
428
-
429
- if (autoResizeImages) {
430
- // Resize image if needed
431
- const resized = await resizeImage({ type: "image", data: base64, mimeType });
432
- const dimensionNote = formatDimensionNote(resized);
433
-
434
- let textNote = `Read image file [${resized.mimeType}]`;
435
- if (dimensionNote) {
436
- textNote += `\n${dimensionNote}`;
437
- }
438
490
 
491
+ // Check actual buffer size after reading to prevent OOM during serialization
492
+ if (buffer.byteLength > MAX_IMAGE_SIZE) {
493
+ const sizeStr = formatSize(buffer.byteLength);
494
+ const maxStr = formatSize(MAX_IMAGE_SIZE);
439
495
  content = [
440
- { type: "text", text: textNote },
441
- { type: "image", data: resized.data, mimeType: resized.mimeType },
496
+ {
497
+ type: "text",
498
+ text: `[Image file too large: ${sizeStr} exceeds ${maxStr} limit. Use an image viewer or resize the image.]`,
499
+ },
442
500
  ];
443
501
  } else {
444
- content = [
445
- { type: "text", text: `Read image file [${mimeType}]` },
446
- { type: "image", data: base64, mimeType },
447
- ];
502
+ const base64 = Buffer.from(buffer).toString("base64");
503
+
504
+ if (autoResizeImages) {
505
+ // Resize image if needed
506
+ const resized = await resizeImage({ type: "image", data: base64, mimeType });
507
+ const dimensionNote = formatDimensionNote(resized);
508
+
509
+ let textNote = `Read image file [${resized.mimeType}]`;
510
+ if (dimensionNote) {
511
+ textNote += `\n${dimensionNote}`;
512
+ }
513
+
514
+ content = [
515
+ { type: "text", text: textNote },
516
+ { type: "image", data: resized.data, mimeType: resized.mimeType },
517
+ ];
518
+ } else {
519
+ content = [
520
+ { type: "text", text: `Read image file [${mimeType}]` },
521
+ { type: "image", data: base64, mimeType },
522
+ ];
523
+ }
448
524
  }
449
525
  }
450
526
  } else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
451
527
  // Convert document via markitdown
452
- const result = convertWithMarkitdown(absolutePath);
528
+ const result = await convertWithMarkitdown(absolutePath, signal);
453
529
  if (result.ok) {
454
530
  // Apply truncation to converted content
455
531
  const truncation = truncateHead(result.content);
@@ -147,11 +147,7 @@ export function formatAge(ageSeconds: number | null | undefined): string {
147
147
  * Get the appropriate status icon with color for a given state.
148
148
  * Standardizes status icon usage across all renderers.
149
149
  */
150
- export function getStyledStatusIcon(
151
- status: "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted",
152
- theme: Theme,
153
- spinnerFrame?: number,
154
- ): string {
150
+ export function getStyledStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFrame?: number): string {
155
151
  switch (status) {
156
152
  case "success":
157
153
  return theme.styledSymbol("status.success", "success");
@@ -185,11 +181,7 @@ export function formatExpandHint(expanded: boolean, hasMore: boolean, theme: The
185
181
  /**
186
182
  * Format a badge like [done] or [failed] with brackets and color.
187
183
  */
188
- export function formatBadge(
189
- label: string,
190
- color: "success" | "error" | "warning" | "accent" | "muted",
191
- theme: Theme,
192
- ): string {
184
+ export function formatBadge(label: string, color: ToolUIColor, theme: Theme): string {
193
185
  const left = theme.format.bracketLeft;
194
186
  const right = theme.format.bracketRight;
195
187
  return theme.fg(color, `${left}${label}${right}`);
@@ -225,6 +217,74 @@ export function formatEmptyMessage(message: string, theme: Theme): string {
225
217
  return `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`;
226
218
  }
227
219
 
220
+ // =============================================================================
221
+ // Tool UI Kit
222
+ // =============================================================================
223
+
224
+ export type ToolUIStatus = "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted";
225
+ export type ToolUIColor = "success" | "error" | "warning" | "accent" | "muted";
226
+
227
+ export interface ToolUITitleOptions {
228
+ bold?: boolean;
229
+ }
230
+
231
+ export interface ToolUIKit {
232
+ theme: Theme;
233
+ title: (label: string, options?: ToolUITitleOptions) => string;
234
+ meta: (meta: string[]) => string;
235
+ count: (label: string, count: number) => string;
236
+ moreItems: (remaining: number, itemType: string) => string;
237
+ expandHint: (expanded: boolean, hasMore: boolean) => string;
238
+ scope: (scopePath?: string) => string;
239
+ truncationSuffix: (truncated: boolean) => string;
240
+ errorMessage: (message: string | undefined) => string;
241
+ emptyMessage: (message: string) => string;
242
+ badge: (label: string, color: ToolUIColor) => string;
243
+ statusIcon: (status: ToolUIStatus, spinnerFrame?: number) => string;
244
+ wrapBrackets: (text: string) => string;
245
+ truncate: (text: string, maxLen: number) => string;
246
+ previewLines: (text: string, maxLines: number, maxLineLen: number) => string[];
247
+ formatBytes: (bytes: number) => string;
248
+ formatTokens: (tokens: number) => string;
249
+ formatDuration: (ms: number) => string;
250
+ formatAge: (ageSeconds: number | null | undefined) => string;
251
+ formatDiagnostics: (
252
+ diag: { errored: boolean; summary: string; messages: string[] },
253
+ expanded: boolean,
254
+ getLangIcon: (filePath: string) => string,
255
+ ) => string;
256
+ formatDiffStats: (added: number, removed: number, hunks: number) => string;
257
+ }
258
+
259
+ export function createToolUIKit(theme: Theme): ToolUIKit {
260
+ return {
261
+ theme,
262
+ title: (label, options) => {
263
+ const content = options?.bold === false ? label : theme.bold(label);
264
+ return theme.fg("toolTitle", content);
265
+ },
266
+ meta: (meta) => formatMeta(meta, theme),
267
+ count: (label, count) => formatCount(label, count),
268
+ moreItems: (remaining, itemType) => formatMoreItems(remaining, itemType, theme),
269
+ expandHint: (expanded, hasMore) => formatExpandHint(expanded, hasMore, theme),
270
+ scope: (scopePath) => formatScope(scopePath, theme),
271
+ truncationSuffix: (truncated) => formatTruncationSuffix(truncated, theme),
272
+ errorMessage: (message) => formatErrorMessage(message, theme),
273
+ emptyMessage: (message) => formatEmptyMessage(message, theme),
274
+ badge: (label, color) => formatBadge(label, color, theme),
275
+ statusIcon: (status, spinnerFrame) => getStyledStatusIcon(status, theme, spinnerFrame),
276
+ wrapBrackets: (text) => wrapBrackets(text, theme),
277
+ truncate: (text, maxLen) => truncate(text, maxLen, theme.format.ellipsis),
278
+ previewLines: (text, maxLines, maxLineLen) => getPreviewLines(text, maxLines, maxLineLen, theme.format.ellipsis),
279
+ formatBytes,
280
+ formatTokens,
281
+ formatDuration,
282
+ formatAge,
283
+ formatDiagnostics: (diag, expanded, getLangIcon) => formatDiagnostics(diag, expanded, theme, getLangIcon),
284
+ formatDiffStats: (added, removed, hunks) => formatDiffStats(added, removed, hunks, theme),
285
+ };
286
+ }
287
+
228
288
  // =============================================================================
229
289
  // Diagnostic Formatting
230
290
  // =============================================================================