Skip to content

Commit e2b086f

Browse files
committed
feat: add replace_lines tool with configurable editMode scaffolding
1 parent e41076a commit e2b086f

6 files changed

Lines changed: 162 additions & 10 deletions

File tree

src/config-field-definitions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ export const CONFIG_FIELD_DEFINITIONS = {
3838
description: 'Maximum number of lines that can be written in one edit operation. This helps prevent accidental oversized writes and keeps file changes predictable.',
3939
valueType: 'number',
4040
},
41+
editMode: {
42+
label: 'Edit Mode',
43+
description: 'Controls which file editing tools are registered. "string-replace" (default) uses edit_block with fuzzy matching. "line-replace" uses replace_lines with line numbers. "both" registers both tools. Fewer tools means a leaner system prompt.',
44+
valueType: 'string',
45+
},
4146
} as const satisfies Record<string, ConfigFieldDefinition>;
4247

4348
export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS;

src/config-manager.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface ServerConfig {
1313
telemetryEnabled?: boolean; // New field for telemetry control
1414
fileWriteLineLimit?: number; // Line limit for file write operations
1515
fileReadLineLimit?: number; // Default line limit for file read operations (changed from character-based)
16+
editMode?: 'string-replace' | 'line-replace' | 'both'; // Controls which editing tools are registered
1617
clientId?: string; // Unique client identifier for analytics
1718
currentClient?: ClientInfo; // Current connected client information
1819
[key: string]: any; // Allow for arbitrary configuration keys (including abTest_* keys)
@@ -210,6 +211,14 @@ class ConfigManager {
210211
if (key === 'telemetryEnabled') {
211212
value = normalizeTelemetryEnabledValue(value);
212213
}
214+
215+
// Validate editMode values
216+
if (key === 'editMode') {
217+
const valid = ['string-replace', 'line-replace', 'both'];
218+
if (!valid.includes(value)) {
219+
throw new Error(`Invalid editMode "${value}". Must be one of: ${valid.join(', ')}`);
220+
}
221+
}
213222

214223
// Special handling for telemetry opt-out
215224
if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) {

src/handlers/edit-search-handlers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import {
22
EditBlockArgsSchema
33
} from '../tools/schemas.js';
44

5-
import { handleEditBlock } from '../tools/edit.js';
5+
import { handleEditBlock, handleReplaceLines } from '../tools/edit.js';
66

77
import { ServerResult } from '../types.js';
88

99
/**
1010
* Handle edit_block command
1111
* Uses the enhanced implementation with multiple occurrence support and fuzzy matching
1212
*/
13-
export { handleEditBlock };
13+
export { handleEditBlock, handleReplaceLines };

src/server.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
SetConfigValueArgsSchema,
4242
ListProcessesArgsSchema,
4343
EditBlockArgsSchema,
44+
ReplaceLinesArgsSchema,
4445
GetUsageStatsArgsSchema,
4546
GiveFeedbackArgsSchema,
4647
StartSearchArgsSchema,
@@ -57,6 +58,7 @@ import { giveFeedbackToDesktopCommander } from './tools/feedback.js';
5758
import { getPrompts } from './tools/prompts.js';
5859
import { trackToolCall } from './utils/trackTools.js';
5960
import { usageTracker } from './utils/usageTracker.js';
61+
import { configManager } from './config-manager.js';
6062
import { processDockerPrompt } from './utils/dockerPrompt.js';
6163
import { toolHistory } from './utils/toolHistory.js';
6264
import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';
@@ -204,16 +206,21 @@ export { currentClient };
204206
deferLog('info', 'Setting up request handlers...');
205207

206208
/**
207-
* Check if a tool should be included based on current client
209+
* Check if a tool should be included based on current client and config
208210
*/
209-
function shouldIncludeTool(toolName: string): boolean {
211+
async function shouldIncludeTool(toolName: string): Promise<boolean> {
210212
// Exclude give_feedback_to_desktop_commander for desktop-commander client
211213
if (toolName === 'give_feedback_to_desktop_commander' && currentClient?.name === 'desktop-commander') {
212214
return false;
213215
}
214216

215-
// Add more conditional tool logic here as needed
216-
// Example: if (toolName === 'some_tool' && currentClient?.name === 'some_client') return false;
217+
// Edit mode controls which editing tools are registered
218+
if (toolName === 'edit_block' || toolName === 'replace_lines') {
219+
const editMode = (await configManager.getValue('editMode')) || 'string-replace';
220+
if (editMode === 'string-replace' && toolName === 'replace_lines') return false;
221+
if (editMode === 'line-replace' && toolName === 'edit_block') return false;
222+
// 'both' keeps both tools
223+
}
217224

218225
return true;
219226
}
@@ -788,6 +795,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
788795
openWorldHint: false,
789796
},
790797
},
798+
{
799+
name: "replace_lines",
800+
description: `
801+
Replace lines in a text file by line number range.
802+
803+
Token-efficient alternative to edit_block when you already know line numbers
804+
from a previous read_file call. No need to send old_string - just specify
805+
which lines to replace.
806+
807+
PARAMETERS:
808+
- path: File path
809+
- startLine: First line to replace (1-based, from read_file output)
810+
- endLine: Last line to replace (1-based, inclusive)
811+
- newContent: Replacement text (can be more or fewer lines than removed)
812+
813+
EXAMPLES:
814+
- Replace lines 10-15: startLine=10, endLine=15, newContent="new code here"
815+
- Delete lines 5-8: startLine=5, endLine=8, newContent=""
816+
- Insert after line 3: Use edit_block or write_file instead
817+
818+
WARNING - LINE NUMBER SHIFTING:
819+
After every replace_lines call where newContent has a different number of
820+
lines than the replaced range, ALL subsequent line numbers shift.
821+
ALWAYS re-read the file before making another replace_lines call on the
822+
same file. The response includes context lines to verify correctness.
823+
824+
IMPORTANT: Line numbers must match the read_file output exactly.
825+
If the file has been modified since the last read_file, re-read first.
826+
827+
NOTE: This tool is only available when editMode is set to "line-replace" or "both"
828+
in the configuration. Default editMode is "string-replace" (edit_block only).
829+
830+
${PATH_GUIDANCE}
831+
${CMD_PREFIX_DESCRIPTION}`,
832+
inputSchema: zodToJsonSchema(ReplaceLinesArgsSchema),
833+
annotations: {
834+
title: "Replace Lines",
835+
readOnlyHint: false,
836+
destructiveHint: true,
837+
openWorldHint: false,
838+
},
839+
},
791840

792841
// Terminal tools
793842
{
@@ -1147,8 +1196,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
11471196
}
11481197
];
11491198

