@@ -10,9 +10,9 @@ import (
12 "github.com/kujtimiihoxha/termai/internal/config"
12 "github.com/kujtimiihoxha/termai/internal/config"
13+ "github.com/kujtimiihoxha/termai/internal/git"
13 "github.com/kujtimiihoxha/termai/internal/lsp"
14 "github.com/kujtimiihoxha/termai/internal/lsp"
14 "github.com/kujtimiihoxha/termai/internal/permission"
15 "github.com/kujtimiihoxha/termai/internal/permission"
15− "github.com/sergi/go-diff/diffmatchpatch"
18type EditParams struct {
18type EditParams struct {
@@ -22,10 +22,13 @@ type EditParams struct {
24type EditPermissionsParams struct {
24type EditPermissionsParams struct {
25− FilePath string `json:"file_path"`
25+ FilePath string `json:"file_path"`
26− OldString string `json:"old_string"`
27− NewString string `json:"new_string"`
28− Diff string `json:"diff"`
26+ Diff string `json:"diff"`
29+type EditResponseMetadata struct {
30+ Additions int `json:"additions"`
31+ Removals int `json:"removals"`
31type editTool struct {
34type editTool struct {
@@ -129,48 +132,77 @@ func (e *editTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
131 if params.OldString == "" {
134 if params.OldString == "" {
132− result, err := e.createNewFile(params.FilePath, params.NewString)
135+ result, err := e.createNewFile(ctx, params.FilePath, params.NewString)
133 if err != nil {
136 if err != nil {
134 return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
137 return NewTextErrorResponse(fmt.Sprintf("error creating file: %s", err)), nil
136− return NewTextResponse(result), nil
139+ return WithResponseMetadata(NewTextResponse(result.text), EditResponseMetadata{
140+ Additions: result.additions,
141+ Removals: result.removals,
139 if params.NewString == "" {
145 if params.NewString == "" {
140− result, err := e.deleteContent(params.FilePath, params.OldString)
146+ result, err := e.deleteContent(ctx, params.FilePath, params.OldString)
141 if err != nil {
147 if err != nil {
142 return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
148 return NewTextErrorResponse(fmt.Sprintf("error deleting content: %s", err)), nil
144− return NewTextResponse(result), nil
150+ return WithResponseMetadata(NewTextResponse(result.text), EditResponseMetadata{
151+ Additions: result.additions,
152+ Removals: result.removals,
147− result, err := e.replaceContent(params.FilePath, params.OldString, params.NewString)
156+ result, err := e.replaceContent(ctx, params.FilePath, params.OldString, params.NewString)
148 if err != nil {
157 if err != nil {
149 return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
158 return NewTextErrorResponse(fmt.Sprintf("error replacing content: %s", err)), nil
152 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
161 waitForLspDiagnostics(ctx, params.FilePath, e.lspClients)
153− result = fmt.Sprintf("<result>\n%s\n</result>\n", result)
162+ text := fmt.Sprintf("<result>\n%s\n</result>\n", result.text)
154− result += appendDiagnostics(params.FilePath, e.lspClients)
163+ text += appendDiagnostics(params.FilePath, e.lspClients)
155− return NewTextResponse(result), nil
164+ return WithResponseMetadata(NewTextResponse(text), EditResponseMetadata{
165+ Additions: result.additions,
166+ Removals: result.removals,
170+type editResponse struct {
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{}
159 fileInfo, err := os.Stat(filePath)
178 fileInfo, err := os.Stat(filePath)
160 if err == nil {
179 if err == nil {
161 if fileInfo.IsDir() {
180 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)
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)
165 } else if !os.IsNotExist(err) {
184 } 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)
169 dir := filepath.Dir(filePath)
188 dir := filepath.Dir(filePath)
170 if err = os.MkdirAll(dir, 0o755); err != nil {
189 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)
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")
198+ diff, stats, err := git.GenerateGitDiffWithStats(
199+ removeWorkingDirectoryPrefix(filePath),
204+ return er, fmt.Errorf("failed to get file diff: %w", err)
174 p := e.permissions.Request(
206 p := e.permissions.Request(
175 permission.CreatePermissionRequest{
207 permission.CreatePermissionRequest{
176 Path: filepath.Dir(filePath),
208 Path: filepath.Dir(filePath),
@@ -178,71 +210,88 @@ func (e *editTool) createNewFile(filePath, content string) (string, error) {
178 Action: "create",
210 Action: "create",
179 Description: fmt.Sprintf("Create file %s", filePath),
211 Description: fmt.Sprintf("Create file %s", filePath),
180 Params: EditPermissionsParams{
212 Params: EditPermissionsParams{
181− FilePath: filePath,
213+ FilePath: filePath,
182− OldString: "",
214+ Diff: diff,
184− Diff: GenerateDiff("", content),
189− return "", fmt.Errorf("permission denied")
219+ return er, fmt.Errorf("permission denied")
192 err = os.WriteFile(filePath, []byte(content), 0o644)
222 err = os.WriteFile(filePath, []byte(content), 0o644)
193 if err != nil {
223 if err != nil {
194− return "", fmt.Errorf("failed to write file: %w", err)
224+ return er, fmt.Errorf("failed to write file: %w", err)
197 recordFileWrite(filePath)
227 recordFileWrite(filePath)
198 recordFileRead(filePath)
228 recordFileRead(filePath)
200− return "File created: " + filePath, nil
230+ er.text = "File created: " + filePath
231+ er.additions = stats.Additions
232+ er.removals = stats.Removals
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{}
204 fileInfo, err := os.Stat(filePath)
238 fileInfo, err := os.Stat(filePath)
205 if err != nil {
239 if err != nil {
206 if os.IsNotExist(err) {
240 if os.IsNotExist(err) {
207− return "", fmt.Errorf("file not found: %s", filePath)
241+ return er, fmt.Errorf("file not found: %s", filePath)
209− return "", fmt.Errorf("failed to access file: %w", err)
243+ return er, fmt.Errorf("failed to access file: %w", err)
212 if fileInfo.IsDir() {
246 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)
216 if getLastReadTime(filePath).IsZero() {
250 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")
220 modTime := fileInfo.ModTime()
254 modTime := fileInfo.ModTime()
221 lastRead := getLastReadTime(filePath)
255 lastRead := getLastReadTime(filePath)
222 if modTime.After(lastRead) {
256 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)",
224 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
258 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
227 content, err := os.ReadFile(filePath)
261 content, err := os.ReadFile(filePath)
228 if err != nil {
262 if err != nil {
229− return "", fmt.Errorf("failed to read file: %w", err)
263+ return er, fmt.Errorf("failed to read file: %w", err)
232 oldContent := string(content)
266 oldContent := string(content)
234 index := strings.Index(oldContent, oldString)
268 index := strings.Index(oldContent, oldString)
235 if index == -1 {
269 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")
239 lastIndex := strings.LastIndex(oldContent, oldString)
273 lastIndex := strings.LastIndex(oldContent, oldString)
240 if index != lastIndex {
274 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")
244 newContent := oldContent[:index] + oldContent[index+len(oldString):]
278 newContent := oldContent[:index] + oldContent[index+len(oldString):]
280+ sessionID, messageID := getContextValues(ctx)
282+ if sessionID == "" || messageID == "" {
283+ return er, fmt.Errorf("session ID and message ID are required for creating a new file")
286+ diff, stats, err := git.GenerateGitDiffWithStats(
287+ removeWorkingDirectoryPrefix(filePath),
292+ return er, fmt.Errorf("failed to get file diff: %w", err)
246 p := e.permissions.Request(
295 p := e.permissions.Request(
247 permission.CreatePermissionRequest{
296 permission.CreatePermissionRequest{
248 Path: filepath.Dir(filePath),
297 Path: filepath.Dir(filePath),
@@ -250,76 +299,85 @@ func (e *editTool) deleteContent(filePath, oldString string) (string, error) {
250 Action: "delete",
299 Action: "delete",
251 Description: fmt.Sprintf("Delete content from file %s", filePath),
300 Description: fmt.Sprintf("Delete content from file %s", filePath),
252 Params: EditPermissionsParams{
301 Params: EditPermissionsParams{
253− FilePath: filePath,
302+ FilePath: filePath,
254− OldString: oldString,
303+ Diff: diff,
256− Diff: GenerateDiff(oldContent, newContent),
261− return "", fmt.Errorf("permission denied")
308+ return er, fmt.Errorf("permission denied")
264 err = os.WriteFile(filePath, []byte(newContent), 0o644)
311 err = os.WriteFile(filePath, []byte(newContent), 0o644)
265 if err != nil {
312 if err != nil {
266− return "", fmt.Errorf("failed to write file: %w", err)
313+ return er, fmt.Errorf("failed to write file: %w", err)
269 recordFileWrite(filePath)
315 recordFileWrite(filePath)
270 recordFileRead(filePath)
316 recordFileRead(filePath)
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
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{}
276 fileInfo, err := os.Stat(filePath)
326 fileInfo, err := os.Stat(filePath)
277 if err != nil {
327 if err != nil {
278 if os.IsNotExist(err) {
328 if os.IsNotExist(err) {
279− return "", fmt.Errorf("file not found: %s", filePath)
329+ return er, fmt.Errorf("file not found: %s", filePath)
281− return "", fmt.Errorf("failed to access file: %w", err)
331+ return er, fmt.Errorf("failed to access file: %w", err)
284 if fileInfo.IsDir() {
334 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)
288 if getLastReadTime(filePath).IsZero() {
338 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")
292 modTime := fileInfo.ModTime()
342 modTime := fileInfo.ModTime()
293 lastRead := getLastReadTime(filePath)
343 lastRead := getLastReadTime(filePath)
294 if modTime.After(lastRead) {
344 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)",
296 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
346 filePath, modTime.Format(time.RFC3339), lastRead.Format(time.RFC3339))
299 content, err := os.ReadFile(filePath)
349 content, err := os.ReadFile(filePath)
300 if err != nil {
350 if err != nil {
301− return "", fmt.Errorf("failed to read file: %w", err)
351+ return er, fmt.Errorf("failed to read file: %w", err)
304 oldContent := string(content)
354 oldContent := string(content)
306 index := strings.Index(oldContent, oldString)
356 index := strings.Index(oldContent, oldString)
307 if index == -1 {
357 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")
311 lastIndex := strings.LastIndex(oldContent, oldString)
361 lastIndex := strings.LastIndex(oldContent, oldString)
312 if index != lastIndex {
362 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")
316 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
366 newContent := oldContent[:index] + newString + oldContent[index+len(oldString):]
318− startIndex := max(0, index-3)
368+ sessionID, messageID := getContextValues(ctx)
319− oldEndIndex := min(len(oldContent), index+len(oldString)+3)
320− newEndIndex := min(len(newContent), index+len(newString)+3)
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")
373+ diff, stats, err := git.GenerateGitDiffWithStats(
374+ removeWorkingDirectoryPrefix(filePath),
379+ return er, fmt.Errorf("failed to get file diff: %w", err)
324 p := e.permissions.Request(
382 p := e.permissions.Request(
325 permission.CreatePermissionRequest{
383 permission.CreatePermissionRequest{
@@ -328,75 +386,27 @@ func (e *editTool) replaceContent(filePath, oldString, newString string) (string
328 Action: "replace",
386 Action: "replace",
329 Description: fmt.Sprintf("Replace content in file %s", filePath),
387 Description: fmt.Sprintf("Replace content in file %s", filePath),
330 Params: EditPermissionsParams{
388 Params: EditPermissionsParams{
331− FilePath: filePath,
389+ FilePath: filePath,
332− OldString: oldString,
390+
333− NewString: newString,
334− Diff: diff,
391+ Diff: diff,
339− return "", fmt.Errorf("permission denied")
396+ return er, fmt.Errorf("permission denied")
342 err = os.WriteFile(filePath, []byte(newContent), 0o644)
399 err = os.WriteFile(filePath, []byte(newContent), 0o644)
343 if err != nil {
400 if err != nil {
344− return "", fmt.Errorf("failed to write file: %w", err)
401+ return er, fmt.Errorf("failed to write file: %w", err)
347 recordFileWrite(filePath)
404 recordFileWrite(filePath)
348 recordFileRead(filePath)
405 recordFileRead(filePath)
406+ er.text = "Content replaced in file: " + filePath
407+ er.additions = stats.Additions
408+ er.removals = stats.Removals
350− return "Content replaced in file: " + filePath, nil
410+ return er, nil
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{}
361− buff.WriteString("Changes:\n")
363− for _, diff := range diffs {
367− case diffmatchpatch.DiffInsert:
368− for line := range strings.SplitSeq(text, "\n") {
372− _, _ = buff.WriteString("+ " + line + "\n")
374− case diffmatchpatch.DiffDelete:
375− for line := range strings.SplitSeq(text, "\n") {
379− _, _ = buff.WriteString("- " + line + "\n")
381− case diffmatchpatch.DiffEqual:
382− lines := strings.Split(text, "\n")
385− _, _ = buff.WriteString(" " + lines[0] + "\n")
387− _, _ = buff.WriteString(" ...\n")
388− if lines[len(lines)-1] != "" {
389− _, _ = buff.WriteString(" " + lines[len(lines)-1] + "\n")
392− for _, line := range lines {
396− _, _ = buff.WriteString(" " + line + "\n")
401− return buff.String()