@oh-my-pi/pi-coding-agent 3.20.1 → 3.24.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 (123) hide show
  1. package/CHANGELOG.md +107 -8
  2. package/docs/custom-tools.md +3 -3
  3. package/docs/extensions.md +226 -220
  4. package/docs/hooks.md +2 -2
  5. package/docs/sdk.md +50 -53
  6. package/examples/custom-tools/README.md +2 -17
  7. package/examples/extensions/README.md +76 -74
  8. package/examples/extensions/todo.ts +2 -5
  9. package/examples/hooks/custom-compaction.ts +2 -4
  10. package/examples/hooks/handoff.ts +1 -1
  11. package/examples/hooks/qna.ts +1 -1
  12. package/examples/sdk/02-custom-model.ts +1 -1
  13. package/examples/sdk/README.md +7 -11
  14. package/package.json +6 -6
  15. package/src/cli/args.ts +9 -6
  16. package/src/cli/file-processor.ts +1 -1
  17. package/src/cli/list-models.ts +1 -1
  18. package/src/core/agent-session.ts +16 -5
  19. package/src/core/auth-storage.ts +1 -1
  20. package/src/core/compaction/branch-summarization.ts +2 -2
  21. package/src/core/compaction/compaction.ts +2 -2
  22. package/src/core/compaction/utils.ts +1 -1
  23. package/src/core/custom-tools/types.ts +1 -1
  24. package/src/core/custom-tools/wrapper.ts +0 -1
  25. package/src/core/extensions/index.ts +1 -6
  26. package/src/core/extensions/runner.ts +1 -1
  27. package/src/core/extensions/types.ts +1 -1
  28. package/src/core/extensions/wrapper.ts +1 -8
  29. package/src/core/file-mentions.ts +5 -8
  30. package/src/core/hooks/runner.ts +2 -2
  31. package/src/core/hooks/types.ts +1 -1
  32. package/src/core/messages.ts +1 -1
  33. package/src/core/model-registry.ts +1 -1
  34. package/src/core/model-resolver.ts +1 -1
  35. package/src/core/sdk.ts +64 -105
  36. package/src/core/session-manager.ts +18 -22
  37. package/src/core/settings-manager.ts +66 -1
  38. package/src/core/slash-commands.ts +12 -5
  39. package/src/core/system-prompt.ts +49 -36
  40. package/src/core/title-generator.ts +2 -2
  41. package/src/core/tools/ask.ts +98 -4
  42. package/src/core/tools/bash-interceptor.ts +11 -4
  43. package/src/core/tools/bash.ts +121 -5
  44. package/src/core/tools/context.ts +7 -0
  45. package/src/core/tools/edit-diff.ts +73 -24
  46. package/src/core/tools/edit.ts +221 -34
  47. package/src/core/tools/exa/render.ts +4 -16
  48. package/src/core/tools/find.ts +149 -5
  49. package/src/core/tools/gemini-image.ts +279 -56
  50. package/src/core/tools/git.ts +17 -3
  51. package/src/core/tools/grep.ts +185 -5
  52. package/src/core/tools/index.test.ts +180 -0
  53. package/src/core/tools/index.ts +96 -242
  54. package/src/core/tools/ls.ts +133 -5
  55. package/src/core/tools/lsp/index.ts +32 -29
  56. package/src/core/tools/lsp/render.ts +21 -22
  57. package/src/core/tools/notebook.ts +112 -4
  58. package/src/core/tools/output.ts +175 -15
  59. package/src/core/tools/read.ts +127 -25
  60. package/src/core/tools/render-utils.ts +241 -0
  61. package/src/core/tools/renderers.ts +40 -828
  62. package/src/core/tools/review.ts +26 -25
  63. package/src/core/tools/rulebook.ts +11 -3
  64. package/src/core/tools/task/agents.ts +28 -7
  65. package/src/core/tools/task/discovery.ts +0 -6
  66. package/src/core/tools/task/executor.ts +264 -254
  67. package/src/core/tools/task/index.ts +48 -208
  68. package/src/core/tools/task/render.ts +26 -11
  69. package/src/core/tools/task/types.ts +7 -12
  70. package/src/core/tools/task/worker-protocol.ts +17 -0
  71. package/src/core/tools/task/worker.ts +238 -0
  72. package/src/core/tools/truncate.ts +27 -1
  73. package/src/core/tools/web-fetch.ts +25 -49
  74. package/src/core/tools/web-search/index.ts +132 -46
  75. package/src/core/tools/web-search/providers/anthropic.ts +7 -2
  76. package/src/core/tools/web-search/providers/exa.ts +2 -1
  77. package/src/core/tools/web-search/providers/perplexity.ts +6 -1
  78. package/src/core/tools/web-search/render.ts +6 -4
  79. package/src/core/tools/web-search/types.ts +13 -0
  80. package/src/core/tools/write.ts +96 -14
  81. package/src/core/voice.ts +1 -1
  82. package/src/discovery/helpers.test.ts +1 -1
  83. package/src/index.ts +5 -16
  84. package/src/main.ts +5 -5
  85. package/src/modes/interactive/components/assistant-message.ts +1 -1
  86. package/src/modes/interactive/components/custom-message.ts +1 -1
  87. package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
  88. package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
  89. package/src/modes/interactive/components/footer.ts +1 -1
  90. package/src/modes/interactive/components/hook-message.ts +1 -1
  91. package/src/modes/interactive/components/model-selector.ts +1 -1
  92. package/src/modes/interactive/components/oauth-selector.ts +1 -1
  93. package/src/modes/interactive/components/settings-defs.ts +49 -0
  94. package/src/modes/interactive/components/status-line.ts +1 -1
  95. package/src/modes/interactive/components/tool-execution.ts +93 -538
  96. package/src/modes/interactive/interactive-mode.ts +19 -7
  97. package/src/modes/interactive/theme/theme.ts +4 -4
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +1 -1
  100. package/src/modes/rpc/rpc-types.ts +1 -1
  101. package/src/prompts/system-prompt.md +4 -0
  102. package/src/prompts/task.md +0 -7
  103. package/src/prompts/tools/gemini-image.md +5 -1
  104. package/src/prompts/tools/output.md +6 -2
  105. package/src/prompts/tools/task.md +68 -0
  106. package/src/prompts/tools/web-fetch.md +1 -0
  107. package/src/prompts/tools/web-search.md +2 -0
  108. package/src/utils/image-convert.ts +8 -2
  109. package/src/utils/image-magick.ts +247 -0
  110. package/src/utils/image-resize.ts +53 -13
  111. package/examples/custom-tools/question/index.ts +0 -84
  112. package/examples/custom-tools/subagent/README.md +0 -172
  113. package/examples/custom-tools/subagent/agents/planner.md +0 -37
  114. package/examples/custom-tools/subagent/agents/scout.md +0 -50
  115. package/examples/custom-tools/subagent/agents/worker.md +0 -24
  116. package/examples/custom-tools/subagent/agents.ts +0 -156
  117. package/examples/custom-tools/subagent/commands/implement-and-review.md +0 -10
  118. package/examples/custom-tools/subagent/commands/implement.md +0 -10
  119. package/examples/custom-tools/subagent/commands/scout-and-plan.md +0 -9
  120. package/examples/custom-tools/subagent/index.ts +0 -1002
  121. package/examples/sdk/05-tools.ts +0 -94
  122. package/examples/sdk/12-full-control.ts +0 -95
  123. package/src/prompts/browser.md +0 -71
