diff --git a/packages/core/src/tools/delete_line_range.test.ts b/packages/core/src/tools/delete_line_range.test.ts new file mode 100644 index 000000000..2d881a1bf --- /dev/null +++ b/packages/core/src/tools/delete_line_range.test.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { DeleteLineRangeTool } from './delete_line_range.js'; +import { ToolEditConfirmationDetails } from './tools.js'; +import { ApprovalMode, Config } from '../config/config.js'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; +import { ToolErrorType } from './tool-error.js'; + +const rootDir = path.resolve(os.tmpdir(), 'delete-line-range-test-root'); + +const fsService = new StandardFileSystemService(); +const mockConfigInternal = { + getTargetDir: () => rootDir, + getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), + setApprovalMode: vi.fn(), + getFileSystemService: () => fsService, + getFileService: () => ({ + shouldLlxprtIgnoreFile: () => false, + shouldGitIgnoreFile: () => false, + }), + getIdeClient: vi.fn(), + getIdeMode: vi.fn(() => false), + getWorkspaceContext: () => createMockWorkspaceContext(rootDir), + getDebugMode: () => false, +}; +const mockConfig = mockConfigInternal as unknown as Config; + +describe('DeleteLineRangeTool', () => { + let tool: DeleteLineRangeTool; + let tempDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'delete-line-range-test-external-'), + ); + if (!fs.existsSync(rootDir)) { + fs.mkdirSync(rootDir, { recursive: true }); + } + + mockConfigInternal.getIdeClient.mockReturnValue({ + openDiff: vi.fn(), + closeDiff: vi.fn(), + getIdeContext: vi.fn(), + subscribeToIdeContext: vi.fn(), + isCodeTrackerEnabled: vi.fn(), + getTrackedCode: vi.fn(), + }); + + tool = new DeleteLineRangeTool(mockConfig); + + mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInternal.setApprovalMode.mockClear(); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + if (fs.existsSync(rootDir)) { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + describe('validateToolParams', () => { + it('should return null for valid parameters within workspace', () => { + const params = { + absolute_path: path.join(rootDir, 'test.txt'), + start_line: 1, + end_line: 5, + }; + expect(tool.validateToolParams(params)).toBeNull(); + }); + + it('should return error for relative path', () => { + const params = { absolute_path: 'test.txt', start_line: 1, end_line: 5 }; + expect(tool.validateToolParams(params)).toMatch( + /File path must be absolute/, + ); + }); + + it('should return error for path outside workspace', () => { + const outsidePath = path.resolve(tempDir, 'outside-root.txt'); + const params = { + absolute_path: outsidePath, + start_line: 1, + end_line: 5, + }; + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', + ); + }); + + it('should return error for invalid start_line', () => { + const params = { + absolute_path: path.join(rootDir, 'test.txt'), + start_line: 0, + end_line: 5, + }; + expect(tool.validateToolParams(params)).toMatch(/start_line must be/); + }); + + it('should return error when end_line is less than start_line', () => { + const params = { + absolute_path: path.join(rootDir, 'test.txt'), + start_line: 5, + end_line: 3, + }; + expect(tool.validateToolParams(params)).toMatch( + /end_line must be greater than or equal to start_line/, + ); + }); + }); + + describe('shouldConfirmExecute', () => { + const abortSignal = new AbortController().signal; + + it('should request confirmation with diff in DEFAULT mode', async () => { + const filePath = path.join(rootDir, 'confirm_delete.txt'); + const originalContent = 'line1\nline2\nline3\nline4\nline5'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { absolute_path: filePath, start_line: 2, end_line: 3 }; + const invocation = tool.build(params); + const confirmation = (await invocation.shouldConfirmExecute( + abortSignal, + )) as ToolEditConfirmationDetails; + + expect(confirmation).not.toBe(false); + expect(confirmation.type).toBe('edit'); + expect(confirmation.title).toContain('Delete'); + expect(confirmation.fileDiff).toContain('line2'); + expect(confirmation.fileDiff).toContain('line3'); + }); + + it('should return false (auto-approve) in AUTO_EDIT mode', async () => { + mockConfigInternal.getApprovalMode.mockReturnValue( + ApprovalMode.AUTO_EDIT, + ); + + const filePath = path.join(rootDir, 'auto_edit_delete.txt'); + const originalContent = 'line1\nline2\nline3'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { absolute_path: filePath, start_line: 2, end_line: 2 }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute(abortSignal); + + expect(confirmation).toBe(false); + }); + + it('should return false (auto-approve) in YOLO mode', async () => { + mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.YOLO); + + const filePath = path.join(rootDir, 'yolo_delete.txt'); + const originalContent = 'line1\nline2\nline3'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { absolute_path: filePath, start_line: 1, end_line: 1 }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute(abortSignal); + + expect(confirmation).toBe(false); + }); + + it('should return false when file cannot be read', async () => { + const filePath = path.join(rootDir, 'nonexistent.txt'); + + const params = { absolute_path: filePath, start_line: 1, end_line: 1 }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute(abortSignal); + + expect(confirmation).toBe(false); + }); + }); + + describe('execute', () => { + const abortSignal = new AbortController().signal; + + it('should delete the specified lines', async () => { + const filePath = path.join(rootDir, 'execute_delete.txt'); + const originalContent = 'line1\nline2\nline3\nline4\nline5'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { absolute_path: filePath, start_line: 2, end_line: 3 }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Successfully deleted lines'); + const writtenContent = fs.readFileSync(filePath, 'utf8'); + expect(writtenContent).toBe('line1\nline4\nline5'); + }); + + it('should return error if file does not exist', async () => { + const filePath = path.join(rootDir, 'nonexistent.txt'); + const params = { absolute_path: filePath, start_line: 1, end_line: 1 }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.error?.type).toBe(ToolErrorType.FILE_NOT_FOUND); + }); + + it('should return error if start_line exceeds file length', async () => { + const filePath = path.join(rootDir, 'short_file.txt'); + fs.writeFileSync(filePath, 'line1\nline2', 'utf8'); + + const params = { absolute_path: filePath, start_line: 10, end_line: 12 }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS); + expect(result.llmContent).toContain('start_line'); + }); + }); +}); diff --git a/packages/core/src/tools/delete_line_range.ts b/packages/core/src/tools/delete_line_range.ts index 39cf54a54..29a38e605 100644 --- a/packages/core/src/tools/delete_line_range.ts +++ b/packages/core/src/tools/delete_line_range.ts @@ -5,6 +5,7 @@ */ import path from 'path'; +import * as Diff from 'diff'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { BaseDeclarativeTool, @@ -13,15 +14,20 @@ import { type ToolInvocation, type ToolLocation, type ToolResult, + type ToolCallConfirmationDetails, + type ToolEditConfirmationDetails, + ToolConfirmationOutcome, } from './tools.js'; import { ToolErrorType } from './tool-error.js'; -import { Config } from '../config/config.js'; +import { Config, ApprovalMode } from '../config/config.js'; import { recordFileOperationMetric, FileOperation, } from '../telemetry/metrics.js'; import { getSpecificMimeType } from '../utils/fileUtils.js'; +import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; +import { IDEConnectionStatus } from '../ide/ide-client.js'; /** * Parameters for the DeleteLineRange tool @@ -66,10 +72,84 @@ class DeleteLineRangeToolInvocation extends BaseToolInvocation< return [{ path: this.params.absolute_path, line: this.params.start_line }]; } + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + const approvalMode = this.config.getApprovalMode(); + if ( + approvalMode === ApprovalMode.AUTO_EDIT || + approvalMode === ApprovalMode.YOLO + ) { + return false; + } + + const fileService = this.config.getFileSystemService(); + + let originalContent: string; + try { + originalContent = await fileService.readTextFile( + this.params.absolute_path, + ); + } catch { + return false; + } + + const lines = originalContent.split('\n'); + const totalLines = lines.length; + if (this.params.start_line > totalLines) { + return false; + } + + const startIndex = this.params.start_line - 1; + const count = this.params.end_line - this.params.start_line + 1; + const newLines = [...lines]; + newLines.splice(startIndex, count); + const newContent = newLines.join('\n'); + + const relativePath = makeRelative( + this.params.absolute_path, + this.config.getTargetDir(), + ); + const fileName = path.basename(this.params.absolute_path); + + const fileDiff = Diff.createPatch( + fileName, + originalContent, + newContent, + 'Current', + 'Proposed', + DEFAULT_DIFF_OPTIONS, + ); + + const ideClient = this.config.getIdeClient(); + const ideConfirmation = + this.config.getIdeMode() && + ideClient && + ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected + ? ideClient.openDiff(this.params.absolute_path, newContent) + : undefined; + + const confirmationDetails: ToolEditConfirmationDetails = { + type: 'edit', + title: `Confirm Delete: ${shortenPath(relativePath)} (lines ${this.params.start_line}-${this.params.end_line})`, + fileName, + filePath: this.params.absolute_path, + fileDiff, + originalContent, + newContent, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); + } + }, + ideConfirmation, + }; + return confirmationDetails; + } + async execute(): Promise { const fileService = this.config.getFileSystemService(); - // Read the file content let content: string; try { content = await fileService.readTextFile(this.params.absolute_path); @@ -84,10 +164,8 @@ class DeleteLineRangeToolInvocation extends BaseToolInvocation< }; } - // Split into lines const lines = content.split('\n'); - // Validate line numbers const totalLines = lines.length; if (this.params.start_line > totalLines) { return { @@ -100,21 +178,16 @@ class DeleteLineRangeToolInvocation extends BaseToolInvocation< }; } - // Calculate 0-based indices const startIndex = this.params.start_line - 1; const count = this.params.end_line - this.params.start_line + 1; - // Remove the lines lines.splice(startIndex, count); - // Join back const newContent = lines.join('\n'); - // Write back try { await fileService.writeTextFile(this.params.absolute_path, newContent); - // Record metrics const linesDeleted = count; const mimetype = getSpecificMimeType(this.params.absolute_path); recordFileOperationMetric( diff --git a/packages/core/src/tools/insert_at_line.test.ts b/packages/core/src/tools/insert_at_line.test.ts new file mode 100644 index 000000000..ccf435bdd --- /dev/null +++ b/packages/core/src/tools/insert_at_line.test.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { InsertAtLineTool } from './insert_at_line.js'; +import { ToolEditConfirmationDetails } from './tools.js'; +import { ApprovalMode, Config } from '../config/config.js'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; +import { StandardFileSystemService } from '../services/fileSystemService.js'; +import { ToolErrorType } from './tool-error.js'; + +const rootDir = path.resolve(os.tmpdir(), 'insert-at-line-test-root'); + +const fsService = new StandardFileSystemService(); +const mockConfigInternal = { + getTargetDir: () => rootDir, + getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT), + setApprovalMode: vi.fn(), + getFileSystemService: () => fsService, + getFileService: () => ({ + shouldLlxprtIgnoreFile: () => false, + shouldGitIgnoreFile: () => false, + }), + getIdeClient: vi.fn(), + getIdeMode: vi.fn(() => false), + getWorkspaceContext: () => createMockWorkspaceContext(rootDir), + getDebugMode: () => false, +}; +const mockConfig = mockConfigInternal as unknown as Config; + +describe('InsertAtLineTool', () => { + let tool: InsertAtLineTool; + let tempDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'insert-at-line-test-external-'), + ); + if (!fs.existsSync(rootDir)) { + fs.mkdirSync(rootDir, { recursive: true }); + } + + mockConfigInternal.getIdeClient.mockReturnValue({ + openDiff: vi.fn(), + closeDiff: vi.fn(), + getIdeContext: vi.fn(), + subscribeToIdeContext: vi.fn(), + isCodeTrackerEnabled: vi.fn(), + getTrackedCode: vi.fn(), + }); + + tool = new InsertAtLineTool(mockConfig); + + mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInternal.setApprovalMode.mockClear(); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + if (fs.existsSync(rootDir)) { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + describe('validateToolParams', () => { + it('should return null for valid parameters within workspace', () => { + const params = { + absolute_path: path.join(rootDir, 'test.txt'), + line_number: 1, + content: 'new content', + }; + expect(tool.validateToolParams(params)).toBeNull(); + }); + + it('should return error for relative path', () => { + const params = { + absolute_path: 'test.txt', + line_number: 1, + content: 'new content', + }; + expect(tool.validateToolParams(params)).toMatch( + /File path must be absolute/, + ); + }); + + it('should return error for path outside workspace', () => { + const outsidePath = path.resolve(tempDir, 'outside-root.txt'); + const params = { + absolute_path: outsidePath, + line_number: 1, + content: 'new content', + }; + const error = tool.validateToolParams(params); + expect(error).toContain( + 'File path must be within one of the workspace directories', + ); + }); + + it('should return error for invalid line_number', () => { + const params = { + absolute_path: path.join(rootDir, 'test.txt'), + line_number: 0, + content: 'new content', + }; + expect(tool.validateToolParams(params)).toMatch(/line_number must be/); + }); + + it('should return error when content is empty', () => { + const params = { + absolute_path: path.join(rootDir, 'test.txt'), + line_number: 1, + content: '', + }; + expect(tool.validateToolParams(params)).toMatch( + /content parameter must be/, + ); + }); + }); + + describe('shouldConfirmExecute', () => { + const abortSignal = new AbortController().signal; + + it('should request confirmation with diff in DEFAULT mode', async () => { + const filePath = path.join(rootDir, 'confirm_insert.txt'); + const originalContent = 'line1\nline2\nline3'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { + absolute_path: filePath, + line_number: 2, + content: 'inserted line', + }; + const invocation = tool.build(params); + const confirmation = (await invocation.shouldConfirmExecute( + abortSignal, + )) as ToolEditConfirmationDetails; + + expect(confirmation).not.toBe(false); + expect(confirmation.type).toBe('edit'); + expect(confirmation.title).toContain('Insert'); + expect(confirmation.fileDiff).toContain('inserted line'); + }); + + it('should return false (auto-approve) in AUTO_EDIT mode', async () => { + mockConfigInternal.getApprovalMode.mockReturnValue( + ApprovalMode.AUTO_EDIT, + ); + + const filePath = path.join(rootDir, 'auto_edit_insert.txt'); + const originalContent = 'line1\nline2\nline3'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { + absolute_path: filePath, + line_number: 2, + content: 'inserted line', + }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute(abortSignal); + + expect(confirmation).toBe(false); + }); + + it('should return false (auto-approve) in YOLO mode', async () => { + mockConfigInternal.getApprovalMode.mockReturnValue(ApprovalMode.YOLO); + + const filePath = path.join(rootDir, 'yolo_insert.txt'); + const originalContent = 'line1\nline2\nline3'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { + absolute_path: filePath, + line_number: 1, + content: 'inserted line', + }; + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute(abortSignal); + + expect(confirmation).toBe(false); + }); + + it('should request confirmation for new file creation in DEFAULT mode', async () => { + const filePath = path.join(rootDir, 'new_file.txt'); + + const params = { + absolute_path: filePath, + line_number: 1, + content: 'new file content', + }; + const invocation = tool.build(params); + const confirmation = (await invocation.shouldConfirmExecute( + abortSignal, + )) as ToolEditConfirmationDetails; + + expect(confirmation).not.toBe(false); + expect(confirmation.type).toBe('edit'); + expect(confirmation.fileDiff).toContain('new file content'); + }); + }); + + describe('execute', () => { + const abortSignal = new AbortController().signal; + + it('should insert content at the specified line', async () => { + const filePath = path.join(rootDir, 'execute_insert.txt'); + const originalContent = 'line1\nline2\nline3'; + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const params = { + absolute_path: filePath, + line_number: 2, + content: 'inserted line', + }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Successfully'); + const writtenContent = fs.readFileSync(filePath, 'utf8'); + expect(writtenContent).toBe('line1\ninserted line\nline2\nline3'); + }); + + it('should create a new file when inserting at line 1 in non-existent file', async () => { + const filePath = path.join(rootDir, 'new_file_create.txt'); + + const params = { + absolute_path: filePath, + line_number: 1, + content: 'first line', + }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Successfully'); + expect(fs.existsSync(filePath)).toBe(true); + const writtenContent = fs.readFileSync(filePath, 'utf8'); + expect(writtenContent).toBe('first line\n'); + }); + + it('should return error when inserting at line > 1 in non-existent file', async () => { + const filePath = path.join(rootDir, 'nonexistent.txt'); + + const params = { + absolute_path: filePath, + line_number: 5, + content: 'content', + }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS); + expect(result.llmContent).toContain('file does not exist'); + }); + + it('should return error when line_number exceeds file length + 1', async () => { + const filePath = path.join(rootDir, 'short_file.txt'); + fs.writeFileSync(filePath, 'line1\nline2', 'utf8'); + + const params = { + absolute_path: filePath, + line_number: 10, + content: 'content', + }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.error?.type).toBe(ToolErrorType.INVALID_TOOL_PARAMS); + expect(result.llmContent).toContain('exceeds file length'); + }); + + it('should append content at end of file when line_number equals total lines + 1', async () => { + const filePath = path.join(rootDir, 'append_file.txt'); + fs.writeFileSync(filePath, 'line1\nline2', 'utf8'); + + const params = { + absolute_path: filePath, + line_number: 3, + content: 'appended line', + }; + const invocation = tool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Successfully'); + const writtenContent = fs.readFileSync(filePath, 'utf8'); + expect(writtenContent).toBe('line1\nline2\nappended line'); + }); + }); +}); diff --git a/packages/core/src/tools/insert_at_line.ts b/packages/core/src/tools/insert_at_line.ts index b5edc4e44..85c866e46 100644 --- a/packages/core/src/tools/insert_at_line.ts +++ b/packages/core/src/tools/insert_at_line.ts @@ -5,6 +5,7 @@ */ import path from 'path'; +import * as Diff from 'diff'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { BaseDeclarativeTool, @@ -13,16 +14,21 @@ import { type ToolInvocation, type ToolLocation, type ToolResult, + type ToolCallConfirmationDetails, + type ToolEditConfirmationDetails, + ToolConfirmationOutcome, } from './tools.js'; import { ToolErrorType } from './tool-error.js'; -import { Config } from '../config/config.js'; +import { Config, ApprovalMode } from '../config/config.js'; import { recordFileOperationMetric, FileOperation, } from '../telemetry/metrics.js'; import { getSpecificMimeType } from '../utils/fileUtils.js'; import { isNodeError } from '../utils/errors.js'; +import { DEFAULT_DIFF_OPTIONS } from './diffOptions.js'; +import { IDEConnectionStatus } from '../ide/ide-client.js'; /** * Parameters for the InsertAtLine tool @@ -67,17 +73,101 @@ class InsertAtLineToolInvocation extends BaseToolInvocation< return [{ path: this.params.absolute_path, line: this.params.line_number }]; } + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + const approvalMode = this.config.getApprovalMode(); + if ( + approvalMode === ApprovalMode.AUTO_EDIT || + approvalMode === ApprovalMode.YOLO + ) { + return false; + } + + const fileService = this.config.getFileSystemService(); + + let originalContent = ''; + let fileExists = true; + try { + originalContent = await fileService.readTextFile( + this.params.absolute_path, + ); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + fileExists = false; + originalContent = ''; + } else { + return false; + } + } + + const lines = originalContent.split('\n'); + const totalLines = lines.length; + + if (fileExists && this.params.line_number > totalLines + 1) { + return false; + } + + if (!fileExists && this.params.line_number !== 1) { + return false; + } + + const insertIndex = this.params.line_number - 1; + const newLines = this.params.content.split('\n'); + const resultLines = [...lines]; + resultLines.splice(insertIndex, 0, ...newLines); + const newContent = resultLines.join('\n'); + + const relativePath = makeRelative( + this.params.absolute_path, + this.config.getTargetDir(), + ); + const fileName = path.basename(this.params.absolute_path); + + const fileDiff = Diff.createPatch( + fileName, + originalContent, + newContent, + 'Current', + 'Proposed', + DEFAULT_DIFF_OPTIONS, + ); + + const ideClient = this.config.getIdeClient(); + const ideConfirmation = + this.config.getIdeMode() && + ideClient && + ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected + ? ideClient.openDiff(this.params.absolute_path, newContent) + : undefined; + + const confirmationDetails: ToolEditConfirmationDetails = { + type: 'edit', + title: `Confirm Insert: ${shortenPath(relativePath)} (at line ${this.params.line_number})`, + fileName, + filePath: this.params.absolute_path, + fileDiff, + originalContent, + newContent, + onConfirm: async (outcome: ToolConfirmationOutcome) => { + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); + } + }, + ideConfirmation, + }; + return confirmationDetails; + } + async execute(): Promise { const fileService = this.config.getFileSystemService(); - // Read the file content let content: string; let fileExists = true; try { content = await fileService.readTextFile(this.params.absolute_path); } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { - // File doesn't exist - only allow insertion at line 1 if (this.params.line_number !== 1) { return { llmContent: `Cannot insert at line ${this.params.line_number}: file does not exist. For new files, you can only insert at line_number: 1`, @@ -102,10 +192,8 @@ class InsertAtLineToolInvocation extends BaseToolInvocation< } } - // Split into lines const lines = content.split('\n'); - // Validate line number const totalLines = lines.length; if (fileExists && this.params.line_number > totalLines + 1) { return { @@ -118,23 +206,17 @@ class InsertAtLineToolInvocation extends BaseToolInvocation< }; } - // Calculate 0-based insertion index const insertIndex = this.params.line_number - 1; - // Split new content into lines const newLines = this.params.content.split('\n'); - // Insert the lines lines.splice(insertIndex, 0, ...newLines); - // Join back const newContent = lines.join('\n'); - // Write back try { await fileService.writeTextFile(this.params.absolute_path, newContent); - // Record metrics const linesInserted = newLines.length; const mimetype = getSpecificMimeType(this.params.absolute_path); recordFileOperationMetric( diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 9475fae15..23aaa74f5 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -34,11 +34,11 @@ export interface LSToolParams { ignore?: string[]; /** - * Whether to respect .gitignore and .geminiignore patterns (optional, defaults to true) + * Whether to respect .gitignore and .llxprtignore patterns (optional, defaults to true) */ file_filtering_options?: { respect_git_ignore?: boolean; - respect_gemini_ignore?: boolean; + respect_llxprt_ignore?: boolean; }; } @@ -167,7 +167,7 @@ class LSToolInvocation extends BaseToolInvocation { this.params.file_filtering_options?.respect_git_ignore ?? defaultFileIgnores.respectGitIgnore, respectLlxprtIgnore: - this.params.file_filtering_options?.respect_gemini_ignore ?? + this.params.file_filtering_options?.respect_llxprt_ignore ?? defaultFileIgnores.respectLlxprtIgnore, }; @@ -177,7 +177,7 @@ class LSToolInvocation extends BaseToolInvocation { const entries: FileEntry[] = []; let gitIgnoredCount = 0; - let geminiIgnoredCount = 0; + let llxprtIgnoredCount = 0; if (files.length === 0) { // Changed error message to be more neutral for LLM @@ -198,7 +198,6 @@ class LSToolInvocation extends BaseToolInvocation { fullPath, ); - // Check if this file should be ignored based on git or gemini ignore rules if ( fileFilteringOptions.respectGitIgnore && fileDiscovery.shouldGitIgnoreFile(relativePath) @@ -210,7 +209,7 @@ class LSToolInvocation extends BaseToolInvocation { fileFilteringOptions.respectLlxprtIgnore && fileDiscovery.shouldLlxprtIgnoreFile(relativePath) ) { - geminiIgnoredCount++; + llxprtIgnoredCount++; continue; } @@ -247,8 +246,8 @@ class LSToolInvocation extends BaseToolInvocation { if (gitIgnoredCount > 0) { ignoredMessages.push(`${gitIgnoredCount} git-ignored`); } - if (geminiIgnoredCount > 0) { - ignoredMessages.push(`${geminiIgnoredCount} llxprt-ignored`); + if (llxprtIgnoredCount > 0) { + ignoredMessages.push(`${llxprtIgnoredCount} llxprt-ignored`); } if (ignoredMessages.length > 0) { @@ -306,7 +305,7 @@ export class LSTool extends BaseDeclarativeTool { }, file_filtering_options: { description: - 'Optional: Whether to respect ignore patterns from .gitignore or .geminiignore', + 'Optional: Whether to respect ignore patterns from .gitignore or .llxprtignore', type: 'object', properties: { respect_git_ignore: { @@ -314,9 +313,9 @@ export class LSTool extends BaseDeclarativeTool { 'Optional: Whether to respect .gitignore patterns when listing files. Only available in git repositories. Defaults to true.', type: 'boolean', }, - respect_gemini_ignore: { + respect_llxprt_ignore: { description: - 'Optional: Whether to respect .geminiignore patterns when listing files. Defaults to true.', + 'Optional: Whether to respect .llxprtignore patterns when listing files. Defaults to true.', type: 'boolean', }, }, diff --git a/scripts/start.js b/scripts/start.js index b8a635f20..1ab306a4f 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -28,6 +28,14 @@ const root = join(__dirname, '..'); const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')); const bootstrapSnapshot = parseBootstrapArgs(); +function sanitizeNodeOptions(nodeOptions) { + if (!nodeOptions) return nodeOptions; + return nodeOptions + .replace(/\s*--localstorage-file(?:(?:\s*=\s*|\s+)\S+)?/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + // check build status, write warnings to file for app to display if needed execSync('node ./scripts/check-build-status.js', { stdio: 'inherit', @@ -69,9 +77,22 @@ if (experimentalUi) { const filteredArgs = args.filter((a) => a !== '--experimental-ui'); uiArgs.push(...filteredArgs); + const sanitizedNodeOptionsUi = sanitizeNodeOptions(process.env.NODE_OPTIONS); + const uiEnv = { + ...process.env, + CLI_VERSION: pkg.version, + DEV: 'true', + }; + if (sanitizedNodeOptionsUi !== process.env.NODE_OPTIONS) { + if (sanitizedNodeOptionsUi) { + uiEnv.NODE_OPTIONS = sanitizedNodeOptionsUi; + } else { + delete uiEnv.NODE_OPTIONS; + } + } const uiChild = spawn('bun', uiArgs, { stdio: 'inherit', - env: { ...process.env, CLI_VERSION: pkg.version, DEV: 'true' }, + env: uiEnv, cwd: join(root, 'packages/ui'), }); @@ -83,11 +104,19 @@ if (experimentalUi) { nodeArgs.push('./packages/cli'); nodeArgs.push(...args); + const sanitizedNodeOptions = sanitizeNodeOptions(process.env.NODE_OPTIONS); const env = { ...process.env, CLI_VERSION: pkg.version, DEV: 'true', }; + if (sanitizedNodeOptions !== process.env.NODE_OPTIONS) { + if (sanitizedNodeOptions) { + env.NODE_OPTIONS = sanitizedNodeOptions; + } else { + delete env.NODE_OPTIONS; + } + } if (bootstrapSnapshot.bootstrapArgs.profileName) { env.LLXPRT_BOOTSTRAP_PROFILE = bootstrapSnapshot.bootstrapArgs.profileName;