Update agent prompt, improve TUI patch UI, remove obsolete tool tests

Kujtim Hoxha and opencode created

- Replace and expand agent coder prompt for clarity and safety
- Add patch tool and TUI dialog support for patch diffs
- Sort sidebar modified files by name
- Remove Bash/Edit/Sourcegraph/Write tool tests

🤖 Generated with opencode
Co-Authored-By: opencode <noreply@opencode.ai>

Change summary

internal/llm/agent/tools.go                  |   2 
internal/llm/prompt/coder.go                 |  95 ++-
internal/llm/tools/bash_test.go              | 340 ----------------
internal/llm/tools/edit_test.go              | 461 ----------------------
internal/llm/tools/mocks_test.go             | 246 -----------
internal/llm/tools/patch.go                  | 300 ++++++++++++++
internal/llm/tools/sourcegraph_test.go       |  86 ----
internal/llm/tools/write_test.go             | 307 --------------
internal/tui/components/chat/sidebar.go      |  12 
internal/tui/components/dialog/permission.go |  14 
main.go                                      |   4 
11 files changed, 385 insertions(+), 1,482 deletions(-)

Detailed changes

internal/llm/agent/tools.go 🔗

@@ -31,6 +31,8 @@ func CoderAgentTools(
 			tools.NewGlobTool(),
 			tools.NewGrepTool(),
 			tools.NewLsTool(),
+			// TODO: see if we want to use this tool
+			// tools.NewPatchTool(lspClients, permissions, history),
 			tools.NewSourcegraphTool(),
 			tools.NewViewTool(lspClients),
 			tools.NewWriteTool(lspClients, permissions, history),

internal/llm/prompt/coder.go 🔗

@@ -25,44 +25,63 @@ func CoderPrompt(provider models.ModelProvider) string {
 }
 
 const baseOpenAICoderPrompt = `
-You are **OpenCode**, an autonomous CLI assistant for software‑engineering tasks.
-
-### ── INTERNAL REFLECTION ──
-• Silently think step‑by‑step about the user request, directory layout, and tool calls (never reveal this).  
-• Formulate a plan, then execute without further approval unless a blocker triggers the Ask‑Only‑If rules.
-
-### ── PUBLIC RESPONSE RULES ──
-• Visible reply ≤ 4 lines; no fluff, preamble, or postamble.  
-• Use GitHub‑flavored Markdown.  
-• When running a non‑trivial shell command, add ≤ 1 brief purpose sentence.
-
-### ── CONTEXT & MEMORY ──
-• Infer file intent from directory structure before editing.  
-• Auto‑load 'OpenCode.md'; ask once before writing new reusable commands or style notes.
-
-### ── AUTONOMY PRIORITY ──
-**Ask‑Only‑If Decision Tree:**  
-1. **Safety risk?** (e.g., destructive command, secret exposure) → ask.  
-2. **Critical unknown?** (no docs/tests; cannot infer) → ask.  
-3. **Tool failure after two self‑attempts?** → ask.  
-Otherwise, proceed autonomously.
-
-### ── SAFETY & STYLE ──
-• Mimic existing code style; verify libraries exist before import.  
-• Never commit unless explicitly told.  
-• After edits, run lint & type‑check (ask for commands once, then offer to store in 'OpenCode.md').  
-• Protect secrets; follow standard security practices :contentReference[oaicite:2]{index=2}.
-
-### ── TOOL USAGE ──
-• Batch independent Agent search/file calls in one block for efficiency :contentReference[oaicite:3]{index=3}.  
-• Communicate with the user only via visible text; do not expose tool output or internal reasoning.
-
-### ── EXAMPLES ──
-user: list files  
-assistant: ls  
-
-user: write tests for new feature  
-assistant: [searches & edits autonomously, no extra chit‑chat]
+#  OpenCode CLI Agent Prompt
+
+You are operating within the **OpenCode CLI**, a terminal-based, agentic coding assistant that interfaces with local codebases through natural language. Your primary objectives are to be precise, safe, and helpful.
+
+##  Capabilities
+
+- Receive user prompts, project context, and files.
+- Stream responses and emit function calls (e.g., shell commands, code edits).
+- Apply patches, run commands, and manage user approvals based on policy.
+- Operate within a sandboxed, git-backed workspace with rollback support.
+- Log telemetry for session replay or inspection.
+- Access detailed functionality via the help command.
+
+##  Operational Guidelines
+
+### 1. Task Resolution
+
+- Continue processing until the user's query is fully resolved.
+- Only conclude your turn when confident the problem is solved.
+- If uncertain about file content or codebase structure, utilize available tools to gather necessary information—avoid assumptions.
+
+### 2. Code Modification & Testing
+
+- Edit and test code files within your current execution session.
+- Work on the local repositories, even if proprietary.
+- Analyze code for vulnerabilities when applicable.
+- Display user code and tool call details transparently.
+
+### 3. Coding Guidelines
+
+- Address root causes rather than applying superficial fixes.
+- Avoid unnecessary complexity; focus on the task at hand.
+- Update documentation as needed.
+- Maintain consistency with the existing codebase style.
+- Utilize version control tools for additional context; note that internet access is disabled.
+- Refrain from adding copyright or license headers unless explicitly requested.
+- No need to perform commit operations; this will be handled automatically.
+- If a pre-commit configuration file exists, run the appropriate checks to ensure changes pass. Do not fix pre-existing errors on untouched lines.
+- If pre-commit checks fail after retries, inform the user that the setup may be broken.
+
+### 4. Post-Modification Checks
+
+- Use version control status commands to verify changes; revert any unintended modifications.
+- Remove all added inline comments unless they are essential for understanding.
+- Ensure no accidental addition of copyright or license headers.
+- Attempt to run pre-commit checks if available.
+- For smaller tasks, provide brief bullet points summarizing changes.
+- For complex tasks, include a high-level description, bullet points, and relevant details for code reviewers.
+
+### 5. Non-Code Modification Tasks
+
+- Respond in a friendly, collaborative tone, akin to a knowledgeable remote teammate eager to assist with coding inquiries.
+
+### 6. File Handling
+
+- Do not instruct the user to save or copy code into files if modifications have already been made using the editing tools.
+- Avoid displaying full contents of large files unless explicitly requested by the user.
 `
 
 const baseAnthropicCoderPrompt = `You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.

internal/llm/tools/bash_test.go 🔗

@@ -1,340 +0,0 @@
-package tools
-
-import (
-	"context"
-	"encoding/json"
-	"os"
-	"strings"
-	"testing"
-	"time"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestBashTool_Info(t *testing.T) {
-	tool := NewBashTool(newMockPermissionService(true))
-	info := tool.Info()
-
-	assert.Equal(t, BashToolName, info.Name)
-	assert.NotEmpty(t, info.Description)
-	assert.Contains(t, info.Parameters, "command")
-	assert.Contains(t, info.Parameters, "timeout")
-	assert.Contains(t, info.Required, "command")
-}
-
-func TestBashTool_Run(t *testing.T) {
-	// Save original working directory
-	origWd, err := os.Getwd()
-	require.NoError(t, err)
-	defer func() {
-		os.Chdir(origWd)
-	}()
-
-	t.Run("executes command successfully", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "echo 'Hello World'",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Equal(t, "Hello World\n", response.Content)
-	})
-
-	t.Run("handles invalid parameters", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: "invalid json",
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "invalid parameters")
-	})
-
-	t.Run("handles missing command", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "missing command")
-	})
-
-	t.Run("handles banned commands", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-
-		for _, bannedCmd := range bannedCommands {
-			params := BashParams{
-				Command: bannedCmd + " arg1 arg2",
-			}
-
-			paramsJSON, err := json.Marshal(params)
-			require.NoError(t, err)
-
-			call := ToolCall{
-				Name:  BashToolName,
-				Input: string(paramsJSON),
-			}
-
-			response, err := tool.Run(context.Background(), call)
-			require.NoError(t, err)
-			assert.Contains(t, response.Content, "not allowed", "Command %s should be blocked", bannedCmd)
-		}
-	})
-
-	t.Run("handles multi-word safe commands without permission check", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(false))
-
-		// Test with multi-word safe commands
-		multiWordCommands := []string{
-			"go env",
-		}
-
-		for _, cmd := range multiWordCommands {
-			params := BashParams{
-				Command: cmd,
-			}
-
-			paramsJSON, err := json.Marshal(params)
-			require.NoError(t, err)
-
-			call := ToolCall{
-				Name:  BashToolName,
-				Input: string(paramsJSON),
-			}
-
-			response, err := tool.Run(context.Background(), call)
-			require.NoError(t, err)
-			assert.NotContains(t, response.Content, "permission denied",
-				"Command %s should be allowed without permission", cmd)
-		}
-	})
-
-	t.Run("handles permission denied", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(false))
-
-		// Test with a command that requires permission
-		params := BashParams{
-			Command: "mkdir test_dir",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "permission denied")
-	})
-
-	t.Run("handles command timeout", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "sleep 2",
-			Timeout: 100, // 100ms timeout
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "aborted")
-	})
-
-	t.Run("handles command with stderr output", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "echo 'error message' >&2",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "error message")
-	})
-
-	t.Run("handles command with both stdout and stderr", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "echo 'stdout message' && echo 'stderr message' >&2",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "stdout message")
-		assert.Contains(t, response.Content, "stderr message")
-	})
-
-	t.Run("handles context cancellation", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "sleep 5",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		ctx, cancel := context.WithCancel(context.Background())
-
-		// Cancel the context after a short delay
-		go func() {
-			time.Sleep(100 * time.Millisecond)
-			cancel()
-		}()
-
-		response, err := tool.Run(ctx, call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "aborted")
-	})
-
-	t.Run("respects max timeout", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "echo 'test'",
-			Timeout: MaxTimeout + 1000, // Exceeds max timeout
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Equal(t, "test\n", response.Content)
-	})
-
-	t.Run("uses default timeout for zero or negative timeout", func(t *testing.T) {
-		tool := NewBashTool(newMockPermissionService(true))
-		params := BashParams{
-			Command: "echo 'test'",
-			Timeout: -100, // Negative timeout
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  BashToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Equal(t, "test\n", response.Content)
-	})
-}
-
-func TestTruncateOutput(t *testing.T) {
-	t.Run("does not truncate short output", func(t *testing.T) {
-		output := "short output"
-		result := truncateOutput(output)
-		assert.Equal(t, output, result)
-	})
-
-	t.Run("truncates long output", func(t *testing.T) {
-		// Create a string longer than MaxOutputLength
-		longOutput := strings.Repeat("a\n", MaxOutputLength)
-		result := truncateOutput(longOutput)
-
-		// Check that the result is shorter than the original
-		assert.Less(t, len(result), len(longOutput))
-
-		// Check that the truncation message is included
-		assert.Contains(t, result, "lines truncated")
-
-		// Check that we have the beginning and end of the original string
-		assert.True(t, strings.HasPrefix(result, "a\n"))
-		assert.True(t, strings.HasSuffix(result, "a\n"))
-	})
-}
-
-func TestCountLines(t *testing.T) {
-	testCases := []struct {
-		name     string
-		input    string
-		expected int
-	}{
-		{
-			name:     "empty string",
-			input:    "",
-			expected: 0,
-		},
-		{
-			name:     "single line",
-			input:    "line1",
-			expected: 1,
-		},
-		{
-			name:     "multiple lines",
-			input:    "line1\nline2\nline3",
-			expected: 3,
-		},
-		{
-			name:     "trailing newline",
-			input:    "line1\nline2\n",
-			expected: 3, // Empty string after last newline counts as a line
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			result := countLines(tc.input)
-			assert.Equal(t, tc.expected, result)
-		})
-	}
-}

internal/llm/tools/edit_test.go 🔗

@@ -1,461 +0,0 @@
-package tools
-
-import (
-	"context"
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"testing"
-	"time"
-
-	"github.com/kujtimiihoxha/opencode/internal/lsp"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestEditTool_Info(t *testing.T) {
-	tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-	info := tool.Info()
-
-	assert.Equal(t, EditToolName, info.Name)
-	assert.NotEmpty(t, info.Description)
-	assert.Contains(t, info.Parameters, "file_path")
-	assert.Contains(t, info.Parameters, "old_string")
-	assert.Contains(t, info.Parameters, "new_string")
-	assert.Contains(t, info.Required, "file_path")
-	assert.Contains(t, info.Required, "old_string")
-	assert.Contains(t, info.Required, "new_string")
-}
-
-func TestEditTool_Run(t *testing.T) {
-	// Create a temporary directory for testing
-	tempDir, err := os.MkdirTemp("", "edit_tool_test")
-	require.NoError(t, err)
-	defer os.RemoveAll(tempDir)
-
-	t.Run("creates a new file successfully", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		filePath := filepath.Join(tempDir, "new_file.txt")
-		content := "This is a test content"
-
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "",
-			NewString: content,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "File created")
-
-		// Verify file was created with correct content
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, content, string(fileContent))
-	})
-
-	t.Run("creates file with nested directories", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
-		content := "Content in nested directory"
-
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "",
-			NewString: content,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "File created")
-
-		// Verify file was created with correct content
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, content, string(fileContent))
-	})
-
-	t.Run("fails to create file that already exists", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file first
-		filePath := filepath.Join(tempDir, "existing_file.txt")
-		initialContent := "Initial content"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Try to create the same file
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "",
-			NewString: "New content",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "file already exists")
-	})
-
-	t.Run("fails to create file when path is a directory", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a directory
-		dirPath := filepath.Join(tempDir, "test_dir")
-		err := os.Mkdir(dirPath, 0o755)
-		require.NoError(t, err)
-
-		// Try to create a file with the same path as the directory
-		params := EditParams{
-			FilePath:  dirPath,
-			OldString: "",
-			NewString: "Some content",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "path is a directory")
-	})
-
-	t.Run("replaces content successfully", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file first
-		filePath := filepath.Join(tempDir, "replace_content.txt")
-		initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record the file read to avoid modification time check failure
-		recordFileRead(filePath)
-
-		// Replace content
-		oldString := "Line 2\nLine 3"
-		newString := "Line 2 modified\nLine 3 modified"
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: oldString,
-			NewString: newString,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "Content replaced")
-
-		// Verify file was updated with correct content
-		expectedContent := "Line 1\nLine 2 modified\nLine 3 modified\nLine 4\nLine 5"
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, expectedContent, string(fileContent))
-	})
-
-	t.Run("deletes content successfully", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file first
-		filePath := filepath.Join(tempDir, "delete_content.txt")
-		initialContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record the file read to avoid modification time check failure
-		recordFileRead(filePath)
-
-		// Delete content
-		oldString := "Line 2\nLine 3\n"
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: oldString,
-			NewString: "",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "Content deleted")
-
-		// Verify file was updated with correct content
-		expectedContent := "Line 1\nLine 4\nLine 5"
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, expectedContent, string(fileContent))
-	})
-
-	t.Run("handles invalid parameters", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: "invalid json",
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "invalid parameters")
-	})
-
-	t.Run("handles missing file_path", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		params := EditParams{
-			FilePath:  "",
-			OldString: "old",
-			NewString: "new",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "file_path is required")
-	})
-
-	t.Run("handles file not found", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		filePath := filepath.Join(tempDir, "non_existent_file.txt")
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "old content",
-			NewString: "new content",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "file not found")
-	})
-
-	t.Run("handles old_string not found in file", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file first
-		filePath := filepath.Join(tempDir, "content_not_found.txt")
-		initialContent := "Line 1\nLine 2\nLine 3"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record the file read to avoid modification time check failure
-		recordFileRead(filePath)
-
-		// Try to replace content that doesn't exist
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "This content does not exist",
-			NewString: "new content",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "old_string not found in file")
-	})
-
-	t.Run("handles multiple occurrences of old_string", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file with duplicate content
-		filePath := filepath.Join(tempDir, "duplicate_content.txt")
-		initialContent := "Line 1\nDuplicate\nLine 3\nDuplicate\nLine 5"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record the file read to avoid modification time check failure
-		recordFileRead(filePath)
-
-		// Try to replace content that appears multiple times
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "Duplicate",
-			NewString: "Replaced",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "appears multiple times")
-	})
-
-	t.Run("handles file modified since last read", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file
-		filePath := filepath.Join(tempDir, "modified_file.txt")
-		initialContent := "Initial content"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record an old read time
-		fileRecordMutex.Lock()
-		fileRecords[filePath] = fileRecord{
-			path:     filePath,
-			readTime: time.Now().Add(-1 * time.Hour),
-		}
-		fileRecordMutex.Unlock()
-
-		// Try to update the file
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "Initial",
-			NewString: "Updated",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "has been modified since it was last read")
-
-		// Verify file was not modified
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, initialContent, string(fileContent))
-	})
-
-	t.Run("handles file not read before editing", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file
-		filePath := filepath.Join(tempDir, "not_read_file.txt")
-		initialContent := "Initial content"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Try to update the file without reading it first
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "Initial",
-			NewString: "Updated",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "you must read the file before editing it")
-	})
-
-	t.Run("handles permission denied", func(t *testing.T) {
-		tool := NewEditTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
-
-		// Create a file
-		filePath := filepath.Join(tempDir, "permission_denied.txt")
-		initialContent := "Initial content"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record the file read to avoid modification time check failure
-		recordFileRead(filePath)
-
-		// Try to update the file
-		params := EditParams{
-			FilePath:  filePath,
-			OldString: "Initial",
-			NewString: "Updated",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  EditToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "permission denied")
-
-		// Verify file was not modified
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, initialContent, string(fileContent))
-	})
-}

internal/llm/tools/mocks_test.go 🔗

@@ -1,246 +0,0 @@
-package tools
-
-import (
-	"context"
-	"fmt"
-	"sort"
-	"strconv"
-	"strings"
-	"time"
-
-	"github.com/google/uuid"
-	"github.com/kujtimiihoxha/opencode/internal/history"
-	"github.com/kujtimiihoxha/opencode/internal/permission"
-	"github.com/kujtimiihoxha/opencode/internal/pubsub"
-)
-
-// Mock permission service for testing
-type mockPermissionService struct {
-	*pubsub.Broker[permission.PermissionRequest]
-	allow bool
-}
-
-func (m *mockPermissionService) GrantPersistant(permission permission.PermissionRequest) {
-	// Not needed for tests
-}
-
-func (m *mockPermissionService) Grant(permission permission.PermissionRequest) {
-	// Not needed for tests
-}
-
-func (m *mockPermissionService) Deny(permission permission.PermissionRequest) {
-	// Not needed for tests
-}
-
-func (m *mockPermissionService) Request(opts permission.CreatePermissionRequest) bool {
-	return m.allow
-}
-
-func newMockPermissionService(allow bool) permission.Service {
-	return &mockPermissionService{
-		Broker: pubsub.NewBroker[permission.PermissionRequest](),
-		allow:  allow,
-	}
-}
-
-type mockFileHistoryService struct {
-	*pubsub.Broker[history.File]
-	files     map[string]history.File // ID -> File
-	timeNow   func() int64
-}
-
-// Create implements history.Service.
-func (m *mockFileHistoryService) Create(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
-	return m.createWithVersion(ctx, sessionID, path, content, history.InitialVersion)
-}
-
-// CreateVersion implements history.Service.
-func (m *mockFileHistoryService) CreateVersion(ctx context.Context, sessionID string, path string, content string) (history.File, error) {
-	var files []history.File
-	for _, file := range m.files {
-		if file.Path == path {
-			files = append(files, file)
-		}
-	}
-
-	if len(files) == 0 {
-		// No previous versions, create initial
-		return m.Create(ctx, sessionID, path, content)
-	}
-
-	// Sort files by CreatedAt in descending order
-	sort.Slice(files, func(i, j int) bool {
-		return files[i].CreatedAt > files[j].CreatedAt
-	})
-
-	// Get the latest version
-	latestFile := files[0]
-	latestVersion := latestFile.Version
-
-	// Generate the next version
-	var nextVersion string
-	if latestVersion == history.InitialVersion {
-		nextVersion = "v1"
-	} else if strings.HasPrefix(latestVersion, "v") {
-		versionNum, err := strconv.Atoi(latestVersion[1:])
-		if err != nil {
-			// If we can't parse the version, just use a timestamp-based version
-			nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
-		} else {
-			nextVersion = fmt.Sprintf("v%d", versionNum+1)
-		}
-	} else {
-		// If the version format is unexpected, use a timestamp-based version
-		nextVersion = fmt.Sprintf("v%d", latestFile.CreatedAt)
-	}
-
-	return m.createWithVersion(ctx, sessionID, path, content, nextVersion)
-}
-
-func (m *mockFileHistoryService) createWithVersion(_ context.Context, sessionID, path, content, version string) (history.File, error) {
-	now := m.timeNow()
-	file := history.File{
-		ID:        uuid.New().String(),
-		SessionID: sessionID,
-		Path:      path,
-		Content:   content,
-		Version:   version,
-		CreatedAt: now,
-		UpdatedAt: now,
-	}
-
-	m.files[file.ID] = file
-	m.Publish(pubsub.CreatedEvent, file)
-	return file, nil
-}
-
-// Delete implements history.Service.
-func (m *mockFileHistoryService) Delete(ctx context.Context, id string) error {
-	file, ok := m.files[id]
-	if !ok {
-		return fmt.Errorf("file not found: %s", id)
-	}
-
-	delete(m.files, id)
-	m.Publish(pubsub.DeletedEvent, file)
-	return nil
-}
-
-// DeleteSessionFiles implements history.Service.
-func (m *mockFileHistoryService) DeleteSessionFiles(ctx context.Context, sessionID string) error {
-	files, err := m.ListBySession(ctx, sessionID)
-	if err != nil {
-		return err
-	}
-
-	for _, file := range files {
-		err = m.Delete(ctx, file.ID)
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
-// Get implements history.Service.
-func (m *mockFileHistoryService) Get(ctx context.Context, id string) (history.File, error) {
-	file, ok := m.files[id]
-	if !ok {
-		return history.File{}, fmt.Errorf("file not found: %s", id)
-	}
-	return file, nil
-}
-
-// GetByPathAndSession implements history.Service.
-func (m *mockFileHistoryService) GetByPathAndSession(ctx context.Context, path string, sessionID string) (history.File, error) {
-	var latestFile history.File
-	var found bool
-	var latestTime int64
-
-	for _, file := range m.files {
-		if file.Path == path && file.SessionID == sessionID {
-			if !found || file.CreatedAt > latestTime {
-				latestFile = file
-				latestTime = file.CreatedAt
-				found = true
-			}
-		}
-	}
-
-	if !found {
-		return history.File{}, fmt.Errorf("file not found: %s for session %s", path, sessionID)
-	}
-	return latestFile, nil
-}
-
-// ListBySession implements history.Service.
-func (m *mockFileHistoryService) ListBySession(ctx context.Context, sessionID string) ([]history.File, error) {
-	var files []history.File
-	for _, file := range m.files {
-		if file.SessionID == sessionID {
-			files = append(files, file)
-		}
-	}
-
-	// Sort by CreatedAt in descending order
-	sort.Slice(files, func(i, j int) bool {
-		return files[i].CreatedAt > files[j].CreatedAt
-	})
-
-	return files, nil
-}
-
-// ListLatestSessionFiles implements history.Service.
-func (m *mockFileHistoryService) ListLatestSessionFiles(ctx context.Context, sessionID string) ([]history.File, error) {
-	// Map to track the latest file for each path
-	latestFiles := make(map[string]history.File)
-	
-	for _, file := range m.files {
-		if file.SessionID == sessionID {
-			existing, ok := latestFiles[file.Path]
-			if !ok || file.CreatedAt > existing.CreatedAt {
-				latestFiles[file.Path] = file
-			}
-		}
-	}
-
-	// Convert map to slice
-	var result []history.File
-	for _, file := range latestFiles {
-		result = append(result, file)
-	}
-
-	// Sort by CreatedAt in descending order
-	sort.Slice(result, func(i, j int) bool {
-		return result[i].CreatedAt > result[j].CreatedAt
-	})
-
-	return result, nil
-}
-
-// Subscribe implements history.Service.
-func (m *mockFileHistoryService) Subscribe(ctx context.Context) <-chan pubsub.Event[history.File] {
-	return m.Broker.Subscribe(ctx)
-}
-
-// Update implements history.Service.
-func (m *mockFileHistoryService) Update(ctx context.Context, file history.File) (history.File, error) {
-	_, ok := m.files[file.ID]
-	if !ok {
-		return history.File{}, fmt.Errorf("file not found: %s", file.ID)
-	}
-
-	file.UpdatedAt = m.timeNow()
-	m.files[file.ID] = file
-	m.Publish(pubsub.UpdatedEvent, file)
-	return file, nil
-}
-
-func newMockFileHistoryService() history.Service {
-	return &mockFileHistoryService{
-		Broker:   pubsub.NewBroker[history.File](),
-		files:    make(map[string]history.File),
-		timeNow:  func() int64 { return time.Now().Unix() },
-	}
-}

internal/llm/tools/patch.go 🔗

@@ -0,0 +1,300 @@
+package tools
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/kujtimiihoxha/opencode/internal/config"
+	"github.com/kujtimiihoxha/opencode/internal/diff"
+	"github.com/kujtimiihoxha/opencode/internal/history"
+	"github.com/kujtimiihoxha/opencode/internal/lsp"
+	"github.com/kujtimiihoxha/opencode/internal/permission"
+)
+
+type PatchParams struct {
+	FilePath string `json:"file_path"`
+	Patch    string `json:"patch"`
+}
+
+type PatchPermissionsParams struct {
+	FilePath string `json:"file_path"`
+	Diff     string `json:"diff"`
+}
+
+type PatchResponseMetadata struct {
+	Diff      string `json:"diff"`
+	Additions int    `json:"additions"`
+	Removals  int    `json:"removals"`
+}
+
+type patchTool struct {
+	lspClients  map[string]*lsp.Client
+	permissions permission.Service
+	files       history.Service
+}
+
+const (
+	// TODO: test if this works as expected
+	PatchToolName    = "patch"
+	patchDescription = `Applies a patch to a file. This tool is similar to the edit tool but accepts a unified diff patch instead of old/new strings.
+
+Before using this tool:
+
+1. Use the FileRead tool to understand the file's contents and context
+
+2. Verify the directory path is correct:
+   - Use the LS tool to verify the parent directory exists and is the correct location
+
+To apply a patch, provide the following:
+1. file_path: The absolute path to the file to modify (must be absolute, not relative)
+2. patch: A unified diff patch to apply to the file
+
+The tool will apply the patch to the specified file. The patch must be in unified diff format.
+
+CRITICAL REQUIREMENTS FOR USING THIS TOOL:
+
+1. PATCH FORMAT: The patch must be in unified diff format, which includes:
+   - File headers (--- a/file_path, +++ b/file_path)
+   - Hunk headers (@@ -start,count +start,count @@)
+   - Added lines (prefixed with +)
+   - Removed lines (prefixed with -)
+
+2. CONTEXT: The patch must include sufficient context around the changes to ensure it applies correctly.
+
+3. VERIFICATION: Before using this tool:
+   - Ensure the patch applies cleanly to the current state of the file
+   - Check that the file exists and you have read it first
+
+WARNING: If you do not follow these requirements:
+   - The tool will fail if the patch doesn't apply cleanly
+   - You may change the wrong parts of the file if the context is insufficient
+
+When applying patches:
+   - Ensure the patch results in idiomatic, correct code
+   - Do not leave the code in a broken state
+   - Always use absolute file paths (starting with /)
+
+Remember: patches are a powerful way to make multiple related changes at once, but they require careful preparation.`
+)
+
+func NewPatchTool(lspClients map[string]*lsp.Client, permissions permission.Service, files history.Service) BaseTool {
+	return &patchTool{
+		lspClients:  lspClients,
+		permissions: permissions,
+		files:       files,
+	}
+}
+
+func (p *patchTool) Info() ToolInfo {
+	return ToolInfo{
+		Name:        PatchToolName,
+		Description: patchDescription,
+		Parameters: map[string]any{
+			"file_path": map[string]any{
+				"type":        "string",
+				"description": "The absolute path to the file to modify",
+			},
+			"patch": map[string]any{
+				"type":        "string",
+				"description": "The unified diff patch to apply",
+			},
+		},
+		Required: []string{"file_path", "patch"},
+	}
+}
+
+func (p *patchTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
+	var params PatchParams
+	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
+		return NewTextErrorResponse("invalid parameters"), nil
+	}
+
+	if params.FilePath == "" {
+		return NewTextErrorResponse("file_path is required"), nil
+	}
+
+	if params.Patch == "" {
+		return NewTextErrorResponse("patch is required"), nil
+	}
+
+	if !filepath.IsAbs(params.FilePath) {
+		wd := config.WorkingDirectory()
+		params.FilePath = filepath.Join(wd, params.FilePath)
+	}
+
+	// Check if file exists
+	fileInfo, err := os.Stat(params.FilePath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return NewTextErrorResponse(fmt.Sprintf("file not found: %s", params.FilePath)), nil
+		}
+		return ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
+	}
+
+	if fileInfo.IsDir() {
+		return NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", params.FilePath)), nil
+	}
+
+	if getLastReadTime(params.FilePath).IsZero() {
+		return NewTextErrorResponse("you must read the file before patching it. Use the View tool first"), nil
+	}
+
+	modTime := fileInfo.ModTime()
+	lastRead := getLastReadTime(params.FilePath)
+	if modTime.After(lastRead) {
+		return NewTextErrorResponse(
+			fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
+				params.FilePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
+			)), nil
+	}
+
+	// Read the current file content
+	content, err := os.ReadFile(params.FilePath)
+	if err != nil {
+		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
+	}
+
+	oldContent := string(content)
+
+	// Parse and apply the patch
+	diffResult, err := diff.ParseUnifiedDiff(params.Patch)
+	if err != nil {
+		return NewTextErrorResponse(fmt.Sprintf("failed to parse patch: %v", err)), nil
+	}
+
+	// Apply the patch to get the new content
+	newContent, err := applyPatch(oldContent, diffResult)
+	if err != nil {
+		return NewTextErrorResponse(fmt.Sprintf("failed to apply patch: %v", err)), nil
+	}
+
+	if oldContent == newContent {
+		return NewTextErrorResponse("patch did not result in any changes to the file"), nil
+	}
+
+	sessionID, messageID := GetContextValues(ctx)
+	if sessionID == "" || messageID == "" {
+		return ToolResponse{}, fmt.Errorf("session ID and message ID are required for patching a file")
+	}
+
+	// Generate a diff for permission request and metadata
+	diffText, additions, removals := diff.GenerateDiff(
+		oldContent,
+		newContent,
+		params.FilePath,
+	)
+
+	// Request permission to apply the patch
+	p.permissions.Request(
+		permission.CreatePermissionRequest{
+			Path:        filepath.Dir(params.FilePath),
+			ToolName:    PatchToolName,
+			Action:      "patch",
+			Description: fmt.Sprintf("Apply patch to file %s", params.FilePath),
+			Params: PatchPermissionsParams{
+				FilePath: params.FilePath,
+				Diff:     diffText,
+			},
+		},
+	)
+
+	// Write the new content to the file
+	err = os.WriteFile(params.FilePath, []byte(newContent), 0o644)
+	if err != nil {
+		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
+	}
+
+	// Update file history
+	file, err := p.files.GetByPathAndSession(ctx, params.FilePath, sessionID)
+	if err != nil {
+		_, err = p.files.Create(ctx, sessionID, params.FilePath, oldContent)
+		if err != nil {
+			return ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
+		}
+	}
+	if file.Content != oldContent {
+		// User manually changed the content, store an intermediate version
+		_, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, oldContent)
+		if err != nil {
+			fmt.Printf("Error creating file history version: %v\n", err)
+		}
+	}
+	// Store the new version
+	_, err = p.files.CreateVersion(ctx, sessionID, params.FilePath, newContent)
+	if err != nil {
+		fmt.Printf("Error creating file history version: %v\n", err)
+	}
+
+	recordFileWrite(params.FilePath)
+	recordFileRead(params.FilePath)
+
+	// Wait for LSP diagnostics and include them in the response
+	waitForLspDiagnostics(ctx, params.FilePath, p.lspClients)
+	text := fmt.Sprintf("<r>\nPatch applied to file: %s\n</r>\n", params.FilePath)
+	text += getDiagnostics(params.FilePath, p.lspClients)
+
+	return WithResponseMetadata(
+		NewTextResponse(text),
+		PatchResponseMetadata{
+			Diff:      diffText,
+			Additions: additions,
+			Removals:  removals,
+		}), nil
+}
+
+// applyPatch applies a parsed diff to a string and returns the resulting content
+func applyPatch(content string, diffResult diff.DiffResult) (string, error) {
+	lines := strings.Split(content, "\n")
+
+	// Process each hunk in the diff
+	for _, hunk := range diffResult.Hunks {
+		// Parse the hunk header to get line numbers
+		var oldStart, oldCount, newStart, newCount int
+		_, err := fmt.Sscanf(hunk.Header, "@@ -%d,%d +%d,%d @@", &oldStart, &oldCount, &newStart, &newCount)
+		if err != nil {
+			// Try alternative format with single line counts
+			_, err = fmt.Sscanf(hunk.Header, "@@ -%d +%d @@", &oldStart, &newStart)
+			if err != nil {
+				return "", fmt.Errorf("invalid hunk header format: %s", hunk.Header)
+			}
+			oldCount = 1
+			newCount = 1
+		}
+
+		// Adjust for 0-based array indexing
+		oldStart--
+		newStart--
+
+		// Apply the changes
+		newLines := make([]string, 0)
+		newLines = append(newLines, lines[:oldStart]...)
+
+		// Process the hunk lines in order
+		currentOldLine := oldStart
+		for _, line := range hunk.Lines {
+			switch line.Kind {
+			case diff.LineContext:
+				newLines = append(newLines, line.Content)
+				currentOldLine++
+			case diff.LineRemoved:
+				// Skip this line in the output (it's being removed)
+				currentOldLine++
+			case diff.LineAdded:
+				// Add the new line
+				newLines = append(newLines, line.Content)
+			}
+		}
+
+		// Append the rest of the file
+		newLines = append(newLines, lines[currentOldLine:]...)
+		lines = newLines
+	}
+
+	return strings.Join(lines, "\n"), nil
+}
+

internal/llm/tools/sourcegraph_test.go 🔗

@@ -1,86 +0,0 @@
-package tools
-
-import (
-	"context"
-	"encoding/json"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestSourcegraphTool_Info(t *testing.T) {
-	tool := NewSourcegraphTool()
-	info := tool.Info()
-
-	assert.Equal(t, SourcegraphToolName, info.Name)
-	assert.NotEmpty(t, info.Description)
-	assert.Contains(t, info.Parameters, "query")
-	assert.Contains(t, info.Parameters, "count")
-	assert.Contains(t, info.Parameters, "timeout")
-	assert.Contains(t, info.Required, "query")
-}
-
-func TestSourcegraphTool_Run(t *testing.T) {
-	t.Run("handles missing query parameter", func(t *testing.T) {
-		tool := NewSourcegraphTool()
-		params := SourcegraphParams{
-			Query: "",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  SourcegraphToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "Query parameter is required")
-	})
-
-	t.Run("handles invalid parameters", func(t *testing.T) {
-		tool := NewSourcegraphTool()
-		call := ToolCall{
-			Name:  SourcegraphToolName,
-			Input: "invalid json",
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "Failed to parse sourcegraph parameters")
-	})
-
-	t.Run("normalizes count parameter", func(t *testing.T) {
-		// Test cases for count normalization
-		testCases := []struct {
-			name          string
-			inputCount    int
-			expectedCount int
-		}{
-			{"negative count", -5, 10},    // Should use default (10)
-			{"zero count", 0, 10},         // Should use default (10)
-			{"valid count", 50, 50},       // Should keep as is
-			{"excessive count", 150, 100}, // Should cap at 100
-		}
-
-		for _, tc := range testCases {
-			t.Run(tc.name, func(t *testing.T) {
-				// Verify count normalization logic directly
-				assert.NotPanics(t, func() {
-					// Apply the same normalization logic as in the tool
-					normalizedCount := tc.inputCount
-					if normalizedCount <= 0 {
-						normalizedCount = 10
-					} else if normalizedCount > 100 {
-						normalizedCount = 100
-					}
-
-					assert.Equal(t, tc.expectedCount, normalizedCount)
-				})
-			})
-		}
-	})
-}

internal/llm/tools/write_test.go 🔗

@@ -1,307 +0,0 @@
-package tools
-
-import (
-	"context"
-	"encoding/json"
-	"os"
-	"path/filepath"
-	"testing"
-	"time"
-
-	"github.com/kujtimiihoxha/opencode/internal/lsp"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestWriteTool_Info(t *testing.T) {
-	tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-	info := tool.Info()
-
-	assert.Equal(t, WriteToolName, info.Name)
-	assert.NotEmpty(t, info.Description)
-	assert.Contains(t, info.Parameters, "file_path")
-	assert.Contains(t, info.Parameters, "content")
-	assert.Contains(t, info.Required, "file_path")
-	assert.Contains(t, info.Required, "content")
-}
-
-func TestWriteTool_Run(t *testing.T) {
-	// Create a temporary directory for testing
-	tempDir, err := os.MkdirTemp("", "write_tool_test")
-	require.NoError(t, err)
-	defer os.RemoveAll(tempDir)
-
-	t.Run("creates a new file successfully", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		filePath := filepath.Join(tempDir, "new_file.txt")
-		content := "This is a test content"
-
-		params := WriteParams{
-			FilePath: filePath,
-			Content:  content,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "successfully written")
-
-		// Verify file was created with correct content
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, content, string(fileContent))
-	})
-
-	t.Run("creates file with nested directories", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		filePath := filepath.Join(tempDir, "nested/dirs/new_file.txt")
-		content := "Content in nested directory"
-
-		params := WriteParams{
-			FilePath: filePath,
-			Content:  content,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "successfully written")
-
-		// Verify file was created with correct content
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, content, string(fileContent))
-	})
-
-	t.Run("updates existing file", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file first
-		filePath := filepath.Join(tempDir, "existing_file.txt")
-		initialContent := "Initial content"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record the file read to avoid modification time check failure
-		recordFileRead(filePath)
-
-		// Update the file
-		updatedContent := "Updated content"
-		params := WriteParams{
-			FilePath: filePath,
-			Content:  updatedContent,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "successfully written")
-
-		// Verify file was updated with correct content
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, updatedContent, string(fileContent))
-	})
-
-	t.Run("handles invalid parameters", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: "invalid json",
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "error parsing parameters")
-	})
-
-	t.Run("handles missing file_path", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		params := WriteParams{
-			FilePath: "",
-			Content:  "Some content",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "file_path is required")
-	})
-
-	t.Run("handles missing content", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		params := WriteParams{
-			FilePath: filepath.Join(tempDir, "file.txt"),
-			Content:  "",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "content is required")
-	})
-
-	t.Run("handles writing to a directory path", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a directory
-		dirPath := filepath.Join(tempDir, "test_dir")
-		err := os.Mkdir(dirPath, 0o755)
-		require.NoError(t, err)
-
-		params := WriteParams{
-			FilePath: dirPath,
-			Content:  "Some content",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "Path is a directory")
-	})
-
-	t.Run("handles permission denied", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(false), newMockFileHistoryService())
-
-		filePath := filepath.Join(tempDir, "permission_denied.txt")
-		params := WriteParams{
-			FilePath: filePath,
-			Content:  "Content that should not be written",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "Permission denied")
-
-		// Verify file was not created
-		_, err = os.Stat(filePath)
-		assert.True(t, os.IsNotExist(err))
-	})
-
-	t.Run("detects file modified since last read", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file
-		filePath := filepath.Join(tempDir, "modified_file.txt")
-		initialContent := "Initial content"
-		err := os.WriteFile(filePath, []byte(initialContent), 0o644)
-		require.NoError(t, err)
-
-		// Record an old read time
-		fileRecordMutex.Lock()
-		fileRecords[filePath] = fileRecord{
-			path:     filePath,
-			readTime: time.Now().Add(-1 * time.Hour),
-		}
-		fileRecordMutex.Unlock()
-
-		// Try to update the file
-		params := WriteParams{
-			FilePath: filePath,
-			Content:  "Updated content",
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "has been modified since it was last read")
-
-		// Verify file was not modified
-		fileContent, err := os.ReadFile(filePath)
-		require.NoError(t, err)
-		assert.Equal(t, initialContent, string(fileContent))
-	})
-
-	t.Run("skips writing when content is identical", func(t *testing.T) {
-		tool := NewWriteTool(make(map[string]*lsp.Client), newMockPermissionService(true), newMockFileHistoryService())
-
-		// Create a file
-		filePath := filepath.Join(tempDir, "identical_content.txt")
-		content := "Content that won't change"
-		err := os.WriteFile(filePath, []byte(content), 0o644)
-		require.NoError(t, err)
-
-		// Record a read time
-		recordFileRead(filePath)
-
-		// Try to write the same content
-		params := WriteParams{
-			FilePath: filePath,
-			Content:  content,
-		}
-
-		paramsJSON, err := json.Marshal(params)
-		require.NoError(t, err)
-
-		call := ToolCall{
-			Name:  WriteToolName,
-			Input: string(paramsJSON),
-		}
-
-		response, err := tool.Run(context.Background(), call)
-		require.NoError(t, err)
-		assert.Contains(t, response.Content, "already contains the exact content")
-	})
-}

internal/tui/components/chat/sidebar.go 🔗

@@ -3,6 +3,7 @@ package chat
 import (
 	"context"
 	"fmt"
+	"sort"
 	"strings"
 
 	tea "github.com/charmbracelet/bubbletea"
@@ -141,8 +142,17 @@ func (m *sidebarCmp) modifiedFiles() string {
 			)
 	}
 
+	// Sort file paths alphabetically for consistent ordering
+	var paths []string
+	for path := range m.modFiles {
+		paths = append(paths, path)
+	}
+	sort.Strings(paths)
+
+	// Create views for each file in sorted order
 	var fileViews []string
-	for path, stats := range m.modFiles {
+	for _, path := range paths {
+		stats := m.modFiles[path]
 		fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
 	}
 

internal/tui/components/dialog/permission.go 🔗

@@ -266,6 +266,18 @@ func (p *permissionDialogCmp) renderEditContent() string {
 	return ""
 }
 
+func (p *permissionDialogCmp) renderPatchContent() string {
+	if pr, ok := p.permission.Params.(tools.PatchPermissionsParams); ok {
+		diff := p.GetOrSetDiff(p.permission.ID, func() (string, error) {
+			return diff.FormatDiff(pr.Diff, diff.WithTotalWidth(p.contentViewPort.Width))
+		})
+
+		p.contentViewPort.SetContent(diff)
+		return p.styleViewport()
+	}
+	return ""
+}
+
 func (p *permissionDialogCmp) renderWriteContent() string {
 	if pr, ok := p.permission.Params.(tools.WritePermissionsParams); ok {
 		// Use the cache for diff rendering
@@ -350,6 +362,8 @@ func (p *permissionDialogCmp) render() string {
 		contentFinal = p.renderBashContent()
 	case tools.EditToolName:
 		contentFinal = p.renderEditContent()
+	case tools.PatchToolName:
+		contentFinal = p.renderPatchContent()
 	case tools.WriteToolName:
 		contentFinal = p.renderWriteContent()
 	case tools.FetchToolName:

main.go 🔗

@@ -6,11 +6,9 @@ import (
 )
 
 func main() {
-	// Set up panic recovery for the main function
 	defer logging.RecoverPanic("main", func() {
-		// Perform any necessary cleanup before exit
 		logging.ErrorPersist("Application terminated due to unhandled panic")
 	})
-	
+
 	cmd.Execute()
 }