@@ -1,7 +1,14 @@
1
- import type { AgentTool } from "@oh-my-pi/pi-agent-core";
1
+ import type { AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Text } from "@oh-my-pi/pi-tui";
2
4
  import { Type } from "@sinclair/typebox";
5
+ import type { Theme } from "../../modes/interactive/theme/theme";
3
6
  import bashDescription from "../../prompts/tools/bash.md" with { type: "text" };
4
7
  import { executeBash } from "../bash-executor";
8
+ import type { RenderResultOptions } from "../custom-tools/types";
9
+ import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor";
10
+ import type { ToolSession } from "./index";
11
+ import { formatBytes, wrapBrackets } from "./render-utils";
5
12
  import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateTail } from "./truncate";
6
13
 
7
14
  const bashSchema = Type.Object({
@@ -14,7 +21,7 @@ export interface BashToolDetails {
14
21
  fullOutputPath?: string;
15
22
  }
16
23
 
17
- export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
24
+ export function createBashTool(session: ToolSession): AgentTool<typeof bashSchema> {
18
25
  return {
19
26
  name: "bash",
20
27
  label: "Bash",
@@ -25,12 +32,25 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
25
32
  { command, timeout }: { command: string; timeout?: number },
26
33
  signal?: AbortSignal,
27
34
  onUpdate?,
35
+ ctx?: AgentToolContext,
28
36
  ) => {
37
+ // Check interception if enabled and available tools are known
38
+ if (session.settings?.getBashInterceptorEnabled()) {
39
+ const interception = checkBashInterception(command, ctx?.toolNames ?? []);
40
+ if (interception.block) {
41
+ throw new Error(interception.message);
42
+ }
43
+ const lsInterception = checkSimpleLsInterception(command, ctx?.toolNames ?? []);
44
+ if (lsInterception.block) {
45
+ throw new Error(lsInterception.message);
46
+ }
47
+ }
48
+
29
49
  // Track output for streaming updates
30
50
  let currentOutput = "";
31
51
 
32
52
  const result = await executeBash(command, {
33
- cwd,
53
+ cwd: session.cwd,
34
54
  timeout: timeout ? timeout * 1000 : undefined, // Convert to milliseconds
35
55
  signal,
36
56
  onChunk: (chunk) => {
@@ -87,5 +107,101 @@ export function createBashTool(cwd: string): AgentTool<typeof bashSchema> {
87
107
  };
88
108
  }
89
109
 
90
- /** Default bash tool using process.cwd() - for backwards compatibility */
91
- export const bashTool = createBashTool(process.cwd());
110
+ // =============================================================================
111
+ // TUI Renderer
112
+ // =============================================================================
113
+
114
+ interface BashRenderArgs {
115
+ command?: string;
116
+ timeout?: number;
117
+ }
118
+
119
+ interface BashRenderContext {
120
+ /** Visual lines for truncated output (pre-computed by tool-execution) */
121
+ visualLines?: string[];
122
+ /** Number of lines skipped */
123
+ skippedCount?: number;
124
+ /** Total visual lines */
125
+ totalVisualLines?: number;
126
+ }
127
+
128
+ export const bashToolRenderer = {
129
+ renderCall(args: BashRenderArgs, uiTheme: Theme): Component {
130
+ const command = args.command || uiTheme.format.ellipsis;
131
+ const text = uiTheme.fg("toolTitle", uiTheme.bold(`$ ${command}`));
132
+ return new Text(text, 0, 0);
133
+ },
134
+
135
+ renderResult(
136
+ result: {
137
+ content: Array<{ type: string; text?: string }>;
138
+ details?: BashToolDetails;
139
+ },
140
+ options: RenderResultOptions & { renderContext?: BashRenderContext },
141
+ uiTheme: Theme,
142
+ ): Component {
143
+ const { expanded, renderContext } = options;
144
+ const details = result.details;
145
+ const lines: string[] = [];
146
+
147
+ // Get output text
148
+ const textContent = result.content?.find((c) => c.type === "text")?.text ?? "";
149
+ const output = textContent.trim();
150
+
151
+ if (output) {
152
+ if (expanded) {
153
+ // Show all lines when expanded
154
+ const styledOutput = output
155
+ .split("\n")
156
+ .map((line) => uiTheme.fg("toolOutput", line))
157
+ .join("\n");
158
+ lines.push(styledOutput);
159
+ } else if (renderContext?.visualLines) {
160
+ // Use pre-computed visual lines from tool-execution
161
+ const { visualLines, skippedCount = 0, totalVisualLines = visualLines.length } = renderContext;
162
+ if (skippedCount > 0) {
163
+ lines.push(
164
+ uiTheme.fg(
165
+ "dim",
166
+ `${uiTheme.format.ellipsis} (${skippedCount} earlier lines, showing ${visualLines.length} of ${totalVisualLines}) (ctrl+o to expand)`,
167
+ ),
168
+ );
169
+ }
170
+ lines.push(...visualLines);
171
+ } else {
172
+ // Fallback: show first few lines
173
+ const outputLines = output.split("\n");
174
+ const maxLines = 5;
175
+ const displayLines = outputLines.slice(0, maxLines);
176
+ const remaining = outputLines.length - maxLines;
177
+
178
+ lines.push(...displayLines.map((line) => uiTheme.fg("toolOutput", line)));
179
+ if (remaining > 0) {
180
+ lines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${remaining} more lines) (ctrl+o to expand)`));
181
+ }
182
+ }
183
+ }
184
+
185
+ // Truncation warnings
186
+ const truncation = details?.truncation;
187
+ const fullOutputPath = details?.fullOutputPath;
188
+ if (truncation?.truncated || fullOutputPath) {
189
+ const warnings: string[] = [];
190
+ if (fullOutputPath) {
191
+ warnings.push(`Full output: ${fullOutputPath}`);
192
+ }
193
+ if (truncation?.truncated) {
194
+ if (truncation.truncatedBy === "lines") {
195
+ warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
196
+ } else {
197
+ warnings.push(
198
+ `Truncated: ${truncation.outputLines} lines shown (${formatBytes(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
199
+ );
200
+ }
201
+ }
202
+ lines.push(uiTheme.fg("warning", wrapBrackets(warnings.join(". "), uiTheme)));
203
+ }
204
+
205
+ return new Text(lines.join("\n"), 0, 0);
206
+ },
207
+ };
@@ -6,27 +6,34 @@ declare module "@oh-my-pi/pi-agent-core" {
6
6
  interface AgentToolContext extends CustomToolContext {
7
7
  ui?: ExtensionUIContext;
8
8
  hasUI?: boolean;
9
+ toolNames?: string[];
9
10
  }
10
11
  }
11
12
 
12
13
  export interface ToolContextStore {
13
14
  getContext(): AgentToolContext;
14
15
  setUIContext(uiContext: ExtensionUIContext, hasUI: boolean): void;
16
+ setToolNames(names: string[]): void;
15
17
  }
16
18
 
17
19
  export function createToolContextStore(getBaseContext: () => CustomToolContext): ToolContextStore {
18
20
  let uiContext: ExtensionUIContext | undefined;
19
21
  let hasUI = false;
22
+ let toolNames: string[] = [];
20
23
 
21
24
  return {
22
25
  getContext: () => ({
23
26
  ...getBaseContext(),
24
27
  ui: uiContext,
25
28
  hasUI,
29
+ toolNames,
26
30
  }),
27
31
  setUIContext: (context, uiAvailable) => {
28
32
  uiContext = context;
29
33
  hasUI = uiAvailable;
30
34
  },
35
+ setToolNames: (names) => {
36
+ toolNames = names;
37
+ },
31
38
  };
32
39
  }
@@ -422,6 +422,7 @@ export async function computeEditDiff(
422
422
  newText: string,
423
423
  cwd: string,
424
424
  fuzzy = true,
425
+ all = false,
425
426
  ): Promise<EditDiffResult | EditDiffError> {
426
427
  const absolutePath = resolveToCwd(path, cwd);
427
428
 
@@ -443,34 +444,82 @@ export async function computeEditDiff(
443
444
  const normalizedOldText = normalizeToLF(oldText);
444
445
  const normalizedNewText = normalizeToLF(newText);
445
446
 
446
- const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
447
- allowFuzzy: fuzzy,
448
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
449
- });
447
+ let normalizedNewContent: string;
450
448
 
451
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
452
- return {
453
- error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
454
- };
455
- }
449
+ if (all) {
450
+ // Replace all occurrences mode with fuzzy matching
451
+ normalizedNewContent = normalizedContent;
452
+ let replacementCount = 0;
456
453
 
457
- if (!matchOutcome.match) {
458
- return {
459
- error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
460
- allowFuzzy: fuzzy,
461
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
462
- fuzzyMatches: matchOutcome.fuzzyMatches,
463
- }),
464
- };
465
- }
454
+ // First check: if exact matches exist, use simple replaceAll
455
+ const exactCount = normalizedContent.split(normalizedOldText).length - 1;
456
+ if (exactCount > 0) {
457
+ normalizedNewContent = normalizedContent.split(normalizedOldText).join(normalizedNewText);
458
+ replacementCount = exactCount;
459
+ } else {
460
+ // No exact matches - try fuzzy matching iteratively
461
+ while (true) {
462
+ const matchOutcome = findEditMatch(normalizedNewContent, normalizedOldText, {
463
+ allowFuzzy: fuzzy,
464
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
465
+ });
466
+
467
+ // In all mode, use closest match if it passes threshold (even with multiple matches)
468
+ const match =
469
+ matchOutcome.match ||
470
+ (fuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= DEFAULT_FUZZY_THRESHOLD
471
+ ? matchOutcome.closest
472
+ : undefined);
473
+
474
+ if (!match) {
475
+ if (replacementCount === 0) {
476
+ return {
477
+ error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
478
+ allowFuzzy: fuzzy,
479
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
480
+ fuzzyMatches: matchOutcome.fuzzyMatches,
481
+ }),
482
+ };
483
+ }
484
+ break;
485
+ }
486
+
487
+ normalizedNewContent =
488
+ normalizedNewContent.substring(0, match.startIndex) +
489
+ normalizedNewText +
490
+ normalizedNewContent.substring(match.startIndex + match.actualText.length);
491
+ replacementCount++;
492
+ }
493
+ }
494
+ } else {
495
+ // Single replacement mode with fuzzy matching
496
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
497
+ allowFuzzy: fuzzy,
498
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
499
+ });
500
+
501
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
502
+ return {
503
+ error: `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
504
+ };
505
+ }
466
506
 
467
- const match = matchOutcome.match;
507
+ if (!matchOutcome.match) {
508
+ return {
509
+ error: EditMatchError.formatMessage(path, normalizedOldText, matchOutcome.closest, {
510
+ allowFuzzy: fuzzy,
511
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
512
+ fuzzyMatches: matchOutcome.fuzzyMatches,
513
+ }),
514
+ };
515
+ }
468
516
 
469
- // Compute the new content
470
- const normalizedNewContent =
471
- normalizedContent.substring(0, match.startIndex) +
472
- normalizedNewText +
473
- normalizedContent.substring(match.startIndex + match.actualText.length);
517
+ const match = matchOutcome.match;
518
+ normalizedNewContent =
519
+ normalizedContent.substring(0, match.startIndex) +
520
+ normalizedNewText +
521
+ normalizedContent.substring(match.startIndex + match.actualText.length);
522
+ }
474
523
 
475
524
  // Check if it would actually change anything
476
525
  if (normalizedContent === normalizedNewContent) {
@@ -1,9 +1,15 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Text } from "@oh-my-pi/pi-tui";
2
4
  import { Type } from "@sinclair/typebox";
5
+ import { getLanguageFromPath, type Theme } from "../../modes/interactive/theme/theme";
3
6
  import editDescription from "../../prompts/tools/edit.md" with { type: "text" };
7
+ import type { RenderResultOptions } from "../custom-tools/types";
4
8
  import {
5
9
  DEFAULT_FUZZY_THRESHOLD,
6
10
  detectLineEnding,
11
+ type EditDiffError,
12
+ type EditDiffResult,
7
13
  EditMatchError,
8
14
  findEditMatch,
9
15
  generateDiffString,
@@ -11,8 +17,17 @@ import {
11
17
  restoreLineEndings,
12
18
  stripBom,
13
19
  } from "./edit-diff";
14
- import { type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "./lsp/index";
20
+ import type { ToolSession } from "./index";
21
+ import { createLspWritethrough, type FileDiagnosticsResult } from "./lsp/index";
15
22
  import { resolveToCwd } from "./path-utils";
23
+ import {
24
+ formatDiagnostics,
25
+ formatDiffStats,
26
+ getDiffStats,
27
+ shortenPath,
28
+ truncateDiffByHunk,
29
+ wrapBrackets,
30
+ } from "./render-utils";
16
31
 
17
32
  const editSchema = Type.Object({
18
33
  path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
@@ -20,6 +35,7 @@ const editSchema = Type.Object({
20
35
  description: "Text to find and replace (high-confidence fuzzy matching for whitespace/indentation is always on)",
21
36
  }),
22
37
  newText: Type.String({ description: "New text to replace the old text with" }),
38
+ all: Type.Optional(Type.Boolean({ description: "Replace all occurrences instead of requiring unique match" })),
23
39
  });
24
40
 
25
41
  export interface EditToolDetails {
@@ -31,16 +47,11 @@ export interface EditToolDetails {
31
47
  diagnostics?: FileDiagnosticsResult;
32
48
  }
33
49
 
34
- export interface EditToolOptions {
35
- /** Whether to accept high-confidence fuzzy matches for whitespace/indentation (default: true) */
36
- fuzzyMatch?: boolean;
37
- /** Writethrough callback to get LSP diagnostics after editing a file */
38
- writethrough?: WritethroughCallback;
39
- }
40
-
41
- export function createEditTool(cwd: string, options: EditToolOptions = {}): AgentTool<typeof editSchema> {
42
- const allowFuzzy = options.fuzzyMatch ?? true;
43
- const writethrough = options.writethrough ?? writethroughNoop;
50
+ export function createEditTool(session: ToolSession): AgentTool<typeof editSchema> {
51
+ const allowFuzzy = session.settings?.getEditFuzzyMatch() ?? true;
52
+ const enableDiagnostics = session.settings?.getLspDiagnosticsOnEdit() ?? false;
53
+ const enableFormat = session.settings?.getLspFormatOnWrite() ?? true;
54
+ const writethrough = createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics });
44
55
  return {
45
56
  name: "edit",
46
57
  label: "Edit",
@@ -48,7 +59,7 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
48
59
  parameters: editSchema,
49
60
  execute: async (
50
61
  _toolCallId: string,
51
- { path, oldText, newText }: { path: string; oldText: string; newText: string },
62
+ { path, oldText, newText, all }: { path: string; oldText: string; newText: string; all?: boolean },
52
63
  signal?: AbortSignal,
53
64
  ) => {
54
65
  // Reject .ipynb files - use NotebookEdit tool instead
@@ -56,7 +67,7 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
56
67
  throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
57
68
  }
58
69
 
59
- const absolutePath = resolveToCwd(path, cwd);
70
+ const absolutePath = resolveToCwd(path, session.cwd);
60
71
 
61
72
  const file = Bun.file(absolutePath);
62
73
  if (!(await file.exists())) {
@@ -73,30 +84,79 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
73
84
  const normalizedOldText = normalizeToLF(oldText);
74
85
  const normalizedNewText = normalizeToLF(newText);
75
86
 
76
- const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
77
- allowFuzzy,
78
- similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
79
- });
87
+ let normalizedNewContent: string;
88
+ let replacementCount = 0;
80
89
 
81
- if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
82
- throw new Error(
83
- `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
84
- );
85
- }
90
+ if (all) {
91
+ // Replace all occurrences mode with fuzzy matching
92
+ normalizedNewContent = normalizedContent;
93
+
94
+ // First check: if exact matches exist, use simple replaceAll
95
+ const exactCount = normalizedContent.split(normalizedOldText).length - 1;
96
+ if (exactCount > 0) {
97
+ normalizedNewContent = normalizedContent.split(normalizedOldText).join(normalizedNewText);
98
+ replacementCount = exactCount;
99
+ } else {
100
+ // No exact matches - try fuzzy matching iteratively
101
+ while (true) {
102
+ const matchOutcome = findEditMatch(normalizedNewContent, normalizedOldText, {
103
+ allowFuzzy,
104
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
105
+ });
106
+
107
+ // In all mode, use closest match if it passes threshold (even with multiple matches)
108
+ const match =
109
+ matchOutcome.match ||
110
+ (allowFuzzy && matchOutcome.closest && matchOutcome.closest.confidence >= DEFAULT_FUZZY_THRESHOLD
111
+ ? matchOutcome.closest
112
+ : undefined);
113
+
114
+ if (!match) {
115
+ if (replacementCount === 0) {
116
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
117
+ allowFuzzy,
118
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
119
+ fuzzyMatches: matchOutcome.fuzzyMatches,
120
+ });
121
+ }
122
+ break;
123
+ }
86
124
 
87
- if (!matchOutcome.match) {
88
- throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
125
+ normalizedNewContent =
126
+ normalizedNewContent.substring(0, match.startIndex) +
127
+ normalizedNewText +
128
+ normalizedNewContent.substring(match.startIndex + match.actualText.length);
129
+ replacementCount++;
130
+ }
131
+ }
132
+ } else {
133
+ // Single replacement mode with fuzzy matching
134
+ const matchOutcome = findEditMatch(normalizedContent, normalizedOldText, {
89
135
  allowFuzzy,
90
136
  similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
91
- fuzzyMatches: matchOutcome.fuzzyMatches,
92
137
  });
93
- }
94
138
 
