@@ -10,9 +10,9 @@ import (
1010 "time"
1111
1212 "github.com/kujtimiihoxha/termai/internal/config"
13+ "github.com/kujtimiihoxha/termai/internal/git"
1314 "github.com/kujtimiihoxha/termai/internal/lsp"
1415 "github.com/kujtimiihoxha/termai/internal/permission"
15− "github.com/sergi/go-diff/diffmatchpatch"
1616)
1717
1818type EditParams struct {
@@ -22,10 +22,13 @@ type EditParams struct {
2222}
2323
2424type EditPermissionsParams struct {
25− FilePath string `json:"file_path"`
26− OldString string `json:"old_string"`
27− NewString string `json:"new_string"`
28− Diff string `json:"diff"`
25+ FilePath string `json:"file_path"`
26+ Diff string `json:"diff"`
27+}
28+
29+type EditResponseMetadata struct {
30+ Additions int `json:"additions"`
31+ Removals int `json:"removals"`
2932}
3033
3134type editTool struct {
@@ -129,48 +132,77 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
129132 }
130133
131134 if params.OldString == "" {
132− result, err := e.createNewFile(params.FilePath, params.NewString)
135+ result, err := e.createNewFile(ctx, params.FilePath, params.NewString)
133136 if err != nil {
134137 return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
135138 }
136− return NewTextResponse(result), nil
139+ return WithResponseMetadata(NewTextResponse(result.text), EditResponseMetadata{
140+ Additions: result.additions,
141+ Removals: result.removals,
142+ }), nil
137143 }
138144
139145 if params.NewString == "" {
140− result, err := e.deleteContent(params.FilePath, params.OldString)
146+ result, err := e.deleteContent(ctx, params.FilePath, params.OldString)
141147 if err != nil {
142148 return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
143149 }
144− return NewTextResponse(result), nil
150+ return WithResponseMetadata(NewTextResponse(result.text), EditResponseMetadata{
151+ Additions: result.additions,
152+ Removals: result.removals,
153+ }), nil
145154 }
146155
147− result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString)
156+ result, err := e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
148157 if err != nil {
149158 return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
150159 }
151160
152161 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
153− result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
154− result += appendDiagnostics(params.FilePath, e.lspClients)
155− return NewTextResponse(result), nil
162+ text := fmt.Sprintf("<result>\n%s\n</result>\n", result.text)
163+ text += appendDiagnostics(params.FilePath, e.lspClients)
164+ return WithResponseMetadata(NewTextResponse(text), EditResponseMetadata{
165+ Additions: result.additions,
166+ Removals: result.removals,
167+ }), nil
168+}
169+
170+type editResponse struct {
171+ text string
172+ additions int
173+ removals int
156174}
157175
158−func (e *editTool) createNewFile(filePath, content string) (string, error) {
176+func (e *editTool) createNewFile(ctx context.Context, filePath, content string) (editResponse, error) {
177+ er := editResponse{}
159178 fileInfo, err := os.Stat(filePath)
160179 if err == nil {
161180 if fileInfo.IsDir() {
162− return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
181+ return er, fmt.Errorf("path is a directory, not a file: %s", filePath)
163182 }
164− return "", fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
183+ return er, fmt.Errorf("file already exists: %s. Use the Replace tool to overwrite an existing file", filePath)
165184 } else if !os.IsNotExist(err) {
166− return "", fmt.Errorf("failed to access file: %w", err)
185+ return er, fmt.Errorf("failed to access file: %w", err)
167186 }
168187
169188 dir := filepath.Dir(filePath)
170189 if err = os.MkdirAll(dir, 0o755); err != nil {
171− return "", fmt.Errorf("failed to create parent directories: %w", err)
190+ return er, fmt.Errorf("failed to create parent directories: %w", err)
172191 }
173192
193+ sessionID, messageID := getContextValues(ctx)
194+ if sessionID == "" || messageID == "" {
195+ return er, fmt.Errorf("session ID and message ID are required for creating a new file")
196+ }
197+
198+ diff, stats, err := git.GenerateGitDiffWithStats(
199+ removeWorkingDirectoryPrefix(filePath),
200+ "",
201+ content,
202+ )
203+ if err != nil {
204+ return er, fmt.Errorf("failed to get file diff: %w", err)
205+ }
174206 p := e.permissions.Request(
175207 permission.CreatePermissionRequest{
176208 Path: filepath.Dir(filePath),
@@ -178,71 +210,88 @@ func (e *editTool) createNewFile(filePath, content string) (string, error) {
178210 Action: "create",
179211 Description: fmt.Sprintf("Create file %s", filePath),
180212 Params: EditPermissionsParams{
181− FilePath: filePath,
182− OldString: "",
183− NewString: content,
184− Diff: GenerateDiff("", content),
213+ FilePath: filePath,
214+ Diff: diff,
185215 },
186216 },
187217 )
188218 if !p {
189− return "", fmt.Errorf("permission denied")
219+ return er, fmt.Errorf("permission denied")
190220 }
191221
192222 err = os.WriteFile(filePath, []byte(content), 0o644)
193223 if err != nil {
194− return "", fmt.Errorf("failed to write file: %w", err)
224+ return er, fmt.Errorf("failed to write file: %w", err)
195225 }
196226
197227 recordFileWrite(filePath)
198228 recordFileRead(filePath)
199229
200− return "File created: " + filePath, nil
230+ er.text = "File created: " + filePath
231+ er.additions = stats.Additions
232+ er.removals = stats.Removals
233+ return er, nil
201234}
202235
203−func (e *editTool) deleteContent(filePath, oldString string) (string, error) {
236+func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string) (editResponse, error) {
237+ er := editResponse{}
204238 fileInfo, err := os.Stat(filePath)
205239 if err != nil {
206240 if os.IsNotExist(err) {
207− return "", fmt.Errorf("file not found: %s", filePath)
241+ return er, fmt.Errorf("file not found: %s", filePath)
208242 }
209− return "", fmt.Errorf("failed to access file: %w", err)
243+ return er, fmt.Errorf("failed to access file: %w", err)
210244 }
211245
212246 if fileInfo.IsDir() {
213− return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
247+ return er, fmt.Errorf("path is a directory, not a file: %s", filePath)
214248 }
215249
216250 if getLastReadTime(filePath).IsZero() {
217− return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
251+ return er, fmt.Errorf("you must read the file before editing it. Use the View tool first")
218252 }
219253
220254 modTime := fileInfo.ModTime()
221255 lastRead := getLastReadTime(filePath)
222256 if modTime.After(lastRead) {
223− return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
257+ return er, fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
224258 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
225259 }
226260
227261 content, err := os.ReadFile(filePath)
228262 if err != nil {
229− return "", fmt.Errorf("failed to read file: %w", err)
263+ return er, fmt.Errorf("failed to read file: %w", err)
230264 }
231265
232266 oldContent := string(content)
233267
234268 index := strings.Index(oldContent, oldString)
235269 if index == -1 {
236− return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
270+ return er, fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
237271 }
238272
239273 lastIndex := strings.LastIndex(oldContent, oldString)
240274 if index != lastIndex {
241− return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
275+ return er, fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
242276 }
243277
244278 newContent := oldContent[:index] + oldContent[index+len(oldString):]
245279
280+ sessionID, messageID := getContextValues(ctx)
281+
282+ if sessionID == "" || messageID == "" {
283+ return er, fmt.Errorf("session ID and message ID are required for creating a new file")
284+ }
285+
286+ diff, stats, err := git.GenerateGitDiffWithStats(
287+ removeWorkingDirectoryPrefix(filePath),
288+ oldContent,
289+ newContent,
290+ )
291+ if err != nil {
292+ return er, fmt.Errorf("failed to get file diff: %w", err)
293+ }
294+
246295 p := e.permissions.Request(
247296 permission.CreatePermissionRequest{
248297 Path: filepath.Dir(filePath),
@@ -250,76 +299,85 @@ func (e *editTool) deleteContent(filePath, oldString string) (string, error) {
250299 Action: "delete",
251300 Description: fmt.Sprintf("Delete content from file %s", filePath),
252301 Params: EditPermissionsParams{
253− FilePath: filePath,
254− OldString: oldString,
255− NewString: "",
256− Diff: GenerateDiff(oldContent, newContent),
302+ FilePath: filePath,
303+ Diff: diff,
257304 },
258305 },
259306 )
260307 if !p {
261− return "", fmt.Errorf("permission denied")
308+ return er, fmt.Errorf("permission denied")
262309 }
263310
264311 err = os.WriteFile(filePath, []byte(newContent), 0o644)
265312 if err != nil {
266− return "", fmt.Errorf("failed to write file: %w", err)
313+ return er, fmt.Errorf("failed to write file: %w", err)
267314 }
268−
269315 recordFileWrite(filePath)
270316 recordFileRead(filePath)
271317
272− return "Content deleted from file: " + filePath, nil
318+ er.text = "Content deleted from file: " + filePath
319+ er.additions = stats.Additions
320+ er.removals = stats.Removals
321+ return er, nil
273322}
274323
275−func (e *editTool) replaceContent(filePath, oldString, newString string) (string, error) {
324+func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newString string) (editResponse, error) {
325+ er := editResponse{}
276326 fileInfo, err := os.Stat(filePath)
277327 if err != nil {
278328 if os.IsNotExist(err) {
279− return "", fmt.Errorf("file not found: %s", filePath)
329+ return er, fmt.Errorf("file not found: %s", filePath)
280330 }
281− return "", fmt.Errorf("failed to access file: %w", err)
331+ return er, fmt.Errorf("failed to access file: %w", err)
282332 }
283333
284334 if fileInfo.IsDir() {
285− return "", fmt.Errorf("path is a directory, not a file: %s", filePath)
335+ return er, fmt.Errorf("path is a directory, not a file: %s", filePath)
286336 }
287337
288338 if getLastReadTime(filePath).IsZero() {
289− return "", fmt.Errorf("you must read the file before editing it. Use the View tool first")
339+ return er, fmt.Errorf("you must read the file before editing it. Use the View tool first")
290340 }
291341
292342 modTime := fileInfo.ModTime()
293343 lastRead := getLastReadTime(filePath)
294344 if modTime.After(lastRead) {
295− return "", fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
345+ return er, fmt.Errorf("file %s has been modified since it was last read (mod time: %s, last read: %s)",
296346 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
297347 }
298348
299349 content, err := os.ReadFile(filePath)
300350 if err != nil {
301− return "", fmt.Errorf("failed to read file: %w", err)
351+ return er, fmt.Errorf("failed to read file: %w", err)
302352 }
303353
304354 oldContent := string(content)
305355
306356 index := strings.Index(oldContent, oldString)
307357 if index == -1 {
308− return "", fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
358+ return er, fmt.Errorf("old_string not found in file. Make sure it matches exactly, including whitespace and line breaks")
309359 }
310360
311361 lastIndex := strings.LastIndex(oldContent, oldString)
312362 if index != lastIndex {
313− return "", fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
363+ return er, fmt.Errorf("old_string appears multiple times in the file. Please provide more context to ensure a unique match")
314364 }
315365
316366 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
317367
318− startIndex := max(0, index-3)
319− oldEndIndex := min(len(oldContent), index+len(oldString)+3)
320− newEndIndex := min(len(newContent), index+len(newString)+3)
368+ sessionID, messageID := getContextValues(ctx)
321369
322− diff := GenerateDiff(oldContent[startIndex:oldEndIndex], newContent[startIndex:newEndIndex])
370+ if sessionID == "" || messageID == "" {
371+ return er, fmt.Errorf("session ID and message ID are required for creating a new file")
372+ }
373+ diff, stats, err := git.GenerateGitDiffWithStats(
374+ removeWorkingDirectoryPrefix(filePath),
375+ oldContent,
376+ newContent,
377+ )
378+ if err != nil {
379+ return er, fmt.Errorf("failed to get file diff: %w", err)
380+ }
323381
324382 p := e.permissions.Request(
325383 permission.CreatePermissionRequest{
@@ -328,75 +386,27 @@ func (e *editTool) replaceContent(filePath, oldString, newString string) (string
328386 Action: "replace",
329387 Description: fmt.Sprintf("Replace content in file %s", filePath),
330388 Params: EditPermissionsParams{
331− FilePath: filePath,
332− OldString: oldString,
333− NewString: newString,
334− Diff: diff,
389+ FilePath: filePath,
390+
391+ Diff: diff,
335392 },
336393 },
337394 )
338395 if !p {
339− return "", fmt.Errorf("permission denied")
396+ return er, fmt.Errorf("permission denied")
340397 }
341398
342399 err = os.WriteFile(filePath, []byte(newContent), 0o644)
343400 if err != nil {
344− return "", fmt.Errorf("failed to write file: %w", err)
401+ return er, fmt.Errorf("failed to write file: %w", err)
345402 }
346403
347404 recordFileWrite(filePath)
348405 recordFileRead(filePath)
406+ er.text = "Content replaced in file: " + filePath
407+ er.additions = stats.Additions
408+ er.removals = stats.Removals
349409
350− return "Content replaced in file: " + filePath, nil
410+ return er, nil
351411}
352412
353−func GenerateDiff(oldContent, newContent string) string {
354− dmp := diffmatchpatch.New()
355− fileAdmp, fileBdmp, dmpStrings := dmp.DiffLinesToChars(oldContent, newContent)
356− diffs := dmp.DiffMain(fileAdmp, fileBdmp, false)
357− diffs = dmp.DiffCharsToLines(diffs, dmpStrings)
358− diffs = dmp.DiffCleanupSemantic(diffs)
359− buff := strings.Builder{}
360−
361− buff.WriteString("Changes:\n")
362−
363− for _, diff := range diffs {
364− text := diff.Text
365−
366− switch diff.Type {
367− case diffmatchpatch.DiffInsert:
368− for line := range strings.SplitSeq(text, "\n") {
369− if line == "" {
370− continue
371− }
372− _, _ = buff.WriteString("+ " + line + "\n")
373− }
374− case diffmatchpatch.DiffDelete:
375− for line := range strings.SplitSeq(text, "\n") {
376− if line == "" {
377− continue
378− }
379− _, _ = buff.WriteString("- " + line + "\n")
380− }
381− case diffmatchpatch.DiffEqual:
382− lines := strings.Split(text, "\n")
383− if len(lines) > 3 {
384− if lines[0] != "" {
385− _, _ = buff.WriteString(" " + lines[0] + "\n")
386− }
387− _, _ = buff.WriteString(" ...\n")
388− if lines[len(lines)-1] != "" {
389− _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n")
390− }
391− } else {
392− for _, line := range lines {
393− if line == "" {
394− continue
395− }
396− _, _ = buff.WriteString(" " + line + "\n")
397− }
398− }
399− }
400− }
401− return buff.String()
402−}