1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "time"
13
14 "github.com/charmbracelet/crush/internal/config"
15 "github.com/charmbracelet/crush/internal/permission"
16)
17
18type VSCodeDiffParams struct {
19 LeftContent string `json:"left_content"`
20 RightContent string `json:"right_content"`
21 LeftTitle string `json:"left_title,omitempty"`
22 RightTitle string `json:"right_title,omitempty"`
23 Language string `json:"language,omitempty"`
24}
25
26type VSCodeDiffPermissionsParams struct {
27 LeftContent string `json:"left_content"`
28 RightContent string `json:"right_content"`
29 LeftTitle string `json:"left_title,omitempty"`
30 RightTitle string `json:"right_title,omitempty"`
31 Language string `json:"language,omitempty"`
32}
33
34type vscodeDiffTool struct {
35 permissions permission.Service
36}
37
38const (
39 VSCodeDiffToolName = "vscode_diff"
40)
41
42func NewVSCodeDiffTool(permissions permission.Service) BaseTool {
43 return &vscodeDiffTool{
44 permissions: permissions,
45 }
46}
47
48func (t *vscodeDiffTool) Name() string {
49 return VSCodeDiffToolName
50}
51
52func (t *vscodeDiffTool) Info() ToolInfo {
53 return ToolInfo{
54 Name: VSCodeDiffToolName,
55 Description: "Opens VS Code with a diff view comparing two pieces of content. Useful for visualizing code changes, comparing files, or reviewing modifications.",
56 Parameters: map[string]any{
57 "left_content": map[string]any{
58 "type": "string",
59 "description": "The content for the left side of the diff (typically the 'before' or original content)",
60 },
61 "right_content": map[string]any{
62 "type": "string",
63 "description": "The content for the right side of the diff (typically the 'after' or modified content)",
64 },
65 "left_title": map[string]any{
66 "type": "string",
67 "description": "Optional title for the left side (e.g., 'Original', 'Before', or a filename)",
68 },
69 "right_title": map[string]any{
70 "type": "string",
71 "description": "Optional title for the right side (e.g., 'Modified', 'After', or a filename)",
72 },
73 "language": map[string]any{
74 "type": "string",
75 "description": "Optional language identifier for syntax highlighting (e.g., 'javascript', 'python', 'go')",
76 },
77 },
78 Required: []string{"left_content", "right_content"},
79 }
80}
81
82func (t *vscodeDiffTool) Run(ctx context.Context, params ToolCall) (ToolResponse, error) {
83 var diffParams VSCodeDiffParams
84 if err := json.Unmarshal([]byte(params.Input), &diffParams); err != nil {
85 return NewTextErrorResponse(fmt.Sprintf("Failed to parse parameters: %v", err)), nil
86 }
87
88 // Check if VS Code is available
89 vscodeCmd := getVSCodeCommand()
90 if vscodeCmd == "" {
91 return NewTextErrorResponse("VS Code is not available. Please install VS Code and ensure 'code' command is in PATH."), nil
92 }
93
94 // Check permissions
95 sessionID, _ := GetContextValues(ctx)
96 permissionParams := VSCodeDiffPermissionsParams(diffParams)
97
98 p := t.permissions.Request(
99 permission.CreatePermissionRequest{
100 SessionID: sessionID,
101 Path: config.WorkingDirectory(),
102 ToolName: VSCodeDiffToolName,
103 Action: "open_diff",
104 Description: fmt.Sprintf("Open VS Code diff view comparing '%s' and '%s'", diffParams.LeftTitle, diffParams.RightTitle),
105 Params: permissionParams,
106 },
107 )
108 if !p {
109 return NewTextErrorResponse("Permission denied to open VS Code diff"), nil
110 }
111
112 // Create temporary directory for diff files
113 tempDir, err := os.MkdirTemp("", "crush-vscode-diff-*")
114 if err != nil {
115 return NewTextErrorResponse(fmt.Sprintf("Failed to create temporary directory: %v", err)), nil
116 }
117
118 // Determine file extension based on language
119 ext := getFileExtension(diffParams.Language)
120
121 // Create temporary files
122 leftTitle := diffParams.LeftTitle
123 if leftTitle == "" {
124 leftTitle = "before"
125 }
126 rightTitle := diffParams.RightTitle
127 if rightTitle == "" {
128 rightTitle = "after"
129 }
130
131 leftFile := filepath.Join(tempDir, sanitizeFilename(leftTitle)+ext)
132 rightFile := filepath.Join(tempDir, sanitizeFilename(rightTitle)+ext)
133
134 // Write content to temporary files
135 if err := os.WriteFile(leftFile, []byte(diffParams.LeftContent), 0644); err != nil {
136 os.RemoveAll(tempDir)
137 return NewTextErrorResponse(fmt.Sprintf("Failed to write left file: %v", err)), nil
138 }
139
140 if err := os.WriteFile(rightFile, []byte(diffParams.RightContent), 0644); err != nil {
141 os.RemoveAll(tempDir)
142 return NewTextErrorResponse(fmt.Sprintf("Failed to write right file: %v", err)), nil
143 }
144
145 // Open VS Code with diff view
146 cmd := exec.Command(vscodeCmd, "--diff", leftFile, rightFile)
147
148 // Set working directory to current directory
149 cwd := config.WorkingDirectory()
150 if cwd != "" {
151 cmd.Dir = cwd
152 }
153
154 if err := cmd.Start(); err != nil {
155 os.RemoveAll(tempDir)
156 return NewTextErrorResponse(fmt.Sprintf("Failed to open VS Code: %v", err)), nil
157 }
158
159 // Clean up temporary files after a delay (VS Code should have opened them by then)
160 go func() {
161 time.Sleep(5 * time.Second)
162 os.RemoveAll(tempDir)
163 }()
164
165 response := fmt.Sprintf("Opened VS Code diff view comparing '%s' and '%s'", leftTitle, rightTitle)
166 if diffParams.Language != "" {
167 response += fmt.Sprintf(" with %s syntax highlighting", diffParams.Language)
168 }
169
170 return NewTextResponse(response), nil
171}
172
173// getVSCodeCommand returns the appropriate VS Code command for the current platform
174func getVSCodeCommand() string {
175 // Try common VS Code command names
176 commands := []string{"code", "code-insiders"}
177
178 // On macOS, also try the full path
179 if runtime.GOOS == "darwin" {
180 commands = append(commands,
181 "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
182 "/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code",
183 )
184 }
185
186 for _, cmd := range commands {
187 if _, err := exec.LookPath(cmd); err == nil {
188 return cmd
189 }
190 }
191
192 return ""
193}
194
195// getFileExtension returns the appropriate file extension for syntax highlighting
196func getFileExtension(language string) string {
197 if language == "" {
198 return ".txt"
199 }
200
201 extensions := map[string]string{
202 "javascript": ".js",
203 "typescript": ".ts",
204 "python": ".py",
205 "go": ".go",
206 "java": ".java",
207 "c": ".c",
208 "cpp": ".cpp",
209 "csharp": ".cs",
210 "php": ".php",
211 "ruby": ".rb",
212 "rust": ".rs",
213 "swift": ".swift",
214 "kotlin": ".kt",
215 "scala": ".scala",
216 "html": ".html",
217 "css": ".css",
218 "scss": ".scss",
219 "less": ".less",
220 "json": ".json",
221 "xml": ".xml",
222 "yaml": ".yaml",
223 "yml": ".yml",
224 "toml": ".toml",
225 "markdown": ".md",
226 "sql": ".sql",
227 "shell": ".sh",
228 "bash": ".sh",
229 "zsh": ".sh",
230 "fish": ".fish",
231 "powershell": ".ps1",
232 "dockerfile": ".dockerfile",
233 "makefile": ".mk",
234 }
235
236 if ext, ok := extensions[strings.ToLower(language)]; ok {
237 return ext
238 }
239
240 return ".txt"
241}
242
243// sanitizeFilename removes or replaces characters that are not safe for filenames
244func sanitizeFilename(filename string) string {
245 // Replace common unsafe characters
246 replacements := map[string]string{
247 "/": "_",
248 "\\": "_",
249 ":": "_",
250 "*": "_",
251 "?": "_",
252 "\"": "_",
253 "<": "_",
254 ">": "_",
255 "|": "_",
256 }
257
258 result := filename
259 for old, new := range replacements {
260 result = strings.ReplaceAll(result, old, new)
261 }
262
263 // Trim spaces and dots from the beginning and end
264 result = strings.Trim(result, " .")
265
266 // If the result is empty, use a default name
267 if result == "" {
268 result = "file"
269 }
270
271 return result
272}