14 "github.com/charmbracelet/crush/internal/csync"
15 "github.com/charmbracelet/crush/internal/diff"
16 "github.com/charmbracelet/crush/internal/filepathext"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/history"
20 "github.com/charmbracelet/crush/internal/lsp"
21 "github.com/charmbracelet/crush/internal/permission"
24type EditParams struct {
25 FilePath string `json:"file_path" description:"The absolute path to the file to modify"`
26 OldString string `json:"old_string" description:"The text to replace"`
27 NewString string `json:"new_string" description:"The text to replace it with"`
28 ReplaceAll bool `json:"replace_all,omitempty" description:"Replace all occurrences of old_string (default false)"`
31type EditPermissionsParams struct {
32 FilePath string `json:"file_path"`
33 OldContent string `json:"old_content,omitempty"`
34 NewContent string `json:"new_content,omitempty"`
37type EditResponseMetadata struct {
38 Additions int `json:"additions"`
39 Removals int `json:"removals"`
40 OldContent string `json:"old_content,omitempty"`
41 NewContent string `json:"new_content,omitempty"`
44const EditToolName = "edit"
47var editDescription []byte
49type editContext struct {
51 permissions permission.Service
56func NewEditTool(lspClients *csync.Map[string, *lsp.Client], permissions permission.Service, files history.Service, workingDir string) fantasy.AgentTool {
57 return fantasy.NewAgentTool(
59 string(editDescription),
60 func(ctx context.Context, params EditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
61 if params.FilePath == "" {
62 return fantasy.NewTextErrorResponse("file_path is required"), nil
65 params.FilePath = filepathext.SmartJoin(workingDir, params.FilePath)
67 var response fantasy.ToolResponse
70 editCtx := editContext{ctx, permissions, files, workingDir}
72 if params.OldString == "" {
73 response, err = createNewFile(editCtx, params.FilePath, params.NewString, call)
79 if params.NewString == "" {
80 response, err = deleteContent(editCtx, params.FilePath, params.OldString, params.ReplaceAll, call)
86 response, err = replaceContent(editCtx, params.FilePath, params.OldString, params.NewString, params.ReplaceAll, call)
91 // Return early if there was an error during content replacement
92 // This prevents unnecessary LSP diagnostics processing
96 notifyLSPs(ctx, lspClients, params.FilePath)
98 text := fmt.Sprintf("<result>\n%s\n</result>\n", response.Content)
99 text += getDiagnostics(params.FilePath, lspClients)
100 response.Content = text
105func createNewFile(edit editContext, filePath, content string, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
106 fileInfo, err := os.Stat(filePath)
108 if fileInfo.IsDir() {
109 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
111 return fantasy.NewTextErrorResponse(fmt.Sprintf("file already exists: %s", filePath)), nil
112 } else if !os.IsNotExist(err) {
113 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
116 dir := filepath.Dir(filePath)
117 if err = os.MkdirAll(dir, 0o755); err != nil {
118 return fantasy.ToolResponse{}, fmt.Errorf("failed to create parent directories: %w", err)
121 sessionID := GetSessionFromContext(edit.ctx)
123 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
126 _, additions, removals := diff.GenerateDiff(
129 strings.TrimPrefix(filePath, edit.workingDir),
131 p := edit.permissions.Request(
132 permission.CreatePermissionRequest{
133 SessionID: sessionID,
134 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
136 ToolName: EditToolName,
138 Description: fmt.Sprintf("Create file %s", filePath),
139 Params: EditPermissionsParams{
147 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
150 err = os.WriteFile(filePath, []byte(content), 0o644)
152 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
155 // File can't be in the history so we create a new file history
156 _, err = edit.files.Create(edit.ctx, sessionID, filePath, "")
158 // Log error but don't fail the operation
159 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
162 // Add the new content to the file history
163 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, content)
165 // Log error but don't fail the operation
166 slog.Error("Error creating file history version", "error", err)
169 recordFileWrite(filePath)
170 recordFileRead(filePath)
172 return fantasy.WithResponseMetadata(
173 fantasy.NewTextResponse("File created: "+filePath),
174 EditResponseMetadata{
177 Additions: additions,
183func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
184 fileInfo, err := os.Stat(filePath)
186 if os.IsNotExist(err) {
187 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
189 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
192 if fileInfo.IsDir() {
193 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
196 if getLastReadTime(filePath).IsZero() {
197 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
200 modTime := fileInfo.ModTime()
201 lastRead := getLastReadTime(filePath)
202 if modTime.After(lastRead) {
203 return fantasy.NewTextErrorResponse(
204 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
205 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
209 content, err := os.ReadFile(filePath)
211 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
214 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
216 var newContent string
217 var deletionCount int
220 newContent = strings.ReplaceAll(oldContent, oldString, "")
221 deletionCount = strings.Count(oldContent, oldString)
222 if deletionCount == 0 {
223 return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
226 index := strings.Index(oldContent, oldString)
228 return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
231 lastIndex := strings.LastIndex(oldContent, oldString)
232 if index != lastIndex {
233 return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
236 newContent = oldContent[:index] + oldContent[index+len(oldString):]
240 sessionID := GetSessionFromContext(edit.ctx)
243 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
246 _, additions, removals := diff.GenerateDiff(
249 strings.TrimPrefix(filePath, edit.workingDir),
252 p := edit.permissions.Request(
253 permission.CreatePermissionRequest{
254 SessionID: sessionID,
255 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
257 ToolName: EditToolName,
259 Description: fmt.Sprintf("Delete content from file %s", filePath),
260 Params: EditPermissionsParams{
262 OldContent: oldContent,
263 NewContent: newContent,
268 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
272 newContent, _ = fsext.ToWindowsLineEndings(newContent)
275 err = os.WriteFile(filePath, []byte(newContent), 0o644)
277 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
280 // Check if file exists in history
281 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
283 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
285 // Log error but don't fail the operation
286 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
289 if file.Content != oldContent {
290 // User Manually changed the content store an intermediate version
291 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
293 slog.Error("Error creating file history version", "error", err)
296 // Store the new version
297 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, "")
299 slog.Error("Error creating file history version", "error", err)
302 recordFileWrite(filePath)
303 recordFileRead(filePath)
305 return fantasy.WithResponseMetadata(
306 fantasy.NewTextResponse("Content deleted from file: "+filePath),
307 EditResponseMetadata{
308 OldContent: oldContent,
309 NewContent: newContent,
310 Additions: additions,
316func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
317 fileInfo, err := os.Stat(filePath)
319 if os.IsNotExist(err) {
320 return fantasy.NewTextErrorResponse(fmt.Sprintf("file not found: %s", filePath)), nil
322 return fantasy.ToolResponse{}, fmt.Errorf("failed to access file: %w", err)
325 if fileInfo.IsDir() {
326 return fantasy.NewTextErrorResponse(fmt.Sprintf("path is a directory, not a file: %s", filePath)), nil
329 if getLastReadTime(filePath).IsZero() {
330 return fantasy.NewTextErrorResponse("you must read the file before editing it. Use the View tool first"), nil
333 modTime := fileInfo.ModTime()
334 lastRead := getLastReadTime(filePath)
335 if modTime.After(lastRead) {
336 return fantasy.NewTextErrorResponse(
337 fmt.Sprintf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
338 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339),
342 content, err := os.ReadFile(filePath)
344 return fantasy.ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
347 oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
349 var newContent string
350 var replacementCount int
353 newContent = strings.ReplaceAll(oldContent, oldString, newString)
354 replacementCount = strings.Count(oldContent, oldString)
355 if replacementCount == 0 {
356 return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
359 index := strings.Index(oldContent, oldString)
361 return fantasy.NewTextErrorResponse("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks"), nil
364 lastIndex := strings.LastIndex(oldContent, oldString)
365 if index != lastIndex {
366 return fantasy.NewTextErrorResponse("old_string appears multiple times in the file. Please provide more context to ensure a unique match, or set replace_all to true"), nil
369 newContent = oldContent[:index] + newString + oldContent[index+len(oldString):]
373 if oldContent == newContent {
374 return fantasy.NewTextErrorResponse("new content is the same as old content. No changes made."), nil
376 sessionID := GetSessionFromContext(edit.ctx)
379 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
381 _, additions, removals := diff.GenerateDiff(
384 strings.TrimPrefix(filePath, edit.workingDir),
387 p := edit.permissions.Request(
388 permission.CreatePermissionRequest{
389 SessionID: sessionID,
390 Path: fsext.PathOrPrefix(filePath, edit.workingDir),
392 ToolName: EditToolName,
394 Description: fmt.Sprintf("Replace content in file %s", filePath),
395 Params: EditPermissionsParams{
397 OldContent: oldContent,
398 NewContent: newContent,
403 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
407 newContent, _ = fsext.ToWindowsLineEndings(newContent)
410 err = os.WriteFile(filePath, []byte(newContent), 0o644)
412 return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
415 // Check if file exists in history
416 file, err := edit.files.GetByPathAndSession(edit.ctx, filePath, sessionID)
418 _, err = edit.files.Create(edit.ctx, sessionID, filePath, oldContent)
420 // Log error but don't fail the operation
421 return fantasy.ToolResponse{}, fmt.Errorf("error creating file history: %w", err)
424 if file.Content != oldContent {
425 // User Manually changed the content store an intermediate version
426 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, oldContent)
428 slog.Debug("Error creating file history version", "error", err)
431 // Store the new version
432 _, err = edit.files.CreateVersion(edit.ctx, sessionID, filePath, newContent)
434 slog.Error("Error creating file history version", "error", err)
437 recordFileWrite(filePath)
438 recordFileRead(filePath)
440 return fantasy.WithResponseMetadata(
441 fantasy.NewTextResponse("Content replaced in file: "+filePath),
442 EditResponseMetadata{
443 OldContent: oldContent,
444 NewContent: newContent,
445 Additions: additions,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list