@oh-my-pi/pi-coding-agent 10.3.2 → 10.6.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 (58) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/package.json +16 -11
  3. package/src/capability/index.ts +9 -0
  4. package/src/cli/update-cli.ts +2 -5
  5. package/src/config/settings-schema.ts +39 -1
  6. package/src/cursor.ts +1 -1
  7. package/src/extensibility/custom-tools/wrapper.ts +9 -33
  8. package/src/extensibility/extensions/wrapper.ts +18 -31
  9. package/src/extensibility/hooks/tool-wrapper.ts +6 -16
  10. package/src/extensibility/tool-proxy.ts +25 -0
  11. package/src/index.ts +1 -0
  12. package/src/ipy/executor.ts +107 -3
  13. package/src/ipy/gateway-coordinator.ts +0 -4
  14. package/src/ipy/kernel.ts +65 -175
  15. package/src/main.ts +17 -0
  16. package/src/mcp/render.ts +10 -226
  17. package/src/modes/components/tool-execution.ts +83 -96
  18. package/src/modes/controllers/input-controller.ts +38 -0
  19. package/src/modes/interactive-mode.ts +13 -0
  20. package/src/patch/index.ts +1 -0
  21. package/src/prompts/system/system-prompt.md +5 -2
  22. package/src/prompts/tools/ask.md +6 -9
  23. package/src/prompts/tools/browser.md +26 -0
  24. package/src/prompts/tools/grep.md +4 -8
  25. package/src/prompts/tools/task.md +29 -4
  26. package/src/sdk.ts +21 -0
  27. package/src/session/session-manager.ts +1 -0
  28. package/src/task/executor.ts +5 -47
  29. package/src/tools/ask.ts +60 -71
  30. package/src/tools/bash.ts +1 -0
  31. package/src/tools/browser.ts +1138 -0
  32. package/src/tools/find.ts +11 -2
  33. package/src/tools/grep.ts +111 -107
  34. package/src/tools/index.ts +4 -0
  35. package/src/tools/json-tree.ts +231 -0
  36. package/src/tools/notebook.ts +1 -0
  37. package/src/tools/puppeteer/00_stealth_tampering.txt +63 -0
  38. package/src/tools/puppeteer/01_stealth_activity.txt +20 -0
  39. package/src/tools/puppeteer/02_stealth_hairline.txt +11 -0
  40. package/src/tools/puppeteer/03_stealth_botd.txt +384 -0
  41. package/src/tools/puppeteer/04_stealth_iframe.txt +81 -0
  42. package/src/tools/puppeteer/05_stealth_webgl.txt +75 -0
  43. package/src/tools/puppeteer/06_stealth_screen.txt +72 -0
  44. package/src/tools/puppeteer/07_stealth_fonts.txt +97 -0
  45. package/src/tools/puppeteer/08_stealth_audio.txt +51 -0
  46. package/src/tools/puppeteer/09_stealth_locale.txt +46 -0
  47. package/src/tools/puppeteer/10_stealth_plugins.txt +206 -0
  48. package/src/tools/puppeteer/11_stealth_hardware.txt +8 -0
  49. package/src/tools/puppeteer/12_stealth_codecs.txt +40 -0
  50. package/src/tools/puppeteer/13_stealth_worker.txt +74 -0
  51. package/src/tools/python.ts +1 -0
  52. package/src/tools/ssh.ts +1 -0
  53. package/src/tools/todo-write.ts +1 -0
  54. package/src/tools/write.ts +1 -0
  55. package/src/web/search/index.ts +15 -4
  56. package/src/web/search/providers/jina.ts +76 -0
  57. package/src/web/search/render.ts +3 -1
  58. package/src/web/search/types.ts +2 -2
package/src/tools/find.ts CHANGED
@@ -286,8 +286,9 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
286
286
  : undefined;
287
287
  const timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
