@nanocollective/nanocoder
Version:
A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter
461 lines • 24.6 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { constants } from 'node:fs';
import { access, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { highlight } from 'cli-highlight';
import { Box, Text } from 'ink';
import React from 'react';
import stripAnsi from 'strip-ansi';
import ToolMessage from '../../components/tool-message.js';
import { getColors } from '../../config/index.js';
import { isNanocoderToolAlwaysAllowed } from '../../config/nanocoder-tools-config.js';
import { DEFAULT_TERMINAL_COLUMNS } from '../../constants.js';
import { getCurrentMode } from '../../context/mode-context.js';
import { jsonSchema, tool } from '../../types/core.js';
import { getCachedFileContent, invalidateCache } from '../../utils/file-cache.js';
import { normalizeIndentation } from '../../utils/indentation-normalizer.js';
import { areLinesSimlar, computeInlineDiff } from '../../utils/inline-diff.js';
import { isValidFilePath, resolveFilePath } from '../../utils/path-validation.js';
import { getLanguageFromExtension } from '../../utils/programming-language-helper.js';
import { closeDiffInVSCode, isVSCodeConnected, sendFileChangeToVSCode, } from '../../vscode/index.js';
const executeStringReplace = async (args) => {
const { path, old_str, new_str } = args;
// Validate old_str is not empty
if (!old_str || old_str.length === 0) {
throw new Error('old_str cannot be empty. Provide the exact content to find and replace.');
}
const absPath = resolve(path);
const cached = await getCachedFileContent(absPath);
const fileContent = cached.content;
// Count occurrences of old_str
const occurrences = fileContent.split(old_str).length - 1;
if (occurrences === 0) {
throw new Error(`Content not found in file. The file may have changed since you last read it.\n`);
}
if (occurrences > 1) {
throw new Error(`Found ${occurrences} matches for the search string. Please provide more surrounding context to make the match unique\n`);
}
// Perform the replacement
const newContent = fileContent.replace(old_str, new_str);
// Write updated content
await writeFile(absPath, newContent, 'utf-8');
// Invalidate cache after write
invalidateCache(absPath);
// Calculate line numbers where change occurred
const beforeLines = fileContent.split('\n');
const oldStrLines = old_str.split('\n');
const newStrLines = new_str.split('\n');
// Find the line where the change started
let startLine = 0;
let searchIndex = 0;
for (let i = 0; i < beforeLines.length; i++) {
const lineWithNewline = beforeLines[i] + (i < beforeLines.length - 1 ? '\n' : '');
if (fileContent.indexOf(old_str, searchIndex) === searchIndex) {
startLine = i + 1;
break;
}
searchIndex += lineWithNewline.length;
}
const endLine = startLine + oldStrLines.length - 1;
const newEndLine = startLine + newStrLines.length - 1;
// Generate full file contents to show the model the current file state
const newLines = newContent.split('\n');
let fileContext = '\n\nUpdated file contents:\n';
for (let i = 0; i < newLines.length; i++) {
const lineNumStr = String(i + 1).padStart(4, ' ');
const line = newLines[i] || '';
fileContext += `${lineNumStr}: ${line}\n`;
}
const rangeDesc = startLine === endLine
? `line ${startLine}`
: `lines ${startLine}-${endLine}`;
const newRangeDesc = startLine === newEndLine
? `line ${startLine}`
: `lines ${startLine}-${newEndLine}`;
return `Successfully replaced content at ${rangeDesc} (now ${newRangeDesc}).${fileContext}`;
};
const stringReplaceCoreTool = tool({
description: 'Replace exact string content in a file. IMPORTANT: Provide exact content including whitespace and surrounding context. For unique matching, include 2-3 lines before/after the change. Break large changes into multiple small replacements.',
inputSchema: jsonSchema({
type: 'object',
properties: {
path: {
type: 'string',
description: 'The path to the file to edit.',
},
old_str: {
type: 'string',
description: 'The EXACT string to find and replace, including all whitespace, newlines, and indentation. Must match exactly. Include surrounding context (2-3 lines) to ensure unique match.',
},
new_str: {
type: 'string',
description: 'The replacement string. Can be empty to delete content. Must preserve proper indentation and formatting.',
},
},
required: ['path', 'old_str', 'new_str'],
}),
// Medium risk: file write operation, requires approval except in auto-accept mode or if configured in nanocoderTools.alwaysAllow
needsApproval: () => {
// Check if this tool is configured to always be allowed
if (isNanocoderToolAlwaysAllowed('string_replace')) {
return false;
}
const mode = getCurrentMode();
return mode !== 'auto-accept' && mode !== 'scheduler';
},
execute: async (args, _options) => {
return await executeStringReplace(args);
},
});
const StringReplaceFormatter = React.memo(({ preview }) => {
return preview;
});
// Truncate a line to fit terminal width
const truncateLine = (line, maxWidth) => {
if (line.length <= maxWidth)
return line;
return line.slice(0, maxWidth - 1) + '…';
};
// Truncate a string with ANSI codes to fit terminal width (visual chars)
const truncateAnsi = (str, maxWidth) => {
const plainText = stripAnsi(str);
if (plainText.length <= maxWidth)
return str;
let visibleCount = 0;
const ansiRegex = /\x1b\[[0-9;]*m/g;
let result = '';
let lastIndex = 0;
let match;
while ((match = ansiRegex.exec(str)) !== null) {
const textBefore = str.slice(lastIndex, match.index);
for (const char of textBefore) {
if (visibleCount >= maxWidth - 1)
break;
result += char;
visibleCount++;
}
if (visibleCount >= maxWidth - 1)
break;
result += match[0];
lastIndex = match.index + match[0].length;
}
if (visibleCount < maxWidth - 1) {
const remaining = str.slice(lastIndex);
for (const char of remaining) {
if (visibleCount >= maxWidth - 1)
break;
result += char;
visibleCount++;
}
}
return result + '\x1b[0m…';
};
async function formatStringReplacePreview(args, result, colors) {
const themeColors = colors || getColors();
const { path, old_str, new_str } = args;
// Calculate available width for line content (terminal width - line number prefix - diff marker - padding)
const terminalWidth = process.stdout.columns || DEFAULT_TERMINAL_COLUMNS;
const lineNumPrefixWidth = 8; // "1234 + " = 7 chars + 1 for safety
const availableWidth = Math.max(terminalWidth - lineNumPrefixWidth - 2, 20);
const isResult = result !== undefined;
try {
const absPath = resolve(path);
const cached = await getCachedFileContent(absPath);
const fileContent = cached.content;
const ext = path.split('.').pop()?.toLowerCase() ?? '';
const language = getLanguageFromExtension(ext);
// In result mode, skip validation since file has already been modified
// Preview mode - validate old_str exists and is unique
if (!isResult) {
const occurrences = fileContent.split(old_str).length - 1;
if (occurrences === 0) {
const errorContent = (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: themeColors.tool, children: "\u2692 string_replace" }), _jsxs(Box, { children: [_jsx(Text, { color: themeColors.secondary, children: "Path: " }), _jsx(Text, { color: themeColors.primary, children: path })] }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsx(Text, { color: themeColors.error, children: "\u2717 Error: Content not found in file. The file may have changed since you last read it." }) })] }));
return _jsx(ToolMessage, { message: errorContent, hideBox: true });
}
if (occurrences > 1) {
const errorContent = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: themeColors.tool, children: "\u2692 string_replace" }), _jsxs(Box, { children: [_jsx(Text, { color: themeColors.secondary, children: "Path: " }), _jsx(Text, { color: themeColors.primary, children: path })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: themeColors.error, children: ["\u2717 Error: Found ", occurrences, " matches"] }), _jsx(Text, { color: themeColors.secondary, children: "Add more surrounding context to make the match unique." })] })] }));
return _jsx(ToolMessage, { message: errorContent, hideBox: true });
}
}
// Find location of the match in the file
// In result mode, old_str no longer exists - find new_str instead
const searchStr = isResult ? new_str : old_str;
const matchIndex = fileContent.indexOf(searchStr);
const beforeContent = fileContent.substring(0, matchIndex);
const beforeLines = beforeContent.split('\n');
const startLine = beforeLines.length;
const oldStrLines = old_str.split('\n');
const newStrLines = new_str.split('\n');
// In result mode, the file contains new_str, so use its length for endLine
const contentLines = isResult ? newStrLines : oldStrLines;
const endLine = startLine + contentLines.length - 1;
const allLines = fileContent.split('\n');
const contextLines = 3;
const showStart = Math.max(0, startLine - 1 - contextLines);
const showEnd = Math.min(allLines.length - 1, endLine - 1 + contextLines);
// Collect all lines to be displayed for normalization
const linesToNormalize = [];
// Context before - always from file
for (let i = showStart; i < startLine - 1; i++) {
linesToNormalize.push(allLines[i] || '');
}
// Old lines - always from old_str (not in file after execution)
for (let i = 0; i < oldStrLines.length; i++) {
linesToNormalize.push(oldStrLines[i] || '');
}
// New lines - in result mode, read from file; in preview mode, use new_str
if (isResult) {
for (let i = 0; i < newStrLines.length; i++) {
linesToNormalize.push(allLines[startLine - 1 + i] || '');
}
}
else {
for (let i = 0; i < newStrLines.length; i++) {
linesToNormalize.push(newStrLines[i] || '');
}
}
// Context after - in result mode, start after new content
const contextAfterStart = isResult
? startLine - 1 + newStrLines.length
: endLine;
for (let i = contextAfterStart; i <= showEnd; i++) {
linesToNormalize.push(allLines[i] || '');
}
// Normalize indentation
const normalizedLines = normalizeIndentation(linesToNormalize);
// Split normalized lines back into sections
let lineIndex = 0;
const contextBeforeCount = startLine - 1 - showStart;
const normalizedContextBefore = normalizedLines.slice(lineIndex, lineIndex + contextBeforeCount);
lineIndex += contextBeforeCount;
const normalizedOldLines = normalizedLines.slice(lineIndex, lineIndex + oldStrLines.length);
lineIndex += oldStrLines.length;
const normalizedNewLines = normalizedLines.slice(lineIndex, lineIndex + newStrLines.length);
lineIndex += newStrLines.length;
const normalizedContextAfter = normalizedLines.slice(lineIndex);
const contextBefore = [];
const diffLines = [];
const contextAfter = [];
// Show context before
for (let i = 0; i < normalizedContextBefore.length; i++) {
const actualLineNum = showStart + i;
const lineNumStr = String(actualLineNum + 1).padStart(4, ' ');
const line = normalizedContextBefore[i] || '';
let displayLine;
try {
displayLine = truncateAnsi(highlight(line, { language, theme: 'default' }), availableWidth);
}
catch {
displayLine = truncateLine(line, availableWidth);
}
contextBefore.push(_jsxs(Box, { children: [_jsxs(Text, { color: themeColors.secondary, children: [lineNumStr, " "] }), _jsx(Text, { wrap: "truncate-end", children: displayLine })] }, `before-${i}`));
}
// Build unified diff - only show lines that actually changed
let oldIdx = 0;
let newIdx = 0;
let diffKey = 0;
while (oldIdx < normalizedOldLines.length ||
newIdx < normalizedNewLines.length) {
const oldLine = oldIdx < normalizedOldLines.length ? normalizedOldLines[oldIdx] : null;
const newLine = newIdx < normalizedNewLines.length ? normalizedNewLines[newIdx] : null;
// Check if lines are identical - show as unchanged context
if (oldLine !== null && newLine !== null && oldLine === newLine) {
const lineNumStr = String(startLine + oldIdx).padStart(4, ' ');
const truncatedLine = truncateLine(oldLine, availableWidth);
diffLines.push(_jsxs(Box, { children: [_jsxs(Text, { color: themeColors.secondary, children: [lineNumStr, " "] }), _jsx(Text, { wrap: "truncate-end", children: truncatedLine })] }, `diff-${diffKey++}`));
oldIdx++;
newIdx++;
}
else if (oldLine !== null &&
newLine !== null &&
areLinesSimlar(oldLine, newLine)) {
// Lines are similar but different - show inline diff with word-level highlighting
// Truncate lines before computing diff for display
const truncatedOldLine = truncateLine(oldLine, availableWidth);
const truncatedNewLine = truncateLine(newLine, availableWidth);
const segments = computeInlineDiff(truncatedOldLine, truncatedNewLine);
const lineNumStr = String(startLine + oldIdx).padStart(4, ' ');
// Render removed line with inline highlights
const oldParts = [];
for (let s = 0; s < segments.length; s++) {
const seg = segments[s];
if (seg.type === 'unchanged' || seg.type === 'removed') {
oldParts.push(_jsx(Text, { bold: seg.type === 'removed', underline: seg.type === 'removed', children: seg.text }, `old-seg-${s}`));
}
}
diffLines.push(_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: themeColors.diffRemoved, color: themeColors.diffRemovedText, children: [lineNumStr, " -"] }), _jsx(Text, { wrap: "truncate-end", backgroundColor: themeColors.diffRemoved, color: themeColors.diffRemovedText, children: oldParts })] }, `diff-${diffKey++}`));
// Render added line with inline highlights
const newParts = [];
for (let s = 0; s < segments.length; s++) {
const seg = segments[s];
if (seg.type === 'unchanged' || seg.type === 'added') {
newParts.push(_jsx(Text, { bold: seg.type === 'added', underline: seg.type === 'added', children: seg.text }, `new-seg-${s}`));
}
}
diffLines.push(_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: themeColors.diffAdded, color: themeColors.diffAddedText, children: [lineNumStr, " +"] }), _jsx(Text, { wrap: "truncate-end", backgroundColor: themeColors.diffAdded, color: themeColors.diffAddedText, children: newParts })] }, `diff-${diffKey++}`));
oldIdx++;
newIdx++;
}
else if (oldLine !== null) {
// Show removed line
const lineNumStr = String(startLine + oldIdx).padStart(4, ' ');
const truncatedLine = truncateLine(oldLine, availableWidth);
diffLines.push(_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: themeColors.diffRemoved, color: themeColors.diffRemovedText, children: [lineNumStr, " -"] }), _jsx(Text, { wrap: "truncate-end", backgroundColor: themeColors.diffRemoved, color: themeColors.diffRemovedText, children: truncatedLine })] }, `diff-${diffKey++}`));
oldIdx++;
}
else if (newLine !== null) {
// Show added line
const lineNumStr = String(startLine + newIdx).padStart(4, ' ');
const truncatedLine = truncateLine(newLine, availableWidth);
diffLines.push(_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: themeColors.diffAdded, color: themeColors.diffAddedText, children: [lineNumStr, " +"] }), _jsx(Text, { wrap: "truncate-end", backgroundColor: themeColors.diffAdded, color: themeColors.diffAddedText, children: truncatedLine })] }, `diff-${diffKey++}`));
newIdx++;
}
}
// Show context after
const lineDelta = newStrLines.length - oldStrLines.length;
for (let i = 0; i < normalizedContextAfter.length; i++) {
const actualLineNum = endLine + i;
const lineNumStr = String(actualLineNum + lineDelta + 1).padStart(4, ' ');
const line = normalizedContextAfter[i] || '';
let displayLine;
try {
displayLine = truncateAnsi(highlight(line, { language, theme: 'default' }), availableWidth);
}
catch {
displayLine = truncateLine(line, availableWidth);
}
contextAfter.push(_jsxs(Box, { children: [_jsxs(Text, { color: themeColors.secondary, children: [lineNumStr, " "] }), _jsx(Text, { wrap: "truncate-end", children: displayLine })] }, `after-${i}`));
}
const rangeDesc = startLine === endLine
? `line ${startLine}`
: `lines ${startLine}-${endLine}`;
const messageContent = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: themeColors.tool, children: "\u2692 string_replace" }), _jsxs(Box, { children: [_jsx(Text, { color: themeColors.secondary, children: "Path: " }), _jsx(Text, { color: themeColors.primary, children: path })] }), _jsxs(Box, { children: [_jsx(Text, { color: themeColors.secondary, children: "Location: " }), _jsx(Text, { color: themeColors.text, children: rangeDesc })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsxs(Text, { color: themeColors.success, children: [isResult ? '✓ Replace completed' : '✓ Replacing', ' ', oldStrLines.length, " line", oldStrLines.length > 1 ? 's' : '', " with", ' ', newStrLines.length, " line", newStrLines.length > 1 ? 's' : ''] }), _jsxs(Box, { flexDirection: "column", children: [contextBefore, diffLines, contextAfter] })] })] }));
return _jsx(ToolMessage, { message: messageContent, hideBox: true });
}
catch (error) {
const errorContent = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: themeColors.tool, children: "\u2692 string_replace" }), _jsxs(Box, { children: [_jsx(Text, { color: themeColors.secondary, children: "Path: " }), _jsx(Text, { color: themeColors.primary, children: path })] }), _jsxs(Box, { children: [_jsx(Text, { color: themeColors.error, children: "Error: " }), _jsx(Text, { color: themeColors.error, children: error instanceof Error ? error.message : String(error) })] })] }));
return _jsx(ToolMessage, { message: errorContent, hideBox: true });
}
}
// Track VS Code change IDs for cleanup
const vscodeChangeIds = new Map();
const stringReplaceFormatter = async (args, result) => {
const colors = getColors();
const { path, old_str, new_str } = args;
const absPath = resolve(path);
// Send diff to VS Code during preview phase (before execution)
if (result === undefined && isVSCodeConnected()) {
try {
const cached = await getCachedFileContent(absPath);
const fileContent = cached.content;
// Only send if we can find a unique match
const occurrences = fileContent.split(old_str).length - 1;
if (occurrences === 1) {
const newContent = fileContent.replace(old_str, new_str);
const changeId = sendFileChangeToVSCode(absPath, fileContent, newContent, 'string_replace', {
path,
old_str,
new_str,
});
if (changeId) {
vscodeChangeIds.set(absPath, changeId);
}
}
}
catch {
// Silently ignore errors sending to VS Code
}
}
else if (result !== undefined && isVSCodeConnected()) {
// Tool was executed (confirmed or rejected), close the diff
const changeId = vscodeChangeIds.get(absPath);
if (changeId) {
closeDiffInVSCode(changeId);
vscodeChangeIds.delete(absPath);
}
}
const preview = await formatStringReplacePreview(args, result, colors);
return _jsx(StringReplaceFormatter, { preview: preview });
};
const stringReplaceValidator = async (args) => {
const { path, old_str } = args;
// Validate path boundary first to prevent directory traversal
if (!isValidFilePath(path)) {
return {
valid: false,
error: `⚒ Invalid file path: "${path}". Path must be relative and within the project directory.`,
};
}
// Verify the resolved path stays within project boundaries
try {
const cwd = process.cwd();
resolveFilePath(path, cwd);
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
valid: false,
error: `⚒ Path validation failed: ${errorMessage}`,
};
}
// Check if file exists
const absPath = resolve(path);
try {
await access(absPath, constants.F_OK);
}
catch (error) {
if (error && typeof error === 'object' && 'code' in error) {
if (error.code === 'ENOENT') {
return {
valid: false,
error: `⚒ File "${path}" does not exist`,
};
}
}
const errorMessage = error instanceof Error ? error.message : String(error);
return {
valid: false,
error: `⚒ Cannot access file "${path}": ${errorMessage}`,
};
}
// Validate old_str is not empty
if (!old_str || old_str.length === 0) {
return {
valid: false,
error: '⚒ old_str cannot be empty. Provide the exact content to find and replace.',
};
}
// Check if content exists in file and is unique
try {
const cached = await getCachedFileContent(absPath);
const fileContent = cached.content;
const occurrences = fileContent.split(old_str).length - 1;
if (occurrences === 0) {
return {
valid: false,
error: `⚒ Content not found in file. The file may have changed since you last read it. Suggestion: Read the file again to see current contents.`,
};
}
if (occurrences > 1) {
return {
valid: false,
error: `⚒ Found ${occurrences} matches for the search string. Please provide more surrounding context to make the match unique.`,
};
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
valid: false,
error: `⚒ Error reading file "${path}": ${errorMessage}`,
};
}
return { valid: true };
};
export const stringReplaceTool = {
name: 'string_replace',
tool: stringReplaceCoreTool,
formatter: stringReplaceFormatter,
validator: stringReplaceValidator,
};
//# sourceMappingURL=string-replace.js.map