Skip to content

Commit 6f79da1

Browse files
committed
improvements to edit mode
1 parent 30a71fd commit 6f79da1

8 files changed

Lines changed: 500 additions & 166 deletions

File tree

cmd/edit.go

Lines changed: 99 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ func init() {
6262
// FileSpec represents a file with optional line range guard
6363
type FileSpec struct {
6464
Path string
65-
StartLine int // 1-indexed, 0 means from beginning
66-
EndLine int // 1-indexed, 0 means to end
65+
StartLine int // 1-indexed, 0 means from beginning
66+
EndLine int // 1-indexed, 0 means to end
6767
HasGuard bool
6868
}
6969

@@ -185,7 +185,7 @@ func runEdit(cmd *cobra.Command, args []string) error {
185185

186186
// Build prompts
187187
systemPrompt := buildEditSystemPrompt(cfg.Edit.Instructions, specs)
188-
userPrompt := buildEditUserPrompt(request, files)
188+
userPrompt := buildEditUserPrompt(request, files, specs)
189189

190190
// Track file contents for applying edits
191191
fileContents := make(map[string]string)
@@ -259,27 +259,30 @@ func processEditsConsolidated(edits []llm.EditToolCall, fileContents map[string]
259259
continue
260260
}
261261

262-
if !strings.Contains(currentContent, edit.OldString) {
263-
cf.skipReasons = append(cf.skipReasons, fmt.Sprintf("old_string not found: %.40s...", edit.OldString))
264-
continue
262+
// Find the matching spec to check for guards
263+
var matchSpec *FileSpec
264+
for i := range specs {
265+
if specs[i].Path == path {
266+
matchSpec = &specs[i]
267+
break
268+
}
265269
}
266270

267-
// Check guard
268-
guardErr := ""
269-
for _, spec := range specs {
270-
if spec.Path == path && spec.HasGuard {
271-
if err := validateGuardForReplace(currentContent, edit, spec); err != nil {
272-
guardErr = err.Error()
273-
break
274-
}
275-
}
271+
var match editMatch
272+
var err error
273+
if matchSpec != nil && matchSpec.HasGuard {
274+
// Use guard-scoped matching
275+
match, err = findEditMatchWithGuard(currentContent, edit.OldString, matchSpec.StartLine, matchSpec.EndLine)
276+
} else {
277+
// No guard, search full content
278+
match, err = findEditMatch(currentContent, edit.OldString)
276279
}
277-
if guardErr != "" {
278-
cf.skipReasons = append(cf.skipReasons, guardErr)
280+
if err != nil {
281+
cf.skipReasons = append(cf.skipReasons, err.Error())
279282
continue
280283
}
281284

282-
currentContent = strings.Replace(currentContent, edit.OldString, edit.NewString, 1)
285+
currentContent = applyEditMatch(currentContent, match, edit.NewString)
283286
cf.editCount++
284287
}
285288

@@ -376,27 +379,33 @@ func processEditsIndividually(edits []llm.EditToolCall, fileContents map[string]
376379
continue
377380
}
378381

379-
if !strings.Contains(content, editCall.OldString) {
382+
// Find the matching spec to check for guards
383+
var matchSpec *FileSpec
384+
for i := range specs {
385+
if specs[i].Path == editCall.FilePath {
386+
matchSpec = &specs[i]
387+
break
388+
}
389+
}
390+
391+
var match editMatch
392+
var err error
393+
if matchSpec != nil && matchSpec.HasGuard {
394+
// Use guard-scoped matching
395+
match, err = findEditMatchWithGuard(content, editCall.OldString, matchSpec.StartLine, matchSpec.EndLine)
396+
} else {
397+
// No guard, search full content
398+
match, err = findEditMatch(content, editCall.OldString)
399+
}
400+
if err != nil {
380401
pe.skip = true
381-
pe.skipReason = "old_string not found"
402+
pe.skipReason = err.Error()
382403
processed = append(processed, pe)
383404
continue
384405
}
385406

386-
for _, spec := range specs {
387-
if spec.Path == editCall.FilePath && spec.HasGuard {
388-
if err := validateGuardForReplace(content, editCall, spec); err != nil {
389-
pe.skip = true
390-
pe.skipReason = err.Error()
391-
break
392-
}
393-
}
394-
}
395-
396-
if !pe.skip {
397-
pe.oldContent = content
398-
pe.newContent = strings.Replace(content, editCall.OldString, editCall.NewString, 1)
399-
}
407+
pe.oldContent = content
408+
pe.newContent = applyEditMatch(content, match, editCall.NewString)
400409
processed = append(processed, pe)
401410
}
402411

@@ -464,27 +473,33 @@ Context:
464473
base += fmt.Sprintf("\n- User Context: %s", instructions)
465474
}
466475

467-
base += `
476+
base += fmt.Sprintf(`
468477
469478
Rules:
470479
1. Make minimal, focused changes
471480
2. Preserve existing code style
472481
3. Use the edit tool for each change - you can call it multiple times
473482
4. The edit tool does find/replace: old_string must match exactly
474-
5. Include enough context in old_string to be unique`
483+
5. You may include the literal token %s in old_string to match any sequence of characters (including newlines)
484+
6. Include enough context in old_string (especially around %s) to be unique`, editWildcardToken, editWildcardToken)
475485

476486
// Add guard info
487+
hasGuards := false
477488
for _, spec := range specs {
478489
if spec.HasGuard {
479-
base += fmt.Sprintf("\n\nIMPORTANT: For %s, only modify lines %d-%d.",
490+
hasGuards = true
491+
base += fmt.Sprintf("\n\nIMPORTANT: For %s, only modify lines %d-%d. The <editable-region> block shows the exact content you may edit with line numbers.",
480492
spec.Path, spec.StartLine, spec.EndLine)
481493
}
482494
}
495+
if hasGuards {
496+
base += "\n\nYour old_string MUST match text within the editable region. Use the line numbers in <editable-region> to ensure your edit is within bounds."
497+
}
483498

484499
return base
485500
}
486501

487-
func buildEditUserPrompt(request string, files []input.FileContent) string {
502+
func buildEditUserPrompt(request string, files []input.FileContent, specs []FileSpec) string {
488503
var sb strings.Builder
489504

490505
sb.WriteString("Files:\n\n")
@@ -497,20 +512,59 @@ func buildEditUserPrompt(request string, files []input.FileContent) string {
497512
sb.WriteString("</file>\n\n")
498513
}
499514

515+
// Add editable region blocks for guarded files
516+
for _, spec := range specs {
517+
if spec.HasGuard {
518+
// Find the matching file content
519+
for _, f := range files {
520+
if f.Path == spec.Path {
521+
excerpt := extractLineRange(f.Content, spec.StartLine, spec.EndLine)
522+
sb.WriteString(fmt.Sprintf("<editable-region path=\"%s\" lines=\"%d-%d\">\n",
523+
spec.Path, spec.StartLine, spec.EndLine))
524+
sb.WriteString(excerpt)
525+
if !strings.HasSuffix(excerpt, "\n") {
526+
sb.WriteString("\n")
527+
}
528+
sb.WriteString("</editable-region>\n\n")
529+
break
530+
}
531+
}
532+
}
533+
}
534+
500535
sb.WriteString(fmt.Sprintf("Request: %s", request))
501536
return sb.String()
502537
}
503538

504-
func validateGuardForReplace(content string, editCall llm.EditToolCall, spec FileSpec) error {
505-
// Find where old_string appears
506-
idx := strings.Index(content, editCall.OldString)
507-
if idx < 0 {
508-
return fmt.Errorf("old_string not found")
539+
// extractLineRange extracts lines startLine to endLine (1-indexed, inclusive) from content
540+
func extractLineRange(content string, startLine, endLine int) string {
541+
lines := strings.Split(content, "\n")
542+
543+
// Adjust for 0-based indexing
544+
start := startLine - 1
545+
if start < 0 {
546+
start = 0
547+
}
548+
end := endLine
549+
if end <= 0 || end > len(lines) {
550+
end = len(lines)
551+
}
552+
if start >= len(lines) {
553+
return ""
509554
}
510555

556+
// Build output with line numbers
557+
var sb strings.Builder
558+
for i := start; i < end; i++ {
559+
sb.WriteString(fmt.Sprintf("%d: %s\n", i+1, lines[i]))
560+
}
561+
return strings.TrimSuffix(sb.String(), "\n")
562+
}
563+
564+
func validateGuardForReplace(content string, match editMatch, spec FileSpec) error {
511565
// Count lines before
512-
lineNum := strings.Count(content[:idx], "\n") + 1
513-
endLineNum := lineNum + strings.Count(editCall.OldString, "\n")
566+
lineNum := strings.Count(content[:match.start], "\n") + 1
567+
endLineNum := lineNum + strings.Count(match.text, "\n")
514568

515569
// Check if within guard
516570
if spec.StartLine > 0 && lineNum < spec.StartLine {

cmd/edit_match.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
const editWildcardToken = "<<<elided>>>"
9+
10+
type editMatch struct {
11+
start int
12+
end int
13+
text string
14+
}
15+
16+
func findEditMatch(content, oldString string) (editMatch, error) {
17+
if oldString == "" {
18+
return editMatch{}, fmt.Errorf("old_string is empty")
19+
}
20+
21+
if !strings.Contains(oldString, editWildcardToken) {
22+
idx := strings.Index(content, oldString)
23+
if idx < 0 {
24+
return editMatch{}, fmt.Errorf("old_string not found: %.40s...", oldString)
25+
}
26+
return editMatch{
27+
start: idx,
28+
end: idx + len(oldString),
29+
text: oldString,
30+
}, nil
31+
}
32+
33+
segments := strings.Split(oldString, editWildcardToken)
34+
foundSegment := false
35+
foundFirst := false
36+
start := 0
37+
end := 0
38+
searchFrom := 0
39+
40+
for _, segment := range segments {
41+
if segment == "" {
42+
continue
43+
}
44+
foundSegment = true
45+
idx := strings.Index(content[searchFrom:], segment)
46+
if idx < 0 {
47+
return editMatch{}, fmt.Errorf("wildcard match failed: %.40s...", oldString)
48+
}
49+
absIdx := searchFrom + idx
50+
if !foundFirst {
51+
start = absIdx
52+
foundFirst = true
53+
}
54+
searchFrom = absIdx + len(segment)
55+
end = searchFrom
56+
}
57+
58+
if !foundSegment {
59+
return editMatch{}, fmt.Errorf("wildcard token used without anchors")
60+
}
61+
62+
return editMatch{
63+
start: start,
64+
end: end,
65+
text: content[start:end],
66+
}, nil
67+
}
68+
69+
func applyEditMatch(content string, match editMatch, newString string) string {
70+
return content[:match.start] + newString + content[match.end:]
71+
}
72+
73+
// lineRangeToByteRange converts 1-indexed line numbers to byte offsets
74+
// Returns (startOffset, endOffset) where endOffset is exclusive
75+
func lineRangeToByteRange(content string, startLine, endLine int) (int, int) {
76+
lines := strings.Split(content, "\n")
77+
78+
startOffset := 0
79+
endOffset := len(content)
80+
81+
// Calculate start offset (byte position at start of startLine)
82+
if startLine > 1 {
83+
lineCount := 0
84+
for i, ch := range content {
85+
if ch == '\n' {
86+
lineCount++
87+
if lineCount == startLine-1 {
88+
startOffset = i + 1
89+
break
90+
}
91+
}
92+
}
93+
}
94+
95+
// Calculate end offset (byte position after endLine, including its newline)
96+
if endLine > 0 && endLine < len(lines) {
97+
lineCount := 0
98+
for i, ch := range content {
99+
if ch == '\n' {
100+
lineCount++
101+
if lineCount == endLine {
102+
endOffset = i + 1
103+
break
104+
}
105+
}
106+
}
107+
}
108+
109+
return startOffset, endOffset
110+
}
111+
112+
// findEditMatchWithGuard searches for oldString only within the guarded line range
113+
// Returns a match with offsets relative to the full content
114+
func findEditMatchWithGuard(content, oldString string, startLine, endLine int) (editMatch, error) {
115+
if oldString == "" {
116+
return editMatch{}, fmt.Errorf("old_string is empty")
117+
}
118+
119+
guardStart, guardEnd := lineRangeToByteRange(content, startLine, endLine)
120+
guardedContent := content[guardStart:guardEnd]
121+
122+
// Search within the guarded slice
123+
if !strings.Contains(oldString, editWildcardToken) {
124+
idx := strings.Index(guardedContent, oldString)
125+
if idx < 0 {
126+
return editMatch{}, fmt.Errorf("not found within lines %d-%d", startLine, endLine)
127+
}
128+
// Adjust offset back to full content
129+
absStart := guardStart + idx
130+
return editMatch{
131+
start: absStart,
132+
end: absStart + len(oldString),
133+
text: oldString,
134+
}, nil
135+
}
136+
137+
// Wildcard matching within guarded slice
138+
segments := strings.Split(oldString, editWildcardToken)
139+
foundSegment := false
140+
foundFirst := false
141+
start := 0
142+
end := 0
143+
searchFrom := 0
144+
145+
for _, segment := range segments {
146+
if segment == "" {
147+
continue
148+
}
149+
foundSegment = true
150+
idx := strings.Index(guardedContent[searchFrom:], segment)
151+
if idx < 0 {
152+
return editMatch{}, fmt.Errorf("wildcard match failed within lines %d-%d", startLine, endLine)
153+
}
154+
absIdx := searchFrom + idx
155+
if !foundFirst {
156+
start = absIdx
157+
foundFirst = true
158+
}
159+
searchFrom = absIdx + len(segment)
160+
end = searchFrom
161+
}
162+
163+
if !foundSegment {
164+
return editMatch{}, fmt.Errorf("wildcard token used without anchors")
165+
}
166+
167+
// Adjust offsets back to full content
168+
return editMatch{
169+
start: guardStart + start,
170+
end: guardStart + end,
171+
text: guardedContent[start:end],
172+
}, nil
173+
}

0 commit comments

Comments
 (0)