288
288
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
289
- try {
290
- const result = await untilAborted(combinedSignal, () =>
289
+
290
+ const doGlob = async (useGitignore: boolean) =>
291
+ untilAborted(combinedSignal, () =>
291
292
  glob(
292
293
  {
293
294
  pattern: globPattern,
@@ -296,10 +297,18 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
296
297
  hidden: includeHidden,
297
298
  maxResults: effectiveLimit,
298
299
  sortByMtime: true,
300
+ gitignore: useGitignore,
299
301
  },
300
302
  onMatch,
301
303
  ),
302
304
  );
305
+
306
+ try {
307
+ let result = await doGlob(true);
308
+ // If gitignore filtering yielded nothing, retry without it
309
+ if (result.matches.length === 0) {
310
+ result = await doGlob(false);
311
+ }
303
312
  matches = result.matches;
304
313
  } catch (error) {
305
314
  if (error instanceof Error && error.name === "AbortError") {
package/src/tools/grep.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as nodePath from "node:path";
2
2
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
- import { StringEnum } from "@oh-my-pi/pi-ai";
4
- import { type GrepMatch as WasmGrepMatch, grep as wasmGrep } from "@oh-my-pi/pi-natives";
3
+
4
+ import { grep as wasmGrep } from "@oh-my-pi/pi-natives";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { untilAborted } from "@oh-my-pi/pi-utils";
@@ -10,7 +10,7 @@ import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import type { Theme } from "../modes/theme/theme";
12
12
  import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
13
- import { renderFileList, renderStatusLine, renderTreeList } from "../tui";
13
+ import { renderStatusLine, renderTreeList } from "../tui";
14
14
  import type { ToolSession } from ".";
15
15
  import type { OutputMeta } from "./output-meta";
16
16
  import { resolveToCwd } from "./path-utils";
@@ -24,16 +24,11 @@ const grepSchema = Type.Object({
24
24
  path: Type.Optional(Type.String({ description: "File or directory to search (default: cwd)" })),
25
25
  glob: Type.Optional(Type.String({ description: "Filter files by glob pattern (e.g., '*.js')" })),
26
26
  type: Type.Optional(Type.String({ description: "Filter by file type (e.g., js, py, rust)" })),
27
- output_mode: Type.Optional(
28
- StringEnum(["filesWithMatches", "content", "count"], {
29
- description: "Output format (default: files_with_matches)",
30
- }),
31
- ),
32
27
  i: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
33
- n: Type.Optional(Type.Boolean({ description: "Show line numbers (default: true)" })),
34
- context: Type.Optional(Type.Number({ description: "Lines of context (default: 5)" })),
35
- multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching (default: false)" })),
36
- limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 100 in content mode)" })),
28
+ pre: Type.Optional(Type.Number({ description: "Lines of context before matches" })),
29
+ post: Type.Optional(Type.Number({ description: "Lines of context after matches" })),
30
+ multiline: Type.Optional(Type.Boolean({ description: "Enable multiline matching" })),
31
+ limit: Type.Optional(Type.Number({ description: "Limit output to first N matches (default: 100)" })),
37
32
  offset: Type.Optional(Type.Number({ description: "Skip first N entries before applying limit (default: 0)" })),
38
33
  });
39
34
 
@@ -50,7 +45,6 @@ export interface GrepToolDetails {
50
45
  fileCount?: number;
51
46
  files?: string[];
52
47
  fileMatches?: Array<{ path: string; count: number }>;
53
- mode?: "content" | "filesWithMatches" | "count";
54
48
  truncated?: boolean;
55
49
  error?: string;
56
50
  }
@@ -86,7 +80,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
86
80
  _onUpdate?: AgentToolUpdateCallback<GrepToolDetails>,
87
81
  _toolContext?: AgentToolContext,
88
82
  ): Promise<AgentToolResult<GrepToolDetails>> {
89
- const { pattern, path: searchDir, glob, type, output_mode, i, n, context, multiline, limit, offset } = params;
83
+ const { pattern, path: searchDir, glob, type, i, pre, post, multiline, limit, offset } = params;
90
84
 
91
85
  return untilAborted(signal, async () => {
92
86
  const normalizedPattern = pattern.trim();
@@ -105,10 +99,13 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
105
99
  }
106
100
  const normalizedLimit = rawLimit !== undefined && rawLimit > 0 ? rawLimit : undefined;
107
101
 
108
- const normalizedContext = context ?? 5;
109
- const showLineNumbers = n ?? true;
102
+ const defaultContextBefore = this.session.settings.get("grep.contextBefore");
103
+ const defaultContextAfter = this.session.settings.get("grep.contextAfter");
104
+ const normalizedContextBefore = pre ?? defaultContextBefore;
105
+ const normalizedContextAfter = post ?? defaultContextAfter;
110
106
  const ignoreCase = i ?? false;
111
- const hasContentHints = limit !== undefined || context !== undefined;
107
+ const patternHasNewline = normalizedPattern.includes("\n") || normalizedPattern.includes("\\n");
108
+ const effectiveMultiline = multiline ?? patternHasNewline;
112
109
 
113
110
  const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
114
111
  const scopePath = (() => {
@@ -124,9 +121,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
124
121
  throw new ToolError(`Path not found: ${searchPath}`);
125
122
  }
126
123
 
127
- const effectiveOutputMode = output_mode ?? (!isDirectory || hasContentHints ? "content" : "filesWithMatches");
128
- const effectiveLimit =
129
- effectiveOutputMode === "content" ? (normalizedLimit ?? DEFAULT_MATCH_LIMIT) : normalizedLimit;
124
+ const effectiveOutputMode = "content";
125
+ const effectiveLimit = normalizedLimit ?? DEFAULT_MATCH_LIMIT;
130
126
 
131
127
  // Run WASM grep
132
128
  let result: Awaited<ReturnType<typeof wasmGrep>>;
@@ -137,11 +133,12 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
137
133
  glob: glob?.trim() || undefined,
138
134
  type: type?.trim() || undefined,
139
135
  ignoreCase,
140
- multiline: multiline ?? false,
136
+ multiline: effectiveMultiline,
141
137
  hidden: true,
142
138
  maxCount: effectiveLimit,
143
139
  offset: normalizedOffset > 0 ? normalizedOffset : undefined,
144
- context: effectiveOutputMode === "content" ? normalizedContext : undefined,
140
+ contextBefore: normalizedContextBefore,
141
+ contextAfter: normalizedContextAfter,
145
142
  maxColumns: DEFAULT_MAX_COLUMN,
146
143
  mode: effectiveOutputMode,
147
144
  });
@@ -180,71 +177,66 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
180
177
  matchCount: 0,
181
178
  fileCount: 0,
182
179
  files: [],
183
- mode: effectiveOutputMode,
184
180
  truncated: false,
185
181
  };
186
182
  return toolResult(details).text("No matches found").done();
187
183
  }
188
184
 
189
- let outputLines: string[] = [];
185
+ const outputLines: string[] = [];
190
186
  let linesTruncated = false;
187
+ let matchIndex = 0;
191
188
 
192
189
  for (const match of result.matches) {
193
190
  recordFile(match.path);
194
191
  const relativePath = formatPath(match.path);
195
192
 
196
- if (effectiveOutputMode === "content") {
197
- // Add context before
198
- if (match.contextBefore) {
199
- for (const ctx of match.contextBefore) {
200
- outputLines.push(
201
- showLineNumbers
202
- ? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
203
- : `${relativePath}- ${ctx.line}`,
204
- );
205
- }
193
+ matchIndex += 1;
194
+ if (matchIndex > 1) {
195
+ outputLines.push("");
196
+ }
197
+ outputLines.push(`${matchIndex}. ${relativePath}:${match.lineNumber}`);
198
+
199
+ const lineNumbers: number[] = [match.lineNumber];
200
+ if (match.contextBefore) {
201
+ for (const ctx of match.contextBefore) {
202
+ lineNumbers.push(ctx.lineNumber);
206
203
  }
204
+ }
205
+ if (match.contextAfter) {
206
+ for (const ctx of match.contextAfter) {
207
+ lineNumbers.push(ctx.lineNumber);
208
+ }
209
+ }
210
+ const lineWidth = Math.max(...lineNumbers.map(value => value.toString().length));
207
211
 
208
- // Add match line
209
- outputLines.push(
210
- showLineNumbers
211
- ? `${relativePath}:${match.lineNumber}: ${match.line}`
212
- : `${relativePath}: ${match.line}`,
213
- );
212
+ const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
213
+ const padded = lineNumber.toString().padStart(lineWidth, " ");
214
+ return isMatch ? `>>${padded} ${line}` : ` ${padded} ${line}`;
215
+ };
214
216
 
215
- if (match.truncated) {
216
- linesTruncated = true;
217
+ // Add context before
218
+ if (match.contextBefore) {
219
+ for (const ctx of match.contextBefore) {
220
+ outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
217
221
  }
222
+ }
218
223
 
219
- // Add context after
220
- if (match.contextAfter) {
221
- for (const ctx of match.contextAfter) {
222
- outputLines.push(
223
- showLineNumbers
224
- ? `${relativePath}-${ctx.lineNumber}- ${ctx.line}`
225
- : `${relativePath}- ${ctx.line}`,
226
- );
227
- }
228
- }
224
+ // Add match line
225
+ outputLines.push(formatLine(match.lineNumber, match.line, true));
229
226
 
230
- // Track per-file counts
231
- fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
232
- } else if (effectiveOutputMode === "filesWithMatches") {
233
- // One line per file
234
- const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
235
- fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 1);
236
- } else {
237
- // count mode
238
- const matchWithCount = match as WasmGrepMatch & { matchCount?: number };
239
- fileMatchCounts.set(relativePath, matchWithCount.matchCount ?? 0);
227
+ if (match.truncated) {
228
+ linesTruncated = true;
240
229
  }
241
- }
242
230
 
243
- // Format output based on mode
244
- if (effectiveOutputMode === "filesWithMatches") {
245
- outputLines = fileList;
246
- } else if (effectiveOutputMode === "count") {
247
- outputLines = fileList.map(f => `${f}:${fileMatchCounts.get(f) ?? 0}`);
231
+ // Add context after
232
+ if (match.contextAfter) {
233
+ for (const ctx of match.contextAfter) {
234
+ outputLines.push(formatLine(ctx.lineNumber, ctx.line, false));
235
+ }
236
+ }
237
+
238
+ // Track per-file counts
239
+ fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
248
240
  }
249
241
 
250
242
  const rawOutput = outputLines.join("\n");
@@ -261,7 +253,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
261
253
  path,
262
254
  count: fileMatchCounts.get(path) ?? 0,
263
255
  })),
