Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/config-field-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export const CONFIG_FIELD_DEFINITIONS = {
description: 'Maximum number of lines that can be written in one edit operation. This helps prevent accidental oversized writes and keeps file changes predictable.',
valueType: 'number',
},
editMode: {
label: 'Edit Mode',
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.',
valueType: 'string',
},
} as const satisfies Record<string, ConfigFieldDefinition>;

export type ConfigFieldKey = keyof typeof CONFIG_FIELD_DEFINITIONS;
Expand Down
20 changes: 20 additions & 0 deletions src/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface ServerConfig {
telemetryEnabled?: boolean; // New field for telemetry control
fileWriteLineLimit?: number; // Line limit for file write operations
fileReadLineLimit?: number; // Default line limit for file read operations (changed from character-based)
editMode?: 'string-replace' | 'line-replace' | 'both'; // Controls which editing tools are registered
clientId?: string; // Unique client identifier for analytics
currentClient?: ClientInfo; // Current connected client information
[key: string]: any; // Allow for arbitrary configuration keys (including abTest_* keys)
Expand Down Expand Up @@ -44,6 +45,14 @@ export function isTelemetryDisabledValue(value: unknown): boolean {
return normalizeTelemetryEnabledValue(value) === false;
}

const VALID_EDIT_MODES = ['string-replace', 'line-replace', 'both'];

function validateEditMode(value: unknown): void {
if (value !== undefined && !VALID_EDIT_MODES.includes(value as string)) {
throw new Error(`Invalid editMode "${value}". Must be one of: ${VALID_EDIT_MODES.join(', ')}`);
}
}

/**
* Singleton config manager for the server
*/
Expand Down Expand Up @@ -87,6 +96,11 @@ class ConfigManager {
}
this.config['version'] = VERSION;

// Sanitize editMode from disk - invalid values reset to default
if (this.config.editMode && !VALID_EDIT_MODES.includes(this.config.editMode)) {
delete this.config.editMode;
}

this.initialized = true;
} catch (error) {
console.error('Failed to initialize config:', error);
Expand Down Expand Up @@ -210,6 +224,11 @@ class ConfigManager {
if (key === 'telemetryEnabled') {
value = normalizeTelemetryEnabledValue(value);
}

// Validate editMode values
if (key === 'editMode') {
validateEditMode(value);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Special handling for telemetry opt-out
if (key === 'telemetryEnabled' && isTelemetryDisabledValue(value)) {
Expand Down Expand Up @@ -241,6 +260,7 @@ class ConfigManager {
*/
async updateConfig(updates: Partial<ServerConfig>): Promise<ServerConfig> {
await this.init();
if (updates.editMode !== undefined) validateEditMode(updates.editMode);
this.config = { ...this.config, ...updates };
await this.saveConfig();
return { ...this.config };
Expand Down
4 changes: 2 additions & 2 deletions src/handlers/edit-search-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import {
EditBlockArgsSchema
} from '../tools/schemas.js';

import { handleEditBlock } from '../tools/edit.js';
import { handleEditBlock, handleReplaceLines } from '../tools/edit.js';

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

/**
* Handle edit_block command
* Uses the enhanced implementation with multiple occurrence support and fuzzy matching
*/
export { handleEditBlock };
export { handleEditBlock, handleReplaceLines };
66 changes: 60 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
SetConfigValueArgsSchema,
ListProcessesArgsSchema,
EditBlockArgsSchema,
ReplaceLinesArgsSchema,
GetUsageStatsArgsSchema,
GiveFeedbackArgsSchema,
StartSearchArgsSchema,
Expand All @@ -57,6 +58,7 @@ import { giveFeedbackToDesktopCommander } from './tools/feedback.js';
import { getPrompts } from './tools/prompts.js';
import { trackToolCall } from './utils/trackTools.js';
import { usageTracker } from './utils/usageTracker.js';
import { configManager } from './config-manager.js';
import { processDockerPrompt } from './utils/dockerPrompt.js';
import { toolHistory } from './utils/toolHistory.js';
import { handleWelcomePageOnboarding } from './utils/welcome-onboarding.js';
Expand Down Expand Up @@ -204,16 +206,21 @@ export { currentClient };
deferLog('info', 'Setting up request handlers...');

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

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

return true;
}
Expand Down Expand Up @@ -788,6 +795,48 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
openWorldHint: false,
},
},
{
name: "replace_lines",
description: `
Replace lines in a text file by line number range.

Token-efficient alternative to edit_block when you already know line numbers
from a previous read_file call. No need to send old_string - just specify
which lines to replace.

PARAMETERS:
- path: File path
- startLine: First line to replace (1-based, from read_file output)
- endLine: Last line to replace (1-based, inclusive)
- newContent: Replacement text (can be more or fewer lines than removed)

EXAMPLES:
- Replace lines 10-15: startLine=10, endLine=15, newContent="new code here"
- Delete lines 5-8: startLine=5, endLine=8, newContent=""
- Insert after line 3: Use edit_block or write_file instead

WARNING - LINE NUMBER SHIFTING:
After every replace_lines call where newContent has a different number of
lines than the replaced range, ALL subsequent line numbers shift.
ALWAYS re-read the file before making another replace_lines call on the
same file. The response includes context lines to verify correctness.

IMPORTANT: Line numbers must match the read_file output exactly.
If the file has been modified since the last read_file, re-read first.

NOTE: This tool is only available when editMode is set to "line-replace" or "both"
in the configuration. Default editMode is "string-replace" (edit_block only).

${PATH_GUIDANCE}
${CMD_PREFIX_DESCRIPTION}`,
inputSchema: zodToJsonSchema(ReplaceLinesArgsSchema),
annotations: {
title: "Replace Lines",
readOnlyHint: false,
destructiveHint: true,
openWorldHint: false,
},
},

// Terminal tools
{
Expand Down Expand Up @@ -1147,8 +1196,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}
];

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

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

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

case "replace_lines":
result = await handlers.handleReplaceLines(args);
break;

default:
capture('server_unknown_tool', { name });
result = {
Expand Down
79 changes: 77 additions & 2 deletions src/tools/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { ServerResult } from '../types.js';
import { recursiveFuzzyIndexOf, getSimilarityRatio } from './fuzzySearch.js';
import { capture } from '../utils/capture.js';
import { createErrorResponse } from '../error-handlers.js';
import { EditBlockArgsSchema } from "./schemas.js";
import { EditBlockArgsSchema, ReplaceLinesArgsSchema } from "./schemas.js";
import path from 'path';
import { detectLineEnding, normalizeLineEndings } from '../utils/lineEndingHandler.js';
import { configManager } from '../config-manager.js';
Expand Down Expand Up @@ -457,4 +457,79 @@ export async function handleEditBlock(args: unknown): Promise<ServerResult> {
search: parsed.old_string,
replace: parsed.new_string
}, parsed.expected_replacements);
}
}

