@oh-my-pi/pi-coding-agent 13.3.7 → 13.3.9

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 (49) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +9 -18
  3. package/scripts/format-prompts.ts +7 -172
  4. package/src/config/prompt-templates.ts +2 -54
  5. package/src/config/settings-schema.ts +24 -0
  6. package/src/discovery/codex.ts +1 -2
  7. package/src/discovery/helpers.ts +0 -5
  8. package/src/lsp/client.ts +8 -0
  9. package/src/lsp/config.ts +2 -3
  10. package/src/lsp/index.ts +379 -99
  11. package/src/lsp/render.ts +21 -31
  12. package/src/lsp/types.ts +21 -8
  13. package/src/lsp/utils.ts +193 -1
  14. package/src/mcp/config-writer.ts +3 -0
  15. package/src/modes/components/settings-defs.ts +9 -0
  16. package/src/modes/interactive-mode.ts +8 -1
  17. package/src/modes/theme/mermaid-cache.ts +4 -4
  18. package/src/modes/theme/theme.ts +33 -0
  19. package/src/prompts/system/subagent-user-prompt.md +2 -0
  20. package/src/prompts/system/system-prompt.md +12 -1
  21. package/src/prompts/tools/ast-find.md +20 -0
  22. package/src/prompts/tools/ast-replace.md +21 -0
  23. package/src/prompts/tools/bash.md +2 -0
  24. package/src/prompts/tools/hashline.md +26 -8
  25. package/src/prompts/tools/lsp.md +22 -5
  26. package/src/sdk.ts +11 -1
  27. package/src/session/agent-session.ts +261 -82
  28. package/src/task/executor.ts +8 -5
  29. package/src/tools/ast-find.ts +316 -0
  30. package/src/tools/ast-replace.ts +294 -0
  31. package/src/tools/bash.ts +2 -1
  32. package/src/tools/browser.ts +2 -8
  33. package/src/tools/fetch.ts +55 -18
  34. package/src/tools/index.ts +8 -0
  35. package/src/tools/path-utils.ts +34 -0
  36. package/src/tools/python.ts +2 -1
  37. package/src/tools/renderers.ts +4 -0
  38. package/src/tools/ssh.ts +2 -1
  39. package/src/tools/todo-write.ts +34 -0
  40. package/src/tools/tool-timeouts.ts +29 -0
  41. package/src/utils/mime.ts +37 -14
  42. package/src/utils/prompt-format.ts +172 -0
  43. package/src/web/scrapers/arxiv.ts +12 -12
  44. package/src/web/scrapers/go-pkg.ts +2 -2
  45. package/src/web/scrapers/iacr.ts +17 -9
  46. package/src/web/scrapers/readthedocs.ts +3 -3
  47. package/src/web/scrapers/twitter.ts +11 -11
  48. package/src/web/scrapers/wikipedia.ts +4 -5
  49. package/src/utils/ignore-files.ts +0 -119