264
- mode: effectiveOutputMode,
265
256
  truncated,
266
257
  matchLimitReached: result.limitReached ? effectiveLimit : undefined,
267
258
  };
@@ -295,15 +286,13 @@ interface GrepRenderArgs {
295
286
  glob?: string;
296
287
  type?: string;
297
288
  i?: boolean;
298
- n?: boolean;
299
- context?: number;
289
+ pre?: number;
290
+ post?: number;
300
291
  multiline?: boolean;
301
- output_mode?: string;
302
292
  limit?: number;
303
293
  offset?: number;
304
294
  }
305
295
 
306
- const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
307
296
  const COLLAPSED_TEXT_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
308
297
 
309
298
  export const grepToolRenderer = {
@@ -313,10 +302,13 @@ export const grepToolRenderer = {
313
302
  if (args.path) meta.push(`in ${args.path}`);
314
303
  if (args.glob) meta.push(`glob:${args.glob}`);
315
304
  if (args.type) meta.push(`type:${args.type}`);
316
- if (args.output_mode && args.output_mode !== "filesWithMatches") meta.push(`mode:${args.output_mode}`);
317
305
  if (args.i) meta.push("case:insensitive");
318
- if (args.n === false) meta.push("no-line-numbers");
319
- if (args.context !== undefined && args.context > 0) meta.push(`context:${args.context}`);
306
+ if (args.pre !== undefined && args.pre > 0) {
307
+ meta.push(`pre:${args.pre}`);
308
+ }
309
+ if (args.post !== undefined && args.post > 0) {
310
+ meta.push(`post:${args.post}`);
311
+ }
320
312
  if (args.multiline) meta.push("multiline");
321
313
  if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
322
314
  if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
@@ -369,13 +361,11 @@ export const grepToolRenderer = {
369
361
 
370
362
  const matchCount = details?.matchCount ?? 0;
371
363
  const fileCount = details?.fileCount ?? 0;
372
- const mode = details?.mode ?? "filesWithMatches";
373
364
  const truncation = details?.meta?.truncation;
374
365
  const limits = details?.meta?.limits;
375
366
  const truncated = Boolean(
376
367
  details?.truncated || truncation || limits?.matchLimit || limits?.resultLimit || limits?.columnTruncated,
377
368
  );
378
- const files = details?.files ?? [];
379
369
 
380
370
  if (matchCount === 0) {
381
371
  const header = renderStatusLine(
@@ -385,10 +375,7 @@ export const grepToolRenderer = {
385
375
  return new Text([header, formatEmptyMessage("No matches found", uiTheme)].join("\n"), 0, 0);
386
376
  }
387
377
 
388
- const summaryParts =
389
- mode === "filesWithMatches"
390
- ? [formatCount("file", fileCount)]
391
- : [formatCount("match", matchCount), formatCount("file", fileCount)];
378
+ const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
392
379
  const meta = [...summaryParts];
393
380
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
394
381
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
@@ -398,34 +385,51 @@ export const grepToolRenderer = {
398
385
  uiTheme,
399
386
  );
400
387
 
401
- if (mode === "content") {
402
- const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
403
- const contentLines = textContent.split("\n").filter(line => line.trim().length > 0);
404
- const matchLines = renderTreeList(
405
- {
406
- items: contentLines,
407
- expanded,
408
- maxCollapsed: COLLAPSED_TEXT_LIMIT,
409
- itemType: "match",
410
- renderItem: line => uiTheme.fg("toolOutput", line),
411
- },
412
- uiTheme,
413
- );
414
- return new Text([header, ...matchLines].join("\n"), 0, 0);
388
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
389
+ const rawLines = textContent.split("\n");
390
+ const hasSeparators = rawLines.some(line => line.trim().length === 0);
391
+ const matchGroups: string[][] = [];
392
+ if (hasSeparators) {
393
+ let current: string[] = [];
394
+ for (const line of rawLines) {
395
+ if (line.trim().length === 0) {
396
+ if (current.length > 0) {
397
+ matchGroups.push(current);
398
+ current = [];
399
+ }
400
+ continue;
401
+ }
402
+ current.push(line);
403
+ }
404
+ if (current.length > 0) matchGroups.push(current);
405
+ } else {
406
+ for (const line of rawLines) {
407
+ if (line.trim().length === 0) continue;
408
+ matchGroups.push([line]);
409
+ }
415
410
  }
416
411
 
417
- const fileEntries: Array<{ path: string; count?: number }> = details?.fileMatches?.length
418
- ? details.fileMatches.map(entry => ({ path: entry.path, count: entry.count }))
419
- : files.map(path => ({ path }));
420
- const fileLines = renderFileList(
412
+ const getCollapsedMatchLimit = (groups: string[][], maxLines: number): number => {
413
+ if (groups.length === 0) return 0;
414
+ let usedLines = 0;
415
+ let count = 0;
416
+ for (const group of groups) {
417
+ if (count > 0 && usedLines + group.length > maxLines) break;
418
+ usedLines += group.length;
419
+ count += 1;
420
+ if (usedLines >= maxLines) break;
421
+ }
422
+ return count;
423
+ };
424
+
425
+ const maxCollapsed = expanded ? matchGroups.length : getCollapsedMatchLimit(matchGroups, COLLAPSED_TEXT_LIMIT);
426
+ const matchLines = renderTreeList(
421
427
  {
422
- files: fileEntries.map(entry => ({
423
- path: entry.path,
424
- isDirectory: entry.path.endsWith("/"),
425
- meta: entry.count !== undefined ? `(${entry.count} match${entry.count !== 1 ? "es" : ""})` : undefined,
426
- })),
428
+ items: matchGroups,
427
429
  expanded,
428
- maxCollapsed: COLLAPSED_LIST_LIMIT,
430
+ maxCollapsed,
431
+ itemType: "match",
432
+ renderItem: group => group.map(line => uiTheme.fg("toolOutput", line)),
429
433
  },
430
434
  uiTheme,
431
435
  );
@@ -440,7 +444,7 @@ export const grepToolRenderer = {
440
444
  const extraLines =
441
445
  truncationReasons.length > 0 ? [uiTheme.fg("warning", `truncated: ${truncationReasons.join(", ")}`)] : [];
442
446
 
443
- return new Text([header, ...fileLines, ...extraLines].join("\n"), 0, 0);
447
+ return new Text([header, ...matchLines, ...extraLines].join("\n"), 0, 0);
444
448
  },
445
449
  mergeCallAndResult: true,
446
450
  };
@@ -17,6 +17,7 @@ import { time } from "../utils/timings";
17
17
  import { WebSearchTool } from "../web/search";
18
18
  import { AskTool } from "./ask";
19
19
  import { BashTool } from "./bash";
20
+ import { BrowserTool } from "./browser";
20
21
  import { CalculatorTool } from "./calculator";
21
22
  import { ExitPlanModeTool } from "./exit-plan-mode";
22
23
  import { FetchTool } from "./fetch";
@@ -69,6 +70,7 @@ export {
69
70
  } from "../web/search";
70
71
  export { AskTool, type AskToolDetails } from "./ask";
71
72
  export { BashTool, type BashToolDetails, type BashToolOptions } from "./bash";
73
+ export { BrowserTool, type BrowserToolDetails } from "./browser";
72
74
  export { CalculatorTool, type CalculatorToolDetails } from "./calculator";
73
75
  export { type ExitPlanModeDetails, ExitPlanModeTool } from "./exit-plan-mode";
74
76
  export { FetchTool, type FetchToolDetails } from "./fetch";
@@ -171,6 +173,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
171
173
  lsp: LspTool.createIf,
172
174
  notebook: s => new NotebookTool(s),
173
175
  read: s => new ReadTool(s),
176
+ browser: s => new BrowserTool(s),
174
177
  task: TaskTool.create,
175
178
  todo_write: s => new TodoWriteTool(s),
176
179
  fetch: s => new FetchTool(s),
@@ -283,6 +286,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
283
286
  if (name === "web_search") return session.settings.get("web_search.enabled");
284
287
  if (name === "lsp") return session.settings.get("lsp.enabled");
285
288
  if (name === "calc") return session.settings.get("calc.enabled");
289
+ if (name === "browser") return session.settings.get("browser.enabled");
286
290
  return true;
287
291
  };
288
292
  if (includeSubmitResult && requestedTools && !requestedTools.includes("submit_result")) {
@@ -0,0 +1,231 @@
1
+ /**
2
+ * JSON tree rendering utilities shared across tool renderers.
3
+ */
4
+ import type { Theme } from "../modes/theme/theme";
5
+ import { truncateToWidth } from "./render-utils";
6
+
7
+ /** Max depth for JSON tree rendering */
8
+ export const JSON_TREE_MAX_DEPTH_COLLAPSED = 2;
9
+ export const JSON_TREE_MAX_DEPTH_EXPANDED = 6;
10
+ export const JSON_TREE_MAX_LINES_COLLAPSED = 6;
11
+ export const JSON_TREE_MAX_LINES_EXPANDED = 200;
12
+ export const JSON_TREE_SCALAR_LEN_COLLAPSED = 60;
13
+ export const JSON_TREE_SCALAR_LEN_EXPANDED = 2000;
14
+
15
+ /**
16
+ * Format a scalar value for inline display.
17
+ */
18
+ export function formatScalar(value: unknown, maxLen: number): string {
19
+ if (value === null) return "null";
20
+ if (value === undefined) return "undefined";
21
+ if (typeof value === "boolean") return String(value);
22
+ if (typeof value === "number") return String(value);
23
+ if (typeof value === "string") {
24
+ const escaped = value.replace(/\n/g, "\\n").replace(/\t/g, "\\t");
25
+ const truncated = truncateToWidth(escaped, maxLen);
26
+ return `"${truncated}"`;
27
+ }
28
+ if (Array.isArray(value)) return `[${value.length} items]`;
29
+ if (typeof value === "object") {
30
+ const keys = Object.keys(value);
31
+ return `{${keys.length} keys}`;
32
+ }
33
+ return String(value);
34
+ }
35
+
36
+ /**
37
+ * Format args inline for collapsed view.
38
+ */
39
+ export function formatArgsInline(args: Record<string, unknown>, maxWidth: number): string {
40
+ const entries = Object.entries(args);
41
+ if (entries.length === 0) return "";
42
+
43
+ // Single arg: show key=value
44
+ if (entries.length === 1) {
45
+ const [key, value] = entries[0];
46
+ return `${key}=${formatScalar(value, maxWidth - key.length - 1)}`;
47
+ }
48
+
49
+ // Multiple args: show key=value, key=value...
50
+ const pairs: string[] = [];
51
+ let totalLen = 0;
52
+
53
+ for (const [key, value] of entries) {
54
+ const valueStr = formatScalar(value, 24);
55
+ const pairStr = `${key}=${valueStr}`;
56
+ const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length;
57
+
58
+ if (totalLen + addLen > maxWidth && pairs.length > 0) {
59
+ pairs.push("…");
60
+ break;
61
+ }
62
+
63
+ pairs.push(pairStr);
64
+ totalLen += addLen;
65
+ }
66
+
67
+ return pairs.join(", ");
68
+ }
69
+
70
+ /**
71
+ * Build tree prefix for nested rendering.
72
+ */
73
+ function buildTreePrefix(ancestors: boolean[], theme: Theme): string {
74
+ return ancestors.map(hasNext => (hasNext ? `${theme.tree.vertical} ` : " ")).join("");
75
+ }
76
+
77
+ /**
78
+ * Render a JSON value as tree lines.
79
+ */
80
+ export function renderJsonTreeLines(
81
+ value: unknown,
82
+ theme: Theme,
83
+ maxDepth: number,
84
+ maxLines: number,
85
+ maxScalarLen: number,
86
+ ): { lines: string[]; truncated: boolean } {
87
+ const lines: string[] = [];
88
+ let truncated = false;
89
+
90
+ const iconObject = theme.styledSymbol("icon.folder", "muted");
91
+ const iconArray = theme.styledSymbol("icon.package", "muted");
92
+ const iconScalar = theme.styledSymbol("icon.file", "muted");
93
+
94
+ const pushLine = (line: string): boolean => {
95
+ if (lines.length >= maxLines) {
96
+ truncated = true;
97
+ return false;
98
+ }
99
+ lines.push(line);
100
+ return true;
101
+ };
102
+
103
+ const renderNode = (val: unknown, key: string | undefined, ancestors: boolean[], isLast: boolean, depth: number) => {
104
+ if (lines.length >= maxLines) {
105
+ truncated = true;
106
+ return;
107
+ }
108
+
109
+ const connector = isLast ? theme.tree.last : theme.tree.branch;
110
+ const prefix = `${buildTreePrefix(ancestors, theme)}${theme.fg("dim", connector)} `;
111
+
112
+ // Handle scalars
113
+ if (val === null || val === undefined || typeof val !== "object") {
114
+ const label = key ? theme.fg("muted", key) : theme.fg("muted", "value");
115
+
116
+ // Special handling for multiline strings
117
+ if (typeof val === "string" && val.includes("\n")) {
118
+ const strLines = val.split("\n");
119
+ const maxStrLines = Math.min(strLines.length, Math.max(1, maxLines - lines.length - 1));
120
+ const continuePrefix = buildTreePrefix([...ancestors, !isLast], theme);
121
+
122
+ // First line with label
123
+ const firstLine = truncateToWidth(strLines[0], maxScalarLen);
124
+ pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", `"${firstLine}`)}`);
125
+
126
+ // Subsequent lines indented
127
+ for (let i = 1; i < maxStrLines; i++) {
128
+ if (lines.length >= maxLines) {
129
+ truncated = true;
130
+ break;
131
+ }
132
+ const line = truncateToWidth(strLines[i], maxScalarLen);
133
+ pushLine(`${continuePrefix} ${theme.fg("dim", ` ${line}`)}`);
134
+ }
135
+
136
+ // Show truncation and closing quote
137
+ if (strLines.length > maxStrLines) {
138
+ truncated = true;
139
+ pushLine(`${continuePrefix} ${theme.fg("dim", ` …(${strLines.length - maxStrLines} more lines)"`)}`);
140
+ } else {
141
+ // Add closing quote to last line - need to modify the last pushed line
142
+ const lastIdx = lines.length - 1;
143
+ lines[lastIdx] = `${lines[lastIdx]}${theme.fg("dim", '"')}`;
144
+ }
145
+ return;
146
+ }
147
+
148
+ const scalar = formatScalar(val, maxScalarLen);
149
+ pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", scalar)}`);
150
+ return;
151
+ }
152
+
153
+ // Handle arrays
154
+ if (Array.isArray(val)) {
155
+ const header = key ? theme.fg("muted", key) : theme.fg("muted", "array");
156
+ pushLine(`${prefix}${iconArray} ${header}`);
157
+ if (val.length === 0) {
158
+ pushLine(
159
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "[]")}`,
160
+ );
161
+ return;
162
+ }
163
+ if (depth >= maxDepth) {
164
+ pushLine(
165
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "…")}`,
166
+ );
167
+ return;
168
+ }
169
+ const nextAncestors = [...ancestors, !isLast];
170
+ for (let i = 0; i < val.length; i++) {
171
+ renderNode(val[i], `[${i}]`, nextAncestors, i === val.length - 1, depth + 1);
172
+ if (lines.length >= maxLines) {
173
+ truncated = true;
174
+ return;
175
+ }
176
+ }
177
+ return;
178
+ }
179
+
180
+ // Handle objects
181
+ const header = key ? theme.fg("muted", key) : theme.fg("muted", "object");
182
+ pushLine(`${prefix}${iconObject} ${header}`);
183
+ const entries = Object.entries(val as Record<string, unknown>);
184
+ if (entries.length === 0) {
185
+ pushLine(
186
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "{}")}`,
187
+ );
188
+ return;
189
+ }
190
+ if (depth >= maxDepth) {
191
+ pushLine(
192
+ `${buildTreePrefix([...ancestors, !isLast], theme)}${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", "…")}`,
193
+ );
194
+ return;
195
+ }
196
+ const nextAncestors = [...ancestors, !isLast];
197
+ for (let i = 0; i < entries.length; i++) {
198
+ const [childKey, child] = entries[i];
199
+ renderNode(child, childKey, nextAncestors, i === entries.length - 1, depth + 1);
200
+ if (lines.length >= maxLines) {
201
+ truncated = true;
202
+ return;
203
+ }
204
+ }
205
+ };
206
+
207
+ // Render root level
208
+ if (value && typeof value === "object" && !Array.isArray(value)) {
209
+ const entries = Object.entries(value as Record<string, unknown>);
210
+ for (let i = 0; i < entries.length; i++) {
211
+ const [childKey, child] = entries[i];
212
+ renderNode(child, childKey, [], i === entries.length - 1, 1);
213
+ if (lines.length >= maxLines) {
214
+ truncated = true;
215
+ break;
216
+ }
217
+ }
218
+ } else if (Array.isArray(value)) {
219
+ for (let i = 0; i < value.length; i++) {
220
+ renderNode(value[i], `[${i}]`, [], i === value.length - 1, 1);
221
+ if (lines.length >= maxLines) {
222
+ truncated = true;
223
+ break;
224
+ }
225
+ }
226
+ } else {
227
+ renderNode(value, undefined, [], true, 0);
228
+ }
229
+
230
+ return { lines, truncated };
231
+ }
@@ -65,6 +65,7 @@ export class NotebookTool implements AgentTool<typeof notebookSchema, NotebookTo
65
65
  public readonly description =
66
66
  "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file) with new source. Jupyter notebooks are interactive documents that combine code, text, and visualizations, commonly used for data analysis and scientific computing. The notebook_path parameter must be an absolute path, not a relative path. The cell_number is 0-indexed. Use edit_mode=insert to add a new cell at the index specified by cell_number. Use edit_mode=delete to delete the cell at the index specified by cell_number.";
67
67
  public readonly parameters = notebookSchema;
68
+ public readonly concurrency = "exclusive";
68
69
 
69
70
  private readonly session: ToolSession;
70
71