1150-
// Filter tools based on current client
1151-
const filteredTools = allTools.filter(tool => shouldIncludeTool(tool.name));
1199+
// Filter tools based on current client and config
1200+
const includeResults = await Promise.all(allTools.map(tool => shouldIncludeTool(tool.name)));
1201+
const filteredTools = allTools.filter((_, i) => includeResults[i]);
11521202

11531203
// logToStderr('debug', `Returning ${filteredTools.length} tools (filtered from ${allTools.length} total) for client: ${currentClient?.name || 'unknown'}`);
11541204

@@ -1415,6 +1465,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest)
14151465
result = await handlers.handleEditBlock(args);
14161466
break;
14171467

1468+
case "replace_lines":
1469+
result = await handlers.handleReplaceLines(args);
1470+
break;
1471+
14181472
default:
14191473
capture('server_unknown_tool', { name });
14201474
result = {

src/tools/edit.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { ServerResult } from '../types.js';
2121
import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js';
2222
import { capture } from '../utils/capture.js';
2323
import { createErrorResponse } from '../error-handlers.js';
24-
import { EditBlockArgsSchema } from "./schemas.js";
24+
import { EditBlockArgsSchema, ReplaceLinesArgsSchema } from "./schemas.js";
2525
import path from 'path';
2626
import { detectLineEnding, normalizeLineEndings } from '../utils/lineEndingHandler.js';
2727
import { configManager } from '../config-manager.js';
@@ -457,4 +457,78 @@ export async function handleEditBlock(args: unknown): Promise<ServerResult> {
457457
search: parsed.old_string,
458458
replace: parsed.new_string
459459
}, parsed.expected_replacements);
460-
}
460+
}
461+
462+
/**
463+
* Handle replace_lines command - line-based editing
464+
* Replaces lines startLine..endLine (1-based, inclusive) with newContent
465+
*/
466+
export async function handleReplaceLines(args: unknown): Promise<ServerResult> {
467+
const parsed = ReplaceLinesArgsSchema.parse(args);
468+
const validPath = await validatePath(parsed.path);
469+
const content = await readFileInternal(validPath, 0, Number.MAX_SAFE_INTEGER);
470+
471+
if (typeof content !== 'string') {
472+
return createErrorResponse('Cannot read file as text: ' + parsed.path);
473+
}
474+
475+
const fileLineEnding = detectLineEnding(content);
476+
const lines = content.split(/\r\n|\r|\n/);
477+
const totalLines = lines.length;
478+
479+
if (parsed.startLine > totalLines) {
480+
return createErrorResponse(`startLine ${parsed.startLine} exceeds file length (${totalLines} lines)`);
481+
}
482+
if (parsed.endLine > totalLines) {
483+
return createErrorResponse(`endLine ${parsed.endLine} exceeds file length (${totalLines} lines)`);
484+
}
485+
486+
const before = lines.slice(0, parsed.startLine - 1);
487+
const after = lines.slice(parsed.endLine);
488+
const newLines = parsed.newContent === '' ? [] : parsed.newContent.split(/\r\n|\r|\n/);
489+
const result = [...before, ...newLines, ...after];
490+
const sep = fileLineEnding;
491+
const newContent = result.join(sep);
492+
493+
await writeFile(parsed.path, newContent);
494+
495+
const removedCount = parsed.endLine - parsed.startLine + 1;
496+
const insertedCount = newLines.length;
497+
const lineDelta = insertedCount - removedCount;
498+
499+
capture('server_replace_lines', {
500+
fileExtension: path.extname(parsed.path).toLowerCase(),
501+
removedLines: removedCount,
502+
insertedLines: insertedCount,
503+
});
504+
505+
// Build response with context around the replacement
506+
const CONTEXT_LINES = 3;
507+
const newTotalLines = result.length;
508+
const insertStart = parsed.startLine;
509+
const insertEnd = insertedCount > 0 ? parsed.startLine + insertedCount - 1 : parsed.startLine - 1;
510+
511+
const ctxStart = Math.max(0, parsed.startLine - 1 - CONTEXT_LINES);
512+
const ctxEnd = Math.min(newTotalLines, (insertedCount > 0 ? insertEnd : parsed.startLine) + CONTEXT_LINES);
513+
const contextSlice = result.slice(ctxStart, ctxEnd);
514+
const contextOutput = contextSlice.map((line, i) => {
515+
const lineNum = ctxStart + i + 1;
516+
const marker = (insertedCount > 0 && lineNum >= insertStart && lineNum <= insertEnd) ? '+' : ' ';
517+
return `${marker} ${String(lineNum).padStart(4)} ${line}`;
518+
}).join('\n');
519+
520+
let msg = `Replaced lines ${parsed.startLine}-${parsed.endLine} (${removedCount} lines) with ${insertedCount} lines in ${parsed.path}`;
521+
522+
if (lineDelta !== 0) {
523+
msg += `\n\nWARNING: Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. All line numbers after line ${insertEnd} have shifted. Re-read before further edits.`;
524+
}
525+
526+
msg += `\n\nContext (lines ${ctxStart + 1}-${ctxEnd}, + = new content):\n${contextOutput}`;
527+
528+
return {
529+
content: [{
530+
type: "text",
531+
text: msg
532+
}],
533+
};
534+
}

src/tools/schemas.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,16 @@ export const GetPromptsArgsSchema = z.object({
204204
// anonymous_user_use_case: z.string().optional(),
205205
});
206206

207+
// Replace lines tool schema - line-based editing (token-efficient alternative to edit_block)
208+
export const ReplaceLinesArgsSchema = z.object({
209+
path: z.string(),
210+
startLine: z.number().int().min(1),
211+
endLine: z.number().int().min(1),
212+
newContent: z.string(),
213+
}).refine(data => data.endLine >= data.startLine, {
214+
message: "endLine must be >= startLine"
215+
});
216+
207217
// Tool history schema
208218
export const GetRecentToolCallsArgsSchema = z.object({
209219
maxResults: z.number().min(1).max(1000).optional().default(50),

0 commit comments

Comments
 (0)