@@ -0,0 +1,316 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { type AstFindResult, astFind } from "@oh-my-pi/pi-natives";
3
+ import type { Component } from "@oh-my-pi/pi-tui";
4
+ import { Text } from "@oh-my-pi/pi-tui";
5
+ import { untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { type Static, Type } from "@sinclair/typebox";
7
+ import { renderPromptTemplate } from "../config/prompt-templates";
8
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
+ import type { Theme } from "../modes/theme/theme";
10
+ import astFindDescription from "../prompts/tools/ast-find.md" with { type: "text" };
11
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
+ import type { ToolSession } from ".";
13
+ import type { OutputMeta } from "./output-meta";
14
+ import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
15
+ import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
16
+ import { ToolError } from "./tool-errors";
17
+ import { toolResult } from "./tool-result";
18
+
19
+ const astFindSchema = Type.Object({
20
+ pattern: Type.String({ description: "AST pattern, e.g. 'foo($A)'" }),
21
+ lang: Type.Optional(Type.String({ description: "Language override" })),
22
+ path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to search (default: cwd)" })),
23
+ selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
24
+ limit: Type.Optional(Type.Number({ description: "Max matches (default: 50)" })),
25
+ offset: Type.Optional(Type.Number({ description: "Skip first N matches (default: 0)" })),
26
+ context: Type.Optional(Type.Number({ description: "Context lines around each match" })),
27
+ include_meta: Type.Optional(Type.Boolean({ description: "Include metavariable captures" })),
28
+ });
29
+
30
+ export interface AstFindToolDetails {
31
+ matchCount: number;
32
+ fileCount: number;
33
+ filesSearched: number;
34
+ limitReached: boolean;
35
+ parseErrors?: string[];
36
+ meta?: OutputMeta;
37
+ }
38
+
39
+ export class AstFindTool implements AgentTool<typeof astFindSchema, AstFindToolDetails> {
40
+ readonly name = "ast_find";
41
+ readonly label = "AST Find";
42
+ readonly description: string;
43
+ readonly parameters = astFindSchema;
44
+ readonly strict = true;
45
+
46
+ constructor(private readonly session: ToolSession) {
47
+ this.description = renderPromptTemplate(astFindDescription);
48
+ }
49
+
50
+ async execute(
51
+ _toolCallId: string,
52
+ params: Static<typeof astFindSchema>,
53
+ signal?: AbortSignal,
54
+ _onUpdate?: AgentToolUpdateCallback<AstFindToolDetails>,
55
+ _context?: AgentToolContext,
56
+ ): Promise<AgentToolResult<AstFindToolDetails>> {
57
+ return untilAborted(signal, async () => {
58
+ const pattern = params.pattern?.trim();
59
+ if (!pattern) {
60
+ throw new ToolError("`pattern` is required");
61
+ }
62
+ const limit = params.limit === undefined ? 50 : Math.floor(params.limit);
63
+ if (!Number.isFinite(limit) || limit < 1) {
64
+ throw new ToolError("Limit must be a positive number");
65
+ }
66
+ const offset = params.offset === undefined ? 0 : Math.floor(params.offset);
67
+ if (!Number.isFinite(offset) || offset < 0) {
68
+ throw new ToolError("Offset must be a non-negative number");
69
+ }
70
+ const context = params.context === undefined ? undefined : Math.floor(params.context);
71
+ if (context !== undefined && (!Number.isFinite(context) || context < 0)) {
72
+ throw new ToolError("Context must be a non-negative number");
73
+ }
74
+
75
+ let searchPath: string | undefined;
76
+ let globFilter: string | undefined;
77
+ const rawPath = params.path?.trim();
78
+ if (rawPath) {
79
+ const internalRouter = this.session.internalRouter;
80
+ if (internalRouter?.canHandle(rawPath)) {
81
+ if (hasGlobPathChars(rawPath)) {
82
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
83
+ }
84
+ const resource = await internalRouter.resolve(rawPath);
85
+ if (!resource.sourcePath) {
86
+ throw new ToolError(`Cannot search internal URL without backing file: ${rawPath}`);
87
+ }
88
+ searchPath = resource.sourcePath;
89
+ } else {
90
+ const parsedPath = parseSearchPath(rawPath);
91
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
92
+ globFilter = parsedPath.glob;
93
+ }
94
+ }
95
+
96
+ const result = await astFind({
97
+ pattern,
98
+ lang: params.lang?.trim(),
99
+ path: searchPath,
100
+ glob: globFilter,
101
+ selector: params.selector?.trim(),
102
+ limit,
103
+ offset,
104
+ context,
105
+ includeMeta: params.include_meta,
106
+ signal,
107
+ });
108
+
109
+ const details: AstFindToolDetails = {
110
+ matchCount: result.totalMatches,
111
+ fileCount: result.filesWithMatches,
112
+ filesSearched: result.filesSearched,
113
+ limitReached: result.limitReached,
114
+ parseErrors: result.parseErrors,
115
+ };
116
+
117
+ if (result.matches.length === 0) {
118
+ const parseMessage = result.parseErrors?.length
119
+ ? `\nParse issues:\n${result.parseErrors.map(err => `- ${err}`).join("\n")}`
120
+ : "";
121
+ return toolResult(details).text(`No matches found${parseMessage}`).done();
122
+ }
123
+
124
+ const lines: string[] = [
125
+ `${result.totalMatches} matches in ${result.filesWithMatches} files (searched ${result.filesSearched})`,
126
+ ];
127
+ const grouped = new Map<string, AstFindResult["matches"]>();
128
+ for (const match of result.matches) {
129
+ const entry = grouped.get(match.path);
130
+ if (entry) {
131
+ entry.push(match);
132
+ } else {
133
+ grouped.set(match.path, [match]);
134
+ }
135
+ }
136
+ for (const [filePath, matches] of grouped) {
137
+ lines.push("", `# ${filePath}`);
138
+ for (const match of matches) {
139
+ const firstLine = match.text.split("\n", 1)[0] ?? "";
140
+ const preview = firstLine.length > 140 ? `${firstLine.slice(0, 137)}...` : firstLine;
141
+ lines.push(`${match.startLine}:${match.startColumn}-${match.endLine}:${match.endColumn}: ${preview}`);
142
+ if (match.metaVariables && Object.keys(match.metaVariables).length > 0) {
143
+ const serializedMeta = Object.entries(match.metaVariables)
144
+ .sort(([left], [right]) => left.localeCompare(right))
145
+ .map(([key, value]) => `${key}=${value}`)
146
+ .join(", ");
147
+ lines.push(` meta: ${serializedMeta}`);
148
+ }
149
+ }
150
+ }
151
+ if (result.limitReached) {
152
+ lines.push("", "Result limit reached; narrow path pattern or increase limit.");
153
+ }
154
+ if (result.parseErrors?.length) {
155
+ lines.push("", "Parse issues:", ...result.parseErrors.map(err => `- ${err}`));
156
+ }
157
+
158
+ const output = lines.join("\n");
159
+ return toolResult(details).text(output).done();
160
+ });
161
+ }
162
+ }
163
+
164
+ // =============================================================================
165
+ // TUI Renderer
166
+ // =============================================================================
167
+
168
+ interface AstFindRenderArgs {
169
+ pattern?: string;
170
+ lang?: string;
171
+ path?: string;
172
+ selector?: string;
173
+ limit?: number;
174
+ offset?: number;
175
+ context?: number;
176
+ include_meta?: boolean;
177
+ }
178
+
179
+ const COLLAPSED_MATCH_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
180
+
181
+ export const astFindToolRenderer = {
182
+ inline: true,
183
+ renderCall(args: AstFindRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
184
+ const meta: string[] = [];
185
+ if (args.lang) meta.push(`lang:${args.lang}`);
186
+ if (args.path) meta.push(`in ${args.path}`);
187
+ if (args.selector) meta.push("selector");
188
+ if (args.limit !== undefined && args.limit > 0) meta.push(`limit:${args.limit}`);
189
+ if (args.offset !== undefined && args.offset > 0) meta.push(`offset:${args.offset}`);
190
+ if (args.context !== undefined) meta.push(`context:${args.context}`);
191
+ if (args.include_meta) meta.push("meta");
192
+
193
+ const description = args.pattern || "?";
194
+ const text = renderStatusLine({ icon: "pending", title: "AST Find", description, meta }, uiTheme);
195
+ return new Text(text, 0, 0);
196
+ },
197
+
198
+ renderResult(
199
+ result: { content: Array<{ type: string; text?: string }>; details?: AstFindToolDetails; isError?: boolean },
200
+ options: RenderResultOptions,
201
+ uiTheme: Theme,
202
+ args?: AstFindRenderArgs,
203
+ ): Component {
204
+ const details = result.details;
205
+
206
+ if (result.isError) {
207
+ const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
208
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
209
+ }
210
+
211
+ const matchCount = details?.matchCount ?? 0;
212
+ const fileCount = details?.fileCount ?? 0;
213
+ const filesSearched = details?.filesSearched ?? 0;
214
+ const limitReached = details?.limitReached ?? false;
215
+
216
+ if (matchCount === 0) {
217
+ const description = args?.pattern;
218
+ const meta = ["0 matches"];
219
+ if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
220
+ const header = renderStatusLine({ icon: "warning", title: "AST Find", description, meta }, uiTheme);
221
+ const lines = [header, formatEmptyMessage("No matches found", uiTheme)];
222
+ if (details?.parseErrors?.length) {
223
+ for (const err of details.parseErrors) {
224
+ lines.push(uiTheme.fg("warning", ` - ${err}`));
225
+ }
226
+ }
227
+ return new Text(lines.join("\n"), 0, 0);
228
+ }
229
+
230
+ const summaryParts = [formatCount("match", matchCount), formatCount("file", fileCount)];
231
+ const meta = [...summaryParts, `searched ${filesSearched}`];
232
+ if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
233
+ const description = args?.pattern;
234
+ const header = renderStatusLine(
235
+ { icon: limitReached ? "warning" : "success", title: "AST Find", description, meta },
236
+ uiTheme,
237
+ );
238
+
239
+ // Parse text content into match groups (grouped by file, separated by blank lines)
240
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
241
+ const rawLines = textContent.split("\n");
242
+ // Skip the summary line and group by blank-line separators
243
+ const contentLines = rawLines.slice(1);
244
+ const allGroups: string[][] = [];
245
+ let current: string[] = [];
246
+ for (const line of contentLines) {
247
+ if (line.trim().length === 0) {
248
+ if (current.length > 0) {
249
+ allGroups.push(current);
250
+ current = [];
251
+ }
252
+ continue;
253
+ }
254
+ current.push(line);
255
+ }
256
+ if (current.length > 0) allGroups.push(current);
257
+
258
+ // Keep only file match groups (starting with "# ")
259
+ const matchGroups = allGroups.filter(group => group[0]?.startsWith("# "));
260
+
261
+ const getCollapsedMatchLimit = (groups: string[][], maxLines: number): number => {
262
+ if (groups.length === 0) return 0;
263
+ let usedLines = 0;
264
+ let count = 0;
265
+ for (const group of groups) {
266
+ if (count > 0 && usedLines + group.length > maxLines) break;
267
+ usedLines += group.length;
268
+ count += 1;
269
+ if (usedLines >= maxLines) break;
270
+ }
271
+ return count;
272
+ };
273
+
274
+ const extraLines: string[] = [];
275
+ if (limitReached) {
276
+ extraLines.push(uiTheme.fg("warning", "limit reached; narrow path pattern or increase limit"));
277
+ }
278
+ if (details?.parseErrors?.length) {
279
+ extraLines.push(uiTheme.fg("warning", `${details.parseErrors.length} parse issue(s)`));
280
+ }
281
+
282
+ let cached: RenderCache | undefined;
283
+ return {
284
+ render(width: number): string[] {
285
+ const { expanded } = options;
286
+ const key = new Hasher().bool(expanded).u32(width).digest();
287
+ if (cached?.key === key) return cached.lines;
288
+ const maxCollapsed = expanded
289
+ ? matchGroups.length
290
+ : getCollapsedMatchLimit(matchGroups, COLLAPSED_MATCH_LIMIT);
291
+ const matchLines = renderTreeList(
292
+ {
293
+ items: matchGroups,
294
+ expanded,
295
+ maxCollapsed,
296
+ itemType: "match",
297
+ renderItem: group =>
298
+ group.map(line => {
299
+ if (line.startsWith("# ")) return uiTheme.fg("accent", line);
300
+ if (line.startsWith(" meta:")) return uiTheme.fg("dim", line);
301
+ return uiTheme.fg("toolOutput", line);
302
+ }),
303
+ },
304
+ uiTheme,
305
+ );
306
+ const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
307
+ cached = { key, lines: result };
308
+ return result;
309
+ },
310
+ invalidate() {
311
+ cached = undefined;
312
+ },
313
+ };
314
+ },
315
+ mergeCallAndResult: true,
316
+ };
@@ -0,0 +1,294 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { astReplace } from "@oh-my-pi/pi-natives";
3
+ import type { Component } from "@oh-my-pi/pi-tui";
4
+ import { Text } from "@oh-my-pi/pi-tui";
5
+ import { untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { type Static, Type } from "@sinclair/typebox";
7
+ import { renderPromptTemplate } from "../config/prompt-templates";
8
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
+ import type { Theme } from "../modes/theme/theme";
10
+ import astReplaceDescription from "../prompts/tools/ast-replace.md" with { type: "text" };
11
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
+ import type { ToolSession } from ".";
13
+ import type { OutputMeta } from "./output-meta";
14
+ import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
15
+ import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
16
+ import { ToolError } from "./tool-errors";
17
+ import { toolResult } from "./tool-result";
18
+
19
+ const astReplaceSchema = Type.Object({
20
+ pattern: Type.String({ description: "AST pattern to match" }),
21
+ rewrite: Type.String({ description: "Rewrite template" }),
22
+ lang: Type.Optional(Type.String({ description: "Language override" })),
23
+ path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to rewrite (default: cwd)" })),
24
+ selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
25
+ dry_run: Type.Optional(Type.Boolean({ description: "Preview only (default: true)" })),
26
+ max_replacements: Type.Optional(Type.Number({ description: "Safety cap on total replacements" })),
27
+ max_files: Type.Optional(Type.Number({ description: "Safety cap on touched files" })),
28
+ });
29
+
30
+ export interface AstReplaceToolDetails {
31
+ totalReplacements: number;
32
+ filesTouched: number;
33
+ filesSearched: number;
34
+ applied: boolean;
35
+ limitReached: boolean;
36
+ parseErrors?: string[];
37
+ meta?: OutputMeta;
38
+ }
39
+
40
+ export class AstReplaceTool implements AgentTool<typeof astReplaceSchema, AstReplaceToolDetails> {
41
+ readonly name = "ast_replace";
42
+ readonly label = "AST Replace";
43
+ readonly description: string;
44
+ readonly parameters = astReplaceSchema;
45
+ readonly strict = true;
46
+
47
+ constructor(private readonly session: ToolSession) {
48
+ this.description = renderPromptTemplate(astReplaceDescription);
49
+ }
50
+
51
+ async execute(
52
+ _toolCallId: string,
53
+ params: Static<typeof astReplaceSchema>,
54
+ signal?: AbortSignal,
55
+ _onUpdate?: AgentToolUpdateCallback<AstReplaceToolDetails>,
56
+ _context?: AgentToolContext,
57
+ ): Promise<AgentToolResult<AstReplaceToolDetails>> {
58
+ return untilAborted(signal, async () => {
59
+ const pattern = params.pattern?.trim();
60
+ if (!pattern) {
61
+ throw new ToolError("`pattern` is required");
62
+ }
63
+ if (!params.rewrite?.trim()) {
64
+ throw new ToolError("`rewrite` is required");
65
+ }
66
+
67
+ const maxReplacements =
68
+ params.max_replacements === undefined ? undefined : Math.floor(params.max_replacements);
69
+ if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
70
+ throw new ToolError("max_replacements must be a positive number");
71
+ }
72
+ const maxFiles = params.max_files === undefined ? undefined : Math.floor(params.max_files);
73
+ if (maxFiles !== undefined && (!Number.isFinite(maxFiles) || maxFiles < 1)) {
74
+ throw new ToolError("max_files must be a positive number");
75
+ }
76
+
77
+ let searchPath: string | undefined;
78
+ let globFilter: string | undefined;
79
+ const rawPath = params.path?.trim();
80
+ if (rawPath) {
81
+ const internalRouter = this.session.internalRouter;
82
+ if (internalRouter?.canHandle(rawPath)) {
83
+ if (hasGlobPathChars(rawPath)) {
84
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
85
+ }
86
+ const resource = await internalRouter.resolve(rawPath);
87
+ if (!resource.sourcePath) {
88
+ throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
89
+ }
90
+ searchPath = resource.sourcePath;
91
+ } else {
92
+ const parsedPath = parseSearchPath(rawPath);
93
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
94
+ globFilter = parsedPath.glob;
95
+ }
96
+ }
97
+
98
+ const result = await astReplace({
99
+ pattern,
100
+ rewrite: params.rewrite?.trim(),
101
+ lang: params.lang?.trim(),
102
+ path: searchPath,
103
+ glob: globFilter,
104
+ selector: params.selector?.trim(),
105
+ dryRun: params.dry_run,
106
+ maxReplacements,
107
+ maxFiles,
108
+ failOnParseError: false,
109
+ signal,
110
+ });
111
+
112
+ const details: AstReplaceToolDetails = {
113
+ totalReplacements: result.totalReplacements,
114
+ filesTouched: result.filesTouched,
115
+ filesSearched: result.filesSearched,
116
+ applied: result.applied,
117
+ limitReached: result.limitReached,
118
+ parseErrors: result.parseErrors,
119
+ };
120
+
121
+ const action = result.applied ? "Applied" : "Would apply";
122
+ const lines = [
123
+ `${action} ${result.totalReplacements} replacements across ${result.filesTouched} files (searched ${result.filesSearched})`,
124
+ ];
125
+ if (result.fileChanges.length > 0) {
126
+ lines.push("", "File changes:");
127
+ for (const file of result.fileChanges) {
128
+ lines.push(`- ${file.path}: ${file.count}`);
129
+ }
130
+ }
131
+ if (result.changes.length > 0) {
132
+ lines.push("", "Preview:");
133
+ for (const change of result.changes.slice(0, 30)) {
134
+ const before = (change.before.split("\n", 1)[0] ?? "").slice(0, 80);
135
+ const after = (change.after.split("\n", 1)[0] ?? "").slice(0, 80);
136
+ lines.push(`${change.path}:${change.startLine}:${change.startColumn} ${before} -> ${after}`);
137
+ }
138
+ if (result.changes.length > 30) {
139
+ lines.push(`... ${result.changes.length - 30} more changes`);
140
+ }
141
+ }
142
+ if (result.limitReached) {
143
+ lines.push("", "Safety cap reached; narrow path pattern or increase max_files/max_replacements.");
144
+ }
145
+ if (result.parseErrors?.length) {
146
+ lines.push("", "Parse issues:", ...result.parseErrors.map(err => `- ${err}`));
147
+ }
148
+
149
+ return toolResult(details).text(lines.join("\n")).done();
150
+ });
151
+ }
152
+ }
153
+
154
+ // =============================================================================
155
+ // TUI Renderer
156
+ // =============================================================================
157
+
158
+ interface AstReplaceRenderArgs {
159
+ pattern?: string;
160
+ rewrite?: string;
161
+ lang?: string;
162
+ path?: string;
163
+ selector?: string;
164
+ dry_run?: boolean;
165
+ max_replacements?: number;
166
+ max_files?: number;
167
+ fail_on_parse_error?: boolean;
168
+ }
169
+
170
+ const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
171
+
172
+ export const astReplaceToolRenderer = {
173
+ inline: true,
174
+ renderCall(args: AstReplaceRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
175
+ const meta: string[] = [];
176
+ if (args.lang) meta.push(`lang:${args.lang}`);
177
+ if (args.path) meta.push(`in ${args.path}`);
178
+ if (args.dry_run !== false) meta.push("dry run");
179
+ if (args.max_replacements !== undefined) meta.push(`max:${args.max_replacements}`);
180
+ if (args.max_files !== undefined) meta.push(`max files:${args.max_files}`);
181
+
182
+ const description = args.pattern || "?";
183
+ const text = renderStatusLine({ icon: "pending", title: "AST Replace", description, meta }, uiTheme);
184
+ return new Text(text, 0, 0);
185
+ },
186
+
187
+ renderResult(
188
+ result: { content: Array<{ type: string; text?: string }>; details?: AstReplaceToolDetails; isError?: boolean },
189
+ options: RenderResultOptions,
190
+ uiTheme: Theme,
191
+ args?: AstReplaceRenderArgs,
192
+ ): Component {
193
+ const details = result.details;
194
+
195
+ if (result.isError) {
196
+ const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
197
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
198
+ }
199
+
200
+ const totalReplacements = details?.totalReplacements ?? 0;
201
+ const filesTouched = details?.filesTouched ?? 0;
202
+ const filesSearched = details?.filesSearched ?? 0;
203
+ const applied = details?.applied ?? false;
204
+ const limitReached = details?.limitReached ?? false;
205
+
206
+ if (totalReplacements === 0) {
207
+ const description = args?.pattern;
208
+ const meta = ["0 replacements"];
209
+ if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
210
+ const header = renderStatusLine({ icon: "warning", title: "AST Replace", description, meta }, uiTheme);
211
+ return new Text([header, formatEmptyMessage("No replacements made", uiTheme)].join("\n"), 0, 0);
212
+ }
213
+
214
+ const summaryParts = [
215
+ formatCount("replacement", totalReplacements),
216
+ formatCount("file", filesTouched),
217
+ `searched ${filesSearched}`,
218
+ ];
219
+ const meta = [...summaryParts];
220
+ if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
221
+ const description = args?.pattern;
222
+ const badge = applied
223
+ ? { label: "applied", color: "success" as const }
224
+ : { label: "dry run", color: "warning" as const };
225
+ const header = renderStatusLine(
226
+ { icon: limitReached ? "warning" : "success", title: "AST Replace", description, badge, meta },
227
+ uiTheme,
228
+ );
229
+
230
+ // Parse text content into display groups
231
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
232
+ const rawLines = textContent.split("\n");
233
+ // Skip the summary line and group by blank-line separators
234
+ const contentLines = rawLines.slice(1);
235
+ const allGroups: string[][] = [];
236
+ let current: string[] = [];
237
+ for (const line of contentLines) {
238
+ if (line.trim().length === 0) {
239
+ if (current.length > 0) {
240
+ allGroups.push(current);
241
+ current = [];
242
+ }
243
+ continue;
244
+ }
245
+ current.push(line);
246
+ }
247
+ if (current.length > 0) allGroups.push(current);
248
+
249
+ // Filter out trailing metadata groups (safety cap / parse issues) — shown via details
250
+ const displayGroups = allGroups.filter(
251
+ group => !group[0]?.startsWith("Safety cap") && !group[0]?.startsWith("Parse issues"),
252
+ );
253
+
254
+ const extraLines: string[] = [];
255
+ if (limitReached) {
256
+ extraLines.push(uiTheme.fg("warning", "safety cap reached; narrow scope or increase limits"));
257
+ }
258
+ if (details?.parseErrors?.length) {
259
+ extraLines.push(uiTheme.fg("warning", `${details.parseErrors.length} parse issue(s)`));
260
+ }
261
+
262
+ let cached: RenderCache | undefined;
263
+ return {
264
+ render(width: number): string[] {
265
+ const { expanded } = options;
266
+ const key = new Hasher().bool(expanded).u32(width).digest();
267
+ if (cached?.key === key) return cached.lines;
268
+ const matchLines = renderTreeList(
269
+ {
270
+ items: displayGroups,
271
+ expanded,
272
+ maxCollapsed: expanded ? displayGroups.length : COLLAPSED_CHANGE_LIMIT,
273
+ itemType: "section",
274
+ renderItem: group =>
275
+ group.map(line => {
276
+ if (line === "File changes:" || line === "Preview:") return uiTheme.fg("accent", line);
277
+ if (line.startsWith("- ")) return uiTheme.fg("toolOutput", line);
278
+ if (line.startsWith("...")) return uiTheme.fg("dim", line);
279
+ return uiTheme.fg("toolOutput", line);
280
+ }),
281
+ },
282
+ uiTheme,
283
+ );
284
+ const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
285
+ cached = { key, lines: result };
286
+ return result;
287
+ },
288
+ invalidate() {
289
+ cached = undefined;
290
+ },
291
+ };
292
+ },
293
+ mergeCallAndResult: true,
294
+ };
package/src/tools/bash.ts CHANGED
@@ -25,6 +25,7 @@ import { resolveToCwd } from "./path-utils";
25
25
  import { replaceTabs } from "./render-utils";
26
26
  import { ToolAbortError, ToolError } from "./tool-errors";
27
27
  import { toolResult } from "./tool-result";
28
+ import { clampTimeout } from "./tool-timeouts";
28
29
 
29
30
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
30
31
 
@@ -200,7 +201,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
200
201
  }
