claudetool: add clipboard support to patch tool
diff --git a/claudetool/patch.go b/claudetool/patch.go
index 7a6dc6f..bd01fa8 100644
--- a/claudetool/patch.go
+++ b/claudetool/patch.go
@@ -31,21 +31,33 @@
Callback PatchCallback // may be nil
// Pwd is the working directory for resolving relative paths
Pwd string
+ // ClipboardEnabled controls whether clipboard functionality is enabled.
+ // NB: The actual implementation of the patch tool is unchanged,
+ // this flag merely extends the description and input schema to include the clipboard operations.
+ ClipboardEnabled bool
+ // clipboards stores clipboard name -> text
+ clipboards map[string]string
}
// Tool returns an llm.Tool based on p.
func (p *PatchTool) Tool() *llm.Tool {
+ description := PatchBaseDescription + PatchUsageNotes
+ schema := PatchStandardInputSchema
+ if p.ClipboardEnabled {
+ description = PatchBaseDescription + PatchClipboardDescription + PatchUsageNotes
+ schema = PatchClipboardInputSchema
+ }
return &llm.Tool{
Name: PatchName,
- Description: strings.TrimSpace(PatchDescription),
- InputSchema: llm.MustSchema(PatchInputSchema),
+ Description: strings.TrimSpace(description),
+ InputSchema: llm.MustSchema(schema),
Run: p.Run,
}
}
const (
- PatchName = "patch"
- PatchDescription = `
+ PatchName = "patch"
+ PatchBaseDescription = `
File modification tool for precise text edits.
Operations:
@@ -53,14 +65,36 @@
- append_eof: Append new text at the end of the file
- prepend_bof: Insert new text at the beginning of the file
- overwrite: Replace the entire file with new content (automatically creates the file)
+`
+ PatchClipboardDescription = `
+Clipboard:
+- toClipboard: Store oldText to a named clipboard before the operation
+- fromClipboard: Use clipboard content as newText (ignores provided newText)
+- Clipboards persist across patch calls
+- Always use clipboards when moving/copying code (within or across files), even when the moved/copied code will also have edits.
+ This prevents transcription errors and distinguishes intentional changes from unintentional changes.
+
+Indentation adjustment:
+- reindent applies to whatever text is being inserted
+- First strips the specified prefix from each line, then adds the new prefix
+- Useful when moving code from one indentation to another
+
+Recipes:
+- cut: replace with empty newText and toClipboard
+- copy: replace with toClipboard and fromClipboard using the same clipboard name
+- paste: replace with fromClipboard
+- in-place indentation change: same as copy, but add indentation adjustment
+`
+
+ PatchUsageNotes = `
Usage notes:
- All inputs are interpreted literally (no automatic newline or whitespace handling)
- For replace operations, oldText must appear EXACTLY ONCE in the file
`
// If you modify this, update the termui template for prettier rendering.
- PatchInputSchema = `
+ PatchStandardInputSchema = `
{
"type": "object",
"required": ["path", "patches"],
@@ -95,6 +129,64 @@
}
}
`
+
+ PatchClipboardInputSchema = `
+{
+ "type": "object",
+ "required": ["path", "patches"],
+ "properties": {
+ "path": {
+ "type": "string",
+ "description": "Path to the file to patch"
+ },
+ "patches": {
+ "type": "array",
+ "description": "List of patch requests to apply",
+ "items": {
+ "type": "object",
+ "required": ["operation"],
+ "properties": {
+ "operation": {
+ "type": "string",
+ "enum": ["replace", "append_eof", "prepend_bof", "overwrite"],
+ "description": "Type of operation to perform"
+ },
+ "oldText": {
+ "type": "string",
+ "description": "Text to locate (must be unique in file, required for replace)"
+ },
+ "newText": {
+ "type": "string",
+ "description": "The new text to use (empty for deletions, leave empty if fromClipboard is set)"
+ },
+ "toClipboard": {
+ "type": "string",
+ "description": "Save oldText to this named clipboard before the operation"
+ },
+ "fromClipboard": {
+ "type": "string",
+ "description": "Use content from this clipboard as newText (overrides newText field)"
+ },
+ "reindent": {
+ "type": "object",
+ "description": "Modify indentation of the inserted text (newText or fromClipboard) before insertion",
+ "properties": {
+ "strip": {
+ "type": "string",
+ "description": "Remove this prefix from each non-empty line before insertion"
+ },
+ "add": {
+ "type": "string",
+ "description": "Add this prefix to each non-empty line after stripping"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+`
)
// TODO: maybe rename PatchRequest to PatchOperation or PatchSpec or PatchPart or just Patch?
@@ -118,19 +210,35 @@
// PatchRequest represents a single patch operation.
type PatchRequest struct {
- Operation string `json:"operation"`
- OldText string `json:"oldText,omitempty"`
- NewText string `json:"newText,omitempty"`
+ Operation string `json:"operation"`
+ OldText string `json:"oldText,omitempty"`
+ NewText string `json:"newText,omitempty"`
+ ToClipboard string `json:"toClipboard,omitempty"`
+ FromClipboard string `json:"fromClipboard,omitempty"`
+ Reindent *Reindent `json:"reindent,omitempty"`
+}
+
+// Reindent represents indentation adjustment configuration.
+type Reindent struct {
+ // TODO: it might be nice to make this more flexible,
+ // so it can e.g. strip all whitespace,
+ // or strip the prefix only on lines where it is present,
+ // or strip based on a regex.
+ Strip string `json:"strip,omitempty"`
+ Add string `json:"add,omitempty"`
}
// Run implements the patch tool logic.
func (p *PatchTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
+ if p.clipboards == nil {
+ p.clipboards = make(map[string]string)
+ }
input, err := p.patchParse(m)
var output llm.ToolOut
if err != nil {
output = llm.ErrorToolOut(err)
} else {
- output = p.patchRun(ctx, m, &input)
+ output = p.patchRun(ctx, &input)
}
if p.Callback != nil {
return p.Callback(input, output)
@@ -168,7 +276,7 @@
// patchRun implements the guts of the patch tool.
// It populates input from m.
-func (p *PatchTool) patchRun(ctx context.Context, m json.RawMessage, input *PatchInput) llm.ToolOut {
+func (p *PatchTool) patchRun(ctx context.Context, input *PatchInput) llm.ToolOut {
path := input.Path
if !filepath.IsAbs(input.Path) {
if p.Pwd == "" {
@@ -213,21 +321,63 @@
// TODO: when the model gets into a "cannot apply patch" cycle of doom, how do we get it unstuck?
// Also: how do we detect that it's in a cycle?
var patchErr error
+
+ var clipboardsModified []string
+ updateToClipboard := func(patch PatchRequest, spec *patchkit.Spec) {
+ if patch.ToClipboard == "" {
+ return
+ }
+ // Update clipboard with the actual matched text
+ matchedOldText := origStr[spec.Off : spec.Off+spec.Len]
+ p.clipboards[patch.ToClipboard] = matchedOldText
+ clipboardsModified = append(clipboardsModified, fmt.Sprintf(`<clipboard_modified name="%s"><message>clipboard contents altered in order to match uniquely</message><new_contents>%q</new_contents></clipboard_modified>`, patch.ToClipboard, matchedOldText))
+ }
+
for i, patch := range input.Patches {
+ // Process toClipboard first, so that copy works
+ if patch.ToClipboard != "" {
+ if patch.Operation != "replace" {
+ return llm.ErrorfToolOut("toClipboard (%s): can only be used with replace operation", patch.ToClipboard)
+ }
+ if patch.OldText == "" {
+ return llm.ErrorfToolOut("toClipboard (%s): oldText cannot be empty when using toClipboard", patch.ToClipboard)
+ }
+ p.clipboards[patch.ToClipboard] = patch.OldText
+ }
+
+ // Handle fromClipboard
+ newText := patch.NewText
+ if patch.FromClipboard != "" {
+ clipboardText, ok := p.clipboards[patch.FromClipboard]
+ if !ok {
+ return llm.ErrorfToolOut("fromClipboard (%s): no clipboard with that name", patch.FromClipboard)
+ }
+ newText = clipboardText
+ }
+
+ // Apply indentation adjustment if specified
+ if patch.Reindent != nil {
+ reindentedText, err := reindent(newText, patch.Reindent)
+ if err != nil {
+ return llm.ErrorfToolOut("reindent(%q -> %q): %w", patch.Reindent.Strip, patch.Reindent.Add, err)
+ }
+ newText = reindentedText
+ }
+
switch patch.Operation {
case "prepend_bof":
- buf.Insert(0, patch.NewText)
+ buf.Insert(0, newText)
case "append_eof":
- buf.Insert(len(orig), patch.NewText)
+ buf.Insert(len(orig), newText)
case "overwrite":
- buf.Replace(0, len(orig), patch.NewText)
+ buf.Replace(0, len(orig), newText)
case "replace":
if patch.OldText == "" {
return llm.ErrorfToolOut("patch %d: oldText cannot be empty for %s operation", i, patch.Operation)
}
// Attempt to apply the patch.
- spec, count := patchkit.Unique(origStr, patch.OldText, patch.NewText)
+ spec, count := patchkit.Unique(origStr, patch.OldText, newText)
switch count {
case 0:
// no matches, maybe recoverable, continued below
@@ -241,7 +391,6 @@
patchErr = errors.Join(patchErr, fmt.Errorf("old text not unique:\n%s", patch.OldText))
continue
default:
- // TODO: return an error instead of using agentPatch
slog.ErrorContext(ctx, "unique returned unexpected count", "count", count)
patchErr = errors.Join(patchErr, fmt.Errorf("internal error"))
continue
@@ -252,34 +401,39 @@
// and the cases they cover appear with some regularity.
// Try adjusting the whitespace prefix.
- spec, ok := patchkit.UniqueDedent(origStr, patch.OldText, patch.NewText)
+ spec, ok := patchkit.UniqueDedent(origStr, patch.OldText, newText)
if ok {
slog.DebugContext(ctx, "patch_applied", "method", "unique_dedent")
spec.ApplyToEditBuf(buf)
+ updateToClipboard(patch, spec)
continue
}
// Try ignoring leading/trailing whitespace in a semantically safe way.
- spec, ok = patchkit.UniqueInValidGo(origStr, patch.OldText, patch.NewText)
+ spec, ok = patchkit.UniqueInValidGo(origStr, patch.OldText, newText)
if ok {
slog.DebugContext(ctx, "patch_applied", "method", "unique_in_valid_go")
spec.ApplyToEditBuf(buf)
+ updateToClipboard(patch, spec)
continue
}
// Try ignoring semantically insignificant whitespace.
- spec, ok = patchkit.UniqueGoTokens(origStr, patch.OldText, patch.NewText)
+ spec, ok = patchkit.UniqueGoTokens(origStr, patch.OldText, newText)
if ok {
slog.DebugContext(ctx, "patch_applied", "method", "unique_go_tokens")
spec.ApplyToEditBuf(buf)
+ updateToClipboard(patch, spec)
continue
}
// Try trimming the first line of the patch, if we can do so safely.
- spec, ok = patchkit.UniqueTrim(origStr, patch.OldText, patch.NewText)
+ spec, ok = patchkit.UniqueTrim(origStr, patch.OldText, newText)
if ok {
slog.DebugContext(ctx, "patch_applied", "method", "unique_trim")
spec.ApplyToEditBuf(buf)
+ // Do NOT call updateToClipboard here,
+ // because the trimmed text may vary significantly from the original text.
continue
}
@@ -292,7 +446,11 @@
}
if patchErr != nil {
- return llm.ErrorToolOut(patchErr)
+ errorMsg := patchErr.Error()
+ for _, msg := range clipboardsModified {
+ errorMsg += "\n" + msg
+ }
+ return llm.ErrorToolOut(fmt.Errorf("%s", errorMsg))
}
patched, err := buf.Bytes()
@@ -307,10 +465,13 @@
}
response := new(strings.Builder)
- fmt.Fprintf(response, "- Applied all patches\n")
+ fmt.Fprintf(response, "<patches_applied>all</patches_applied>\n")
+ for _, msg := range clipboardsModified {
+ fmt.Fprintln(response, msg)
+ }
if autogenerated {
- fmt.Fprintf(response, "- WARNING: %q appears to be autogenerated. Patches were applied anyway.\n", input.Path)
+ fmt.Fprintf(response, "<warning>%q appears to be autogenerated. Patches were applied anyway.</warning>\n", input.Path)
}
diff := generateUnifiedDiff(input.Path, string(orig), string(patched))
@@ -375,3 +536,32 @@
}
return buf.String()
}
+
+// reindent applies indentation adjustments to text.
+func reindent(text string, adj *Reindent) (string, error) {
+ if adj == nil {
+ return text, nil
+ }
+
+ lines := strings.Split(text, "\n")
+
+ for i, line := range lines {
+ if line == "" {
+ continue
+ }
+ var ok bool
+ lines[i], ok = strings.CutPrefix(line, adj.Strip)
+ if !ok {
+ return "", fmt.Errorf("strip precondition failed: line %q does not start with %q", line, adj.Strip)
+ }
+ }
+
+ for i, line := range lines {
+ if line == "" {
+ continue
+ }
+ lines[i] = adj.Add + line
+ }
+
+ return strings.Join(lines, "\n"), nil
+}