95
- const match = matchOutcome.match;
96
- const normalizedNewContent =
97
- normalizedContent.substring(0, match.startIndex) +
98
- normalizedNewText +
99
- normalizedContent.substring(match.startIndex + match.actualText.length);
139
+ if (matchOutcome.occurrences && matchOutcome.occurrences > 1) {
140
+ throw new Error(
141
+ `Found ${matchOutcome.occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique, or use all: true to replace all.`,
142
+ );
143
+ }
144
+
145
+ if (!matchOutcome.match) {
146
+ throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, {
147
+ allowFuzzy,
148
+ similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
149
+ fuzzyMatches: matchOutcome.fuzzyMatches,
150
+ });
151
+ }
152
+
153
+ const match = matchOutcome.match;
154
+ normalizedNewContent =
155
+ normalizedContent.substring(0, match.startIndex) +
156
+ normalizedNewText +
157
+ normalizedContent.substring(match.startIndex + match.actualText.length);
158
+ replacementCount = 1;
159
+ }
100
160
 
101
161
  // Verify the replacement actually changed something
102
162
  if (normalizedContent === normalizedNewContent) {
@@ -111,7 +171,10 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
111
171
  const diffResult = generateDiffString(normalizedContent, normalizedNewContent);
112
172
 
113
173
  // Build result text
114
- let resultText = `Successfully replaced text in ${path}.`;
174
+ let resultText =
175
+ replacementCount > 1
176
+ ? `Successfully replaced ${replacementCount} occurrences in ${path}.`
177
+ : `Successfully replaced text in ${path}.`;
115
178
 
116
179
  const messages = diagnostics?.messages;
117
180
  if (messages && messages.length > 0) {
@@ -136,5 +199,129 @@ export function createEditTool(cwd: string, options: EditToolOptions = {}): Agen
136
199
  };
137
200
  }
