@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.
- package/CHANGELOG.md +107 -8
- package/docs/custom-tools.md +3 -3
- package/docs/extensions.md +226 -220
- package/docs/hooks.md +2 -2
- package/docs/sdk.md +50 -53
- package/examples/custom-tools/README.md +2 -17
- package/examples/extensions/README.md +76 -74
- package/examples/extensions/todo.ts +2 -5
- package/examples/hooks/custom-compaction.ts +2 -4
- package/examples/hooks/handoff.ts +1 -1
- package/examples/hooks/qna.ts +1 -1
- package/examples/sdk/02-custom-model.ts +1 -1
- package/examples/sdk/README.md +7 -11
- package/package.json +6 -6
- package/src/cli/args.ts +9 -6
- package/src/cli/file-processor.ts +1 -1
- package/src/cli/list-models.ts +1 -1
- package/src/core/agent-session.ts +16 -5
- package/src/core/auth-storage.ts +1 -1
- package/src/core/compaction/branch-summarization.ts +2 -2
- package/src/core/compaction/compaction.ts +2 -2
- package/src/core/compaction/utils.ts +1 -1
- package/src/core/custom-tools/types.ts +1 -1
- package/src/core/custom-tools/wrapper.ts +0 -1
- package/src/core/extensions/index.ts +1 -6
- package/src/core/extensions/runner.ts +1 -1
- package/src/core/extensions/types.ts +1 -1
- package/src/core/extensions/wrapper.ts +1 -8
- package/src/core/file-mentions.ts +5 -8
- package/src/core/hooks/runner.ts +2 -2
- package/src/core/hooks/types.ts +1 -1
- package/src/core/messages.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/model-resolver.ts +1 -1
- package/src/core/sdk.ts +64 -105
- package/src/core/session-manager.ts +18 -22
- package/src/core/settings-manager.ts +66 -1
- package/src/core/slash-commands.ts +12 -5
- package/src/core/system-prompt.ts +49 -36
- package/src/core/title-generator.ts +2 -2
- package/src/core/tools/ask.ts +98 -4
- package/src/core/tools/bash-interceptor.ts +11 -4
- package/src/core/tools/bash.ts +121 -5
- package/src/core/tools/context.ts +7 -0
- package/src/core/tools/edit-diff.ts +73 -24
- package/src/core/tools/edit.ts +221 -34
- package/src/core/tools/exa/render.ts +4 -16
- package/src/core/tools/find.ts +149 -5
- package/src/core/tools/gemini-image.ts +279 -56
- package/src/core/tools/git.ts +17 -3
- package/src/core/tools/grep.ts +185 -5
- package/src/core/tools/index.test.ts +180 -0
- package/src/core/tools/index.ts +96 -242
- package/src/core/tools/ls.ts +133 -5
- package/src/core/tools/lsp/index.ts +32 -29
- package/src/core/tools/lsp/render.ts +21 -22
- package/src/core/tools/notebook.ts +112 -4
- package/src/core/tools/output.ts +175 -15
- package/src/core/tools/read.ts +127 -25
- package/src/core/tools/render-utils.ts +241 -0
- package/src/core/tools/renderers.ts +40 -828
- package/src/core/tools/review.ts +26 -25
- package/src/core/tools/rulebook.ts +11 -3
- package/src/core/tools/task/agents.ts +28 -7
- package/src/core/tools/task/discovery.ts +0 -6
- package/src/core/tools/task/executor.ts +264 -254
- package/src/core/tools/task/index.ts +48 -208
- package/src/core/tools/task/render.ts +26 -11
- package/src/core/tools/task/types.ts +7 -12
- package/src/core/tools/task/worker-protocol.ts +17 -0
- package/src/core/tools/task/worker.ts +238 -0
- package/src/core/tools/truncate.ts +27 -1
- package/src/core/tools/web-fetch.ts +25 -49
- package/src/core/tools/web-search/index.ts +132 -46
- package/src/core/tools/web-search/providers/anthropic.ts +7 -2
- package/src/core/tools/web-search/providers/exa.ts +2 -1
- package/src/core/tools/web-search/providers/perplexity.ts +6 -1
- package/src/core/tools/web-search/render.ts +6 -4
- package/src/core/tools/web-search/types.ts +13 -0
- package/src/core/tools/write.ts +96 -14
- package/src/core/voice.ts +1 -1
- package/src/discovery/helpers.test.ts +1 -1
- package/src/index.ts +5 -16
- package/src/main.ts +5 -5
- package/src/modes/interactive/components/assistant-message.ts +1 -1
- package/src/modes/interactive/components/custom-message.ts +1 -1
- package/src/modes/interactive/components/extensions/inspector-panel.ts +25 -22
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/footer.ts +1 -1
- package/src/modes/interactive/components/hook-message.ts +1 -1
- package/src/modes/interactive/components/model-selector.ts +1 -1
- package/src/modes/interactive/components/oauth-selector.ts +1 -1
- package/src/modes/interactive/components/settings-defs.ts +49 -0
- package/src/modes/interactive/components/status-line.ts +1 -1
- package/src/modes/interactive/components/tool-execution.ts +93 -538
- package/src/modes/interactive/interactive-mode.ts +19 -7
- package/src/modes/interactive/theme/theme.ts +4 -4
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/rpc/rpc-client.ts +1 -1
- package/src/modes/rpc/rpc-types.ts +1 -1
- package/src/prompts/system-prompt.md +4 -0
- package/src/prompts/task.md +0 -7
- package/src/prompts/tools/gemini-image.md +5 -1
- package/src/prompts/tools/output.md +6 -2
- package/src/prompts/tools/task.md +68 -0
- package/src/prompts/tools/web-fetch.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/utils/image-convert.ts +8 -2
- package/src/utils/image-magick.ts +247 -0
- package/src/utils/image-resize.ts +53 -13
- package/examples/custom-tools/question/index.ts +0 -84
- package/examples/custom-tools/subagent/README.md +0 -172
- package/examples/custom-tools/subagent/agents/planner.md +0 -37
- package/examples/custom-tools/subagent/agents/scout.md +0 -50
- package/examples/custom-tools/subagent/agents/worker.md +0 -24
- package/examples/custom-tools/subagent/agents.ts +0 -156
- package/examples/custom-tools/subagent/commands/implement-and-review.md +0 -10
- package/examples/custom-tools/subagent/commands/implement.md +0 -10
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +0 -9
- package/examples/custom-tools/subagent/index.ts +0 -1002
- package/examples/sdk/05-tools.ts +0 -94
- package/examples/sdk/12-full-control.ts +0 -95
- package/src/prompts/browser.md +0 -71
package/src/core/tools/bash.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
447
|
-
allowFuzzy: fuzzy,
|
|
448
|
-
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
|
|
449
|
-
});
|
|
447
|
+
let normalizedNewContent: string;
|
|
450
448
|
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
449
|
+
if (all) {
|
|
450
|
+
// Replace all occurrences mode with fuzzy matching
|
|
451
|
+
normalizedNewContent = normalizedContent;
|
|
452
|
+
let replacementCount = 0;
|
|
456
453
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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) {
|
package/src/core/tools/edit.ts
CHANGED
|
@@ -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
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
writethrough
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
similarityThreshold: DEFAULT_FUZZY_THRESHOLD,
|
|
79
|
-
});
|
|
87
|
+
let normalizedNewContent: string;
|
|
88
|
+
let replacementCount = 0;
|
|
80
89
|
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 =
|
|
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
|
-
|
|
140
|
-
|
|
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];
|