/**
* Handle replace_lines command - line-based editing
* Replaces lines startLine..endLine (1-based, inclusive) with newContent
*/
export async function handleReplaceLines(args: unknown): Promise<ServerResult> {
const parsed = ReplaceLinesArgsSchema.parse(args);
const validPath = await validatePath(parsed.path);
const content = await readFileInternal(validPath, 0, Number.MAX_SAFE_INTEGER);

if (typeof content !== 'string') {
return createErrorResponse('Cannot read file as text: ' + parsed.path);
}

const fileLineEnding = detectLineEnding(content);
const lines = content.split(/\r\n|\r|\n/);
const totalLines = lines.length;

if (parsed.startLine > totalLines) {
return createErrorResponse(`startLine ${parsed.startLine} exceeds file length (${totalLines} lines)`);
}
if (parsed.endLine > totalLines) {
return createErrorResponse(`endLine ${parsed.endLine} exceeds file length (${totalLines} lines)`);
}

const before = lines.slice(0, parsed.startLine - 1);
const after = lines.slice(parsed.endLine);
const newLines = parsed.newContent === '' ? [] : parsed.newContent.split(/\r\n|\r|\n/);
const result = [...before, ...newLines, ...after];
const sep = fileLineEnding;
const newContent = result.join(sep);

await writeFile(validPath, newContent);

const removedCount = parsed.endLine - parsed.startLine + 1;
const insertedCount = newLines.length;
const lineDelta = insertedCount - removedCount;

capture('server_replace_lines', {
fileExtension: path.extname(parsed.path).toLowerCase(),
removedLines: removedCount,
insertedLines: insertedCount,
});

// Build response with context around the replacement
const CONTEXT_LINES = 3;
const newTotalLines = result.length;
const insertStart = parsed.startLine;
const insertEnd = insertedCount > 0 ? parsed.startLine + insertedCount - 1 : parsed.startLine - 1;

const ctxStart = Math.max(0, parsed.startLine - 1 - CONTEXT_LINES);
const ctxEnd = Math.min(newTotalLines, (insertedCount > 0 ? insertEnd : parsed.startLine) + CONTEXT_LINES);
const contextSlice = result.slice(ctxStart, ctxEnd);
const contextOutput = contextSlice.map((line, i) => {
const lineNum = ctxStart + i + 1;
const marker = (insertedCount > 0 && lineNum >= insertStart && lineNum <= insertEnd) ? '+' : ' ';
return `${marker} ${String(lineNum).padStart(4)} ${line}`;
}).join('\n');

let msg = `Replaced lines ${parsed.startLine}-${parsed.endLine} (${removedCount} lines) with ${insertedCount} lines in ${parsed.path}`;

if (lineDelta !== 0) {
const shiftAnchor = insertEnd > 0 ? insertEnd : parsed.startLine;
msg += `\n\nWARNING: Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. All line numbers after line ${shiftAnchor} have shifted. Re-read before further edits.`;
}

msg += `\n\nContext (lines ${ctxStart + 1}-${ctxEnd}, + = new content):\n${contextOutput}`;

return {
content: [{
type: "text",
text: msg
}],
};
}
10 changes: 10 additions & 0 deletions src/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ export const GetPromptsArgsSchema = z.object({
// anonymous_user_use_case: z.string().optional(),
});

// Replace lines tool schema - line-based editing (token-efficient alternative to edit_block)
export const ReplaceLinesArgsSchema = z.object({
path: z.string(),
startLine: z.number().int().min(1),
endLine: z.number().int().min(1),
newContent: z.string(),
}).refine(data => data.endLine >= data.startLine, {
message: "endLine must be >= startLine"
});

// Tool history schema
export const GetRecentToolCallsArgsSchema = z.object({
maxResults: z.number().min(1).max(1000).optional().default(50),
Expand Down