v0.51.0 a9d674e

edit.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"fmt"
8	"os"
9	"strings"
10
11	"github.com/bnema/zut/packages/agent/lsp"
12	"github.com/bnema/zut/packages/core"
13	"github.com/bnema/zut/packages/provider"
14)
15
16// EditTool applies one or more exact-match replacements to a file.
17type EditTool struct {
18	CWD            string
19	Sandbox        *Sandbox
20	LSP            *lsp.Manager
21	LSPDiagnostics bool
22}
23
24type editOp struct {
25	OldText string `json:"oldText"`
26	NewText string `json:"newText"`
27}
28
29type editArgs struct {
30	Path  string   `json:"path"`
31	Edits []editOp `json:"edits"`
32}
33
34const editSchema = `{"type":"object","properties":{"path":{"type":"string","description":"File to modify, given as an absolute path or one relative to the working directory."},"edits":{"type":"array","description":"A set of non-overlapping substitutions, all located in the file content as it existed before this call.","items":{"type":"object","properties":{"oldText":{"type":"string","description":"A verbatim excerpt from the file being modified with enough surrounding text to identify exactly one location, without intersecting another edit."},"newText":{"type":"string","description":"Content that will replace the matched excerpt."}},"required":["oldText","newText"]}}},"required":["path","edits"]}`
35
36func (t *EditTool) Name() string { return "edit" }
37func (t *EditTool) Description() string {
38	return "Apply exact substitutions to an existing file. Inspect that file before editing and take every oldText directly from its current contents. Use a verbatim excerpt with enough surrounding text to identify exactly one location; choose write when replacing most or all of a file."
39}
40func (t *EditTool) Schema() json.RawMessage { return json.RawMessage(editSchema) }
41
42type editPlan struct {
43	path   string
44	final  []byte
45	result core.ToolResult
46}
47
48// Preview validates the edit against the current file and returns the exact
49// diff without writing anything. The confirmation UI shows this result before
50// the user decides whether Execute may apply it.
51func (t *EditTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
52	plan, err := t.plan(raw)
53	if err != nil {
54		return core.ToolResult{}, err
55	}
56	return plan.result, nil
57}
58
59func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
60	plan, err := t.plan(raw)
61	if err != nil {
62		return core.ToolResult{}, err
63	}
64	if err := os.WriteFile(plan.path, plan.final, 0o644); err != nil {
65		return core.ToolResult{}, err
66	}
67	if t.LSPDiagnostics {
68		attachWriteDiagnostics(ctx, t.CWD, plan.path, t.LSP, &plan.result)
69	}
70	return plan.result, nil
71}
72
73func (t *EditTool) plan(raw json.RawMessage) (editPlan, error) {
74	var a editArgs
75	if err := json.Unmarshal(raw, &a); err != nil {
76		return editPlan{}, fmt.Errorf("invalid args: %w", err)
77	}
78	if a.Path == "" {
79		return editPlan{}, fmt.Errorf("path is required")
80	}
81	if len(a.Edits) == 0 {
82		return editPlan{}, fmt.Errorf("at least one edit is required")
83	}
84	path := resolvePath(t.CWD, a.Path)
85	if err := t.Sandbox.CheckWritePath(path); err != nil {
86		return editPlan{}, err
87	}
88
89	orig, err := os.ReadFile(path)
90	if err != nil {
91		return editPlan{}, err
92	}
93
94	// Detect BOM and line endings.
95	var bom []byte
96	if bytes.HasPrefix(orig, []byte{0xEF, 0xBB, 0xBF}) {
97		bom = orig[:3]
98		orig = orig[3:]
99	}
100	nl := detectLineEnding(orig)
101	// Normalize the file and edit operands to \n for matching and replacement.
102	body := normalizeLineEndings(string(orig))
103
104	// Validate all edits against the original content and build their spans in
105	// the same pass. This keeps validation and application anchored to the same
106	// match instead of locating each oldText a second time.
107	type span struct {
108		start, end  int
109		replacement string
110	}
111	spans := make([]span, 0, len(a.Edits))
112	for i, e := range a.Edits {
113		oldText := normalizeLineEndings(e.OldText)
114		newText := normalizeLineEndings(e.NewText)
115		if oldText == "" {
116			return editPlan{}, fmt.Errorf("edit %d: oldText must not be empty", i+1)
117		}
118		if oldText == newText {
119			return editPlan{}, fmt.Errorf("edit %d: oldText equals newText", i+1)
120		}
121		matches := summarizeMatches(body, oldText)
122		if matches.count == 0 {
123			return editPlan{}, fmt.Errorf("edit %d: oldText not found in %s; inspect that file again and use a verbatim excerpt with matching spaces and line breaks", i+1, a.Path)
124		}
125		if matches.count > 1 {
126			return editPlan{}, fmt.Errorf("edit %d: oldText matches %d times (must be unique) in %s at %s; read %s with a bounded context window around those lines, then retry with a verbatim excerpt containing enough surrounding text to identify exactly one location", i+1, matches.count, a.Path, formatMatchLines(matches.lines, matches.count), a.Path)
127		}
128		spans = append(spans, span{
129			start:       matches.start,
130			end:         matches.start + len(oldText),
131			replacement: newText,
132		})
133	}
134
135	// Apply atomically (sorted by position so offsets stay valid as we splice).
136	// Check for overlaps.
137	for i := 0; i < len(spans); i++ {
138		for j := i + 1; j < len(spans); j++ {
139			if spans[i].start < spans[j].end && spans[j].start < spans[i].end {
140				return editPlan{}, fmt.Errorf("edits overlap; merge them into one edit")
141			}
142		}
143	}
144	// Sort ascending.
145	for i := 1; i < len(spans); i++ {
146		for j := i; j > 0 && spans[j-1].start > spans[j].start; j-- {
147			spans[j-1], spans[j] = spans[j], spans[j-1]
148		}
149	}
150	var out strings.Builder
151	prev := 0
152	for _, s := range spans {
153		out.WriteString(body[prev:s.start])
154		out.WriteString(s.replacement)
155		prev = s.end
156	}
157	out.WriteString(body[prev:])
158	newBody := out.String()
159
160	// Restore line endings.
161	if nl == "\r\n" {
162		newBody = strings.ReplaceAll(newBody, "\n", "\r\n")
163	}
164	final := append([]byte{}, bom...)
165	final = append(final, []byte(newBody)...)
166
167	diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
168	// The tool-call header renders the path above the result, so the
169	// result body is just the context diff. Details carries metadata for
170	// programmatic consumers and confirmation previews.
171	result := core.ToolResult{
172		Content: []provider.Content{provider.TextBlock{Text: diff}},
173		Context: provider.ToolContext{Mutates: []string{canonicalResourcePath(path)}},
174		Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
175	}
176	return editPlan{path: path, final: final, result: result}, nil
177}
178
179const maxReportedMatchLines = 5
180
181type matchSummary struct {
182	count int
183	start int
184	lines []int
185}
186
187// summarizeMatches counts every possible match start, including overlaps,
188// while retaining only the line numbers useful for recovery guidance.
189func summarizeMatches(body, oldText string) matchSummary {
190	var summary matchSummary
191	for searchFrom := 0; searchFrom < len(body); {
192		relative := strings.Index(body[searchFrom:], oldText)
193		if relative < 0 {
194			break
195		}
196		start := searchFrom + relative
197		if summary.count == 0 {
198			summary.start = start
199		}
200		summary.count++
201		if len(summary.lines) < maxReportedMatchLines {
202			summary.lines = append(summary.lines, 1+strings.Count(body[:start], "\n"))
203		}
204		searchFrom = start + 1
205	}
206	return summary
207}
208
209func formatMatchLines(lines []int, count int) string {
210	parts := make([]string, len(lines))
211	for i, line := range lines {
212		parts[i] = fmt.Sprintf("%d", line)
213	}
214
215	var list string
216	switch len(parts) {
217	case 1:
218		list = parts[0]
219	case 2:
220		list = parts[0] + " and " + parts[1]
221	default:
222		list = strings.Join(parts[:len(parts)-1], ", ") + ", and " + parts[len(parts)-1]
223	}
224	if omitted := count - len(lines); omitted > 0 {
225		list += fmt.Sprintf(" (and %d more)", omitted)
226	}
227	return "lines " + list
228}
229
230func normalizeLineEndings(s string) string {
231	return strings.ReplaceAll(s, "\r\n", "\n")
232}
233
234func detectLineEnding(b []byte) string {
235	if bytes.Contains(b, []byte("\r\n")) {
236		return "\r\n"
237	}
238	return "\n"
239}
240
241// diffContextLines is the number of unchanged lines kept on each
242// side of an edit when rendering the diff. 3 is the git-diff
243// default and balances readability with transcript size.
244const diffContextLines = 3
245
246// unifiedDiff emits a context diff for the edit tool's result.
247//
248// Shape: each output row is either
249//   - " <line>"       unchanged context
250//   - "-<line>"       deletion (from a)
251//   - "+<line>"       addition (to b)
252//   - "..."           context break between hunks
253//
254// The legacy "--- name / +++ name" header is omitted because the
255// tool-call header above the result already shows the path. Only
256// lines within diffContextLines of a +/- row are kept; longer
257// runs of unchanged content collapse into a single "..." row so
258// a one-line edit in a thousand-line file produces a short
259// transcript.
260func unifiedDiff(name, a, b string) string {
261	if a == b {
262		return ""
263	}
264	aLines := strings.Split(a, "\n")
265	bLines := strings.Split(b, "\n")
266	ops := diffLines(aLines, bLines)
267
268	// Mark ops that sit within diffContextLines of any +/- op.
269	keep := make([]bool, len(ops))
270	for i, op := range ops {
271		if op.kind == '+' || op.kind == '-' {
272			keep[i] = true
273			for d := 1; d <= diffContextLines; d++ {
274				if i-d >= 0 {
275					keep[i-d] = true
276				}
277				if i+d < len(ops) {
278					keep[i+d] = true
279				}
280			}
281		}
282	}
283
284	var sb strings.Builder
285	prevKept := false
286	anyOutput := false
287	for i, op := range ops {
288		if !keep[i] {
289			if prevKept {
290				sb.WriteString("...\n")
291				prevKept = false
292			}
293			continue
294		}
295		if !prevKept && anyOutput {
296			sb.WriteString("...\n")
297		}
298		switch op.kind {
299		case ' ':
300			fmt.Fprintf(&sb, " %s\n", op.line)
301		case '-':
302			fmt.Fprintf(&sb, "-%s\n", op.line)
303		case '+':
304			fmt.Fprintf(&sb, "+%s\n", op.line)
305		}
306		prevKept = true
307		anyOutput = true
308	}
309	_ = name // header dropped; kept in signature for call-site stability
310	return sb.String()
311}
312
313type diffOp struct {
314	kind byte
315	line string
316}
317
318func diffLines(a, b []string) []diffOp {
319	// LCS table.
320	m, n := len(a), len(b)
321	dp := make([][]int, m+1)
322	for i := range dp {
323		dp[i] = make([]int, n+1)
324	}
325	for i := 0; i < m; i++ {
326		for j := 0; j < n; j++ {
327			if a[i] == b[j] {
328				dp[i+1][j+1] = dp[i][j] + 1
329			} else if dp[i+1][j] >= dp[i][j+1] {
330				dp[i+1][j+1] = dp[i+1][j]
331			} else {
332				dp[i+1][j+1] = dp[i][j+1]
333			}
334		}
335	}
336	// Backtrack.
337	var ops []diffOp
338	i, j := m, n
339	for i > 0 && j > 0 {
340		if a[i-1] == b[j-1] {
341			ops = append([]diffOp{{' ', a[i-1]}}, ops...)
342			i--
343			j--
344		} else if dp[i][j-1] >= dp[i-1][j] {
345			ops = append([]diffOp{{'+', b[j-1]}}, ops...)
346			j--
347		} else {
348			ops = append([]diffOp{{'-', a[i-1]}}, ops...)
349			i--
350		}
351	}
352	for i > 0 {
353		ops = append([]diffOp{{'-', a[i-1]}}, ops...)
354		i--
355	}
356	for j > 0 {
357		ops = append([]diffOp{{'+', b[j-1]}}, ops...)
358		j--
359	}
360	return ops
361}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close