138
201
 
139
- /** Default edit tool using process.cwd() - for backwards compatibility */
140
- export const editTool = createEditTool(process.cwd());
202
+ // =============================================================================
203
+ // TUI Renderer
204
+ // =============================================================================
205
+
206
+ interface EditRenderArgs {
207
+ path?: string;
208
+ file_path?: string;
209
+ oldText?: string;
210
+ newText?: string;
211
+ all?: boolean;
212
+ }
213
+
214
+ /** Extended context for edit tool rendering */
215
+ export interface EditRenderContext {
216
+ /** Pre-computed diff preview (computed before tool executes) */
217
+ editDiffPreview?: EditDiffResult | EditDiffError;
218
+ /** Function to render diff text with syntax highlighting */
219
+ renderDiff?: (diffText: string, options?: { filePath?: string }) => string;
220
+ }
221
+
222
+ const EDIT_DIFF_PREVIEW_HUNKS = 2;
223
+ const EDIT_DIFF_PREVIEW_LINES = 24;
224
+
225
+ function countLines(text: string): number {
226
+ if (!text) return 0;
227
+ return text.split("\n").length;
228
+ }
229
+
230
+ function formatMetadataLine(lineCount: number | null, language: string | undefined, uiTheme: Theme): string {
231
+ const icon = uiTheme.getLangIcon(language);
232
+ if (lineCount !== null) {
233
+ return uiTheme.fg("dim", `${icon} ${lineCount} lines`);
234
+ }
235
+ return uiTheme.fg("dim", `${icon}`);
236
+ }
237
+
238
+ export const editToolRenderer = {
239
+ renderCall(args: EditRenderArgs, uiTheme: Theme): Component {
240
+ const rawPath = args.file_path || args.path || "";
241
+ const filePath = shortenPath(rawPath);
242
+ const editLanguage = getLanguageFromPath(rawPath) ?? "text";
243
+ const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
244
+ const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
245
+
246
+ const text = `${uiTheme.fg("toolTitle", uiTheme.bold("Edit"))} ${editIcon} ${pathDisplay}`;
247
+ return new Text(text, 0, 0);
248
+ },
249
+
250
+ renderResult(
251
+ result: { content: Array<{ type: string; text?: string }>; details?: EditToolDetails; isError?: boolean },
252
+ options: RenderResultOptions & { renderContext?: EditRenderContext },
253
+ uiTheme: Theme,
254
+ args?: EditRenderArgs,
255
+ ): Component {
256
+ const { expanded, renderContext } = options;
257
+ const rawPath = args?.file_path || args?.path || "";
258
+ const filePath = shortenPath(rawPath);
259
+ const editLanguage = getLanguageFromPath(rawPath) ?? "text";
260
+ const editIcon = uiTheme.fg("muted", uiTheme.getLangIcon(editLanguage));
261
+ const editDiffPreview = renderContext?.editDiffPreview;
262
+ const renderDiffFn = renderContext?.renderDiff ?? ((t: string) => t);
263
+
264
+ // Build path display with line number if available
265
+ let pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
266
+ const firstChangedLine =
267
+ (editDiffPreview && "firstChangedLine" in editDiffPreview ? editDiffPreview.firstChangedLine : undefined) ||
268
+ (result.details && !result.isError ? result.details.firstChangedLine : undefined);
269
+ if (firstChangedLine) {
270
+ pathDisplay += uiTheme.fg("warning", `:${firstChangedLine}`);
271
+ }
272
+
273
+ let text = `${uiTheme.fg("toolTitle", uiTheme.bold("Edit"))} ${editIcon} ${pathDisplay}`;
274
+
275
+ const editLineCount = countLines(args?.newText ?? args?.oldText ?? "");
276
+ text += `\n${formatMetadataLine(editLineCount, editLanguage, uiTheme)}`;
277
+
278
+ if (result.isError) {
279
+ // Show error from result
280
+ const errorText = result.content?.find((c) => c.type === "text")?.text ?? "";
281
+ if (errorText) {
282
+ text += `\n\n${uiTheme.fg("error", errorText)}`;
283
+ }
284
+ } else if (editDiffPreview) {
285
+ // Use cached diff preview (works both before and after execution)
286
+ if ("error" in editDiffPreview) {
287
+ text += `\n\n${uiTheme.fg("error", editDiffPreview.error)}`;
288
+ } else if (editDiffPreview.diff) {
289
+ const diffStats = getDiffStats(editDiffPreview.diff);
290
+ text += `\n${uiTheme.fg("dim", uiTheme.format.bracketLeft)}${formatDiffStats(
291
+ diffStats.added,
292
+ diffStats.removed,
293
+ diffStats.hunks,
294
+ uiTheme,
295
+ )}${uiTheme.fg("dim", uiTheme.format.bracketRight)}`;
296
+
297
+ const {
298
+ text: diffText,
299
+ hiddenHunks,
300
+ hiddenLines,
301
+ } = expanded
302
+ ? { text: editDiffPreview.diff, hiddenHunks: 0, hiddenLines: 0 }
303
+ : truncateDiffByHunk(editDiffPreview.diff, EDIT_DIFF_PREVIEW_HUNKS, EDIT_DIFF_PREVIEW_LINES);
304
+
305
+ text += `\n\n${renderDiffFn(diffText, { filePath: rawPath })}`;
306
+ if (!expanded && (hiddenHunks > 0 || hiddenLines > 0)) {
307
+ const remainder: string[] = [];
308
+ if (hiddenHunks > 0) remainder.push(`${hiddenHunks} more hunks`);
309
+ if (hiddenLines > 0) remainder.push(`${hiddenLines} more lines`);
310
+ text += uiTheme.fg(
311
+ "toolOutput",
312
+ `\n${uiTheme.format.ellipsis} (${remainder.join(", ")}) ${wrapBrackets("Ctrl+O to expand", uiTheme)}`,
313
+ );
314
+ }
315
+ }
316
+ }
317
+
318
+ // Show LSP diagnostics if available
319
+ if (result.details?.diagnostics) {
320
+ text += formatDiagnostics(result.details.diagnostics, expanded, uiTheme, (fp) =>
321
+ uiTheme.getLangIcon(getLanguageFromPath(fp)),
322
+ );
323
+ }
324
+
325
+ return new Text(text, 0, 0);
326
+ },
327
+ };
@@ -78,10 +78,7 @@ export function renderExaResult(
78
78
  }
79
79
 
80
80
  if (remaining > 0) {
81
- text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
82
- "muted",
83
- formatMoreItems(remaining, "line", uiTheme),
84
- )}`;
81
+ text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg("muted", formatMoreItems(remaining, "line", uiTheme))}`;
85
82
  }
86
83
 
87
84
  return new Text(text, 0, 0);
@@ -168,17 +165,11 @@ export function renderExaResult(
168
165
  text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("accent", title)}${domainPart}`;
169
166
 
170
167
  if (res.url) {
171
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
172
- "mdLinkUrl",
173
- res.url,
174
- )}`;
168
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("mdLinkUrl", res.url)}`;
175
169
  }
176
170
 
177
171
  if (res.author) {
178
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
179
- "muted",
180
- `Author: ${res.author}`,
181
- )}`;
172
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("muted", `Author: ${res.author}`)}`;
182
173
  }
183
174
 
184
175
  if (res.publishedDate) {
@@ -206,10 +197,7 @@ export function renderExaResult(
206
197
  }
207
198
 
208
199
  if (res.highlights?.length) {
209
- text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
210
- "accent",
211
- "Highlights",
212
- )}`;
200
+ text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg("accent", "Highlights")}`;
213
201
  const maxHighlights = Math.min(res.highlights.length, 3);
214
202
  for (let j = 0; j < maxHighlights; j++) {
215
203
  const h = res.highlights[j];