201
202
 
202
203
  // Clamp to reasonable range: 1s - 3600s (1 hour)
203
- const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
204
+ const timeoutSec = clampTimeout("bash", rawTimeout);
204
205
  const timeoutMs = timeoutSec * 1000;
205
206
 
206
207
  if (asyncRequested) {
@@ -37,6 +37,7 @@ import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type:
37
37
  import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" };
38
38
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
39
39
  import { toolResult } from "./tool-result";
40
+ import { clampTimeout } from "./tool-timeouts";
40
41
 
41
42
  /**
42
43
  * Lazy-import puppeteer from a safe CWD so cosmiconfig doesn't choke
@@ -57,8 +58,6 @@ async function loadPuppeteer(): Promise<typeof Puppeteer> {
57
58
  }
58
59
  }
59
60
 
60
- const DEFAULT_TIMEOUT_SECONDS = 30;
61
- const MAX_TIMEOUT_SECONDS = 120;
62
61
  const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 };
63
62
  const STEALTH_IGNORE_DEFAULT_ARGS = [
64
63
  "--disable-extensions",
@@ -452,11 +451,6 @@ export interface ReadableResult {
452
451
  markdown?: string;
453
452
  }
454
453
 
455
- function clampTimeout(timeoutSeconds?: number): number {
456
- if (timeoutSeconds === undefined) return DEFAULT_TIMEOUT_SECONDS;
457
- return Math.min(Math.max(timeoutSeconds, 1), MAX_TIMEOUT_SECONDS);
458
- }
459
-
460
454
  function ensureParam<T>(value: T | undefined, name: string, action: string): T {
461
455
  if (value === undefined || value === null || value === "") {
462
456
  throw new ToolError(`Missing required parameter '${name}' for action '${action}'.`);
@@ -956,7 +950,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
956
950
  ): Promise<AgentToolResult<BrowserToolDetails>> {
957
951
  try {
958
952
  throwIfAborted(signal);
959
- const timeoutSeconds = clampTimeout(params.timeout);
953
+ const timeoutSeconds = clampTimeout("browser", params.timeout);
960
954
  const timeoutMs = timeoutSeconds * 1000;
961
955
  const details: BrowserToolDetails = { action: params.action };
962
956