patchllm 0.2.2__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. patchllm/__main__.py +0 -0
  2. patchllm/agent/__init__.py +0 -0
  3. patchllm/agent/actions.py +73 -0
  4. patchllm/agent/executor.py +57 -0
  5. patchllm/agent/planner.py +76 -0
  6. patchllm/agent/session.py +425 -0
  7. patchllm/cli/__init__.py +0 -0
  8. patchllm/cli/entrypoint.py +120 -0
  9. patchllm/cli/handlers.py +192 -0
  10. patchllm/cli/helpers.py +72 -0
  11. patchllm/interactive/__init__.py +0 -0
  12. patchllm/interactive/selector.py +100 -0
  13. patchllm/llm.py +39 -0
  14. patchllm/main.py +1 -323
  15. patchllm/parser.py +120 -64
  16. patchllm/patcher.py +118 -0
  17. patchllm/scopes/__init__.py +0 -0
  18. patchllm/scopes/builder.py +55 -0
  19. patchllm/scopes/constants.py +70 -0
  20. patchllm/scopes/helpers.py +147 -0
  21. patchllm/scopes/resolvers.py +82 -0
  22. patchllm/scopes/structure.py +64 -0
  23. patchllm/tui/__init__.py +0 -0
  24. patchllm/tui/completer.py +153 -0
  25. patchllm/tui/interface.py +703 -0
  26. patchllm/utils.py +19 -1
  27. patchllm/voice/__init__.py +0 -0
  28. patchllm/{listener.py → voice/listener.py} +8 -1
  29. patchllm-1.0.0.dist-info/METADATA +153 -0
  30. patchllm-1.0.0.dist-info/RECORD +51 -0
  31. patchllm-1.0.0.dist-info/entry_points.txt +2 -0
  32. {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/top_level.txt +1 -0
  33. tests/__init__.py +0 -0
  34. tests/conftest.py +112 -0
  35. tests/test_actions.py +62 -0
  36. tests/test_agent.py +383 -0
  37. tests/test_completer.py +121 -0
  38. tests/test_context.py +140 -0
  39. tests/test_executor.py +60 -0
  40. tests/test_interactive.py +64 -0
  41. tests/test_parser.py +70 -0
  42. tests/test_patcher.py +71 -0
  43. tests/test_planner.py +53 -0
  44. tests/test_recipes.py +111 -0
  45. tests/test_scopes.py +47 -0
  46. tests/test_structure.py +48 -0
  47. tests/test_tui.py +397 -0
  48. tests/test_utils.py +31 -0
  49. patchllm/context.py +0 -238
  50. patchllm-0.2.2.dist-info/METADATA +0 -129
  51. patchllm-0.2.2.dist-info/RECORD +0 -12
  52. patchllm-0.2.2.dist-info/entry_points.txt +0 -2
  53. {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
  54. {patchllm-0.2.2.dist-info → patchllm-1.0.0.dist-info}/licenses/LICENSE +0 -0
patchllm/main.py CHANGED
@@ -1,326 +1,4 @@
1
- import textwrap
2
- import argparse
3
- import litellm
4
- import pprint
5
- import os
6
- from dotenv import load_dotenv
7
- from rich.console import Console
8
- from rich.panel import Panel
9
-
10
- from .context import build_context
11
- from .parser import paste_response
12
- from .utils import load_from_py_file
13
-
14
- console = Console()
15
-
16
- # --- Core Functions ---
17
-
18
- def collect_context(scope_name, scopes):
19
- """Builds the code context from a provided scope dictionary."""
20
- console.print("\n--- Building Code Context... ---", style="bold")
21
- if not scopes:
22
- raise FileNotFoundError("Could not find a 'scopes.py' file.")
23
- selected_scope = scopes.get(scope_name)
24
- if selected_scope is None:
25
- raise KeyError(f"Context scope '{scope_name}' not found in provided scopes file.")
26
-
27
- context_object = build_context(selected_scope)
28
- if context_object:
29
- tree, context = context_object.values()
30
- console.print("--- Context Building Finished. The following files were extracted ---", style="bold")
31
- console.print(tree)
32
- return context
33
- else:
34
- console.print("--- Context Building Failed (No files found) ---", style="yellow")
35
- return None
36
-
37
- def run_llm_query(task_instructions, model_name, history, context=None):
38
- """
39
- Assembles the final prompt, sends it to the LLM, and returns the response.
40
- """
41
- console.print("\n--- Sending Prompt to LLM... ---", style="bold")
42
- final_prompt = task_instructions
43
- if context:
44
- final_prompt = f"{context}\n\n{task_instructions}"
45
-
46
- history.append({"role": "user", "content": final_prompt})
47
-
48
- try:
49
- with console.status("[bold cyan]Waiting for LLM response...", spinner="dots"):
50
- response = litellm.completion(model=model_name, messages=history)
51
-
52
- assistant_response_content = response.choices[0].message.content
53
- history.append({"role": "assistant", "content": assistant_response_content})
54
-
55
- if not assistant_response_content or not assistant_response_content.strip():
56
- console.print("⚠️ Response is empty. Nothing to process.", style="yellow")
57
- return None
58
-
59
- return assistant_response_content
60
-
61
- except Exception as e:
62
- history.pop() # Keep history clean on error
63
- raise RuntimeError(f"An error occurred while communicating with the LLM via litellm: {e}") from e
64
-
65
- def write_to_file(file_path, content):
66
- """Utility function to write content to a file."""
67
- console.print(f"Writing to {file_path}..", style="cyan")
68
- try:
69
- with open(file_path, "w", encoding="utf-8") as file:
70
- file.write(content)
71
- console.print(f'✅ Content saved to {file_path}', style="green")
72
- except Exception as e:
73
- raise RuntimeError(f"Failed to write to file {file_path}: {e}") from e
74
-
75
- def read_from_file(file_path):
76
- """Utility function to read and return the content of a file."""
77
- console.print(f"Importing from {file_path}..", style="cyan")
78
- try:
79
- with open(file_path, "r", encoding="utf-8") as file:
80
- content = file.read()
81
- console.print("✅ Finished reading file.", style="green")
82
- return content
83
- except Exception as e:
84
- raise RuntimeError(f"Failed to read from file {file_path}: {e}") from e
85
-
86
- def create_new_scope(scopes, scopes_file_str):
87
- """Interactively creates a new scope and saves it to the specified scopes file."""
88
- console.print(f"\n--- Creating a new scope in '{scopes_file_str}' ---", style="bold")
89
-
90
- try:
91
- name = console.input("[bold]Enter a name for the new scope: [/]").strip()
92
- if not name:
93
- console.print("❌ Scope name cannot be empty.", style="red")
94
- return
95
-
96
- if name in scopes:
97
- overwrite = console.input(f"Scope '[bold]{name}[/]' already exists. Overwrite? (y/n): ").lower()
98
- if overwrite not in ['y', 'yes']:
99
- console.print("Operation cancelled.", style="yellow")
100
- return
101
-
102
- path = console.input("[bold]Enter the base path[/] (e.g., '.' for current directory): ").strip() or "."
103
-
104
- console.print("\nEnter comma-separated glob patterns for files to include.")
105
- include_raw = console.input('[cyan]> (e.g., "[bold]**/*.py, src/**/*.js[/]"): [/]').strip()
106
- include_patterns = [p.strip() for p in include_raw.split(',') if p.strip()]
107
-
108
- console.print("\nEnter comma-separated glob patterns for files to exclude (optional).")
109
- exclude_raw = console.input('[cyan]> (e.g., "[bold]**/tests/*, venv/*[/]"): [/]').strip()
110
- exclude_patterns = [p.strip() for p in exclude_raw.split(',') if p.strip()]
111
-
112
- new_scope_data = {
113
- "path": path,
114
- "include_patterns": include_patterns,
115
- "exclude_patterns": exclude_patterns
116
- }
117
-
118
- scopes[name] = new_scope_data
119
-
120
- with open(scopes_file_str, "w", encoding="utf-8") as f:
121
- f.write("# scopes.py\n")
122
- f.write("scopes = ")
123
- f.write(pprint.pformat(scopes, indent=4))
124
- f.write("\n")
125
-
126
- console.print(f"\n✅ Successfully created and saved scope '[bold]{name}[/]' in '[bold]{scopes_file_str}[/]'.", style="green")
127
-
128
- except KeyboardInterrupt:
129
- console.print("\n\n⚠️ Scope creation cancelled by user.", style="yellow")
130
- return
131
-
132
- def main():
133
- """
134
- Main entry point for the patchllm command-line tool.
135
- """
136
- load_dotenv()
137
-
138
- scopes_file_path = os.getenv("PATCHLLM_SCOPES_FILE", "./scopes.py")
139
-
140
- parser = argparse.ArgumentParser(
141
- description="A CLI tool to apply code changes using an LLM.",
142
- formatter_class=argparse.RawTextHelpFormatter
143
- )
144
-
145
- # --- Group: Core Patching Flow ---
146
- patch_group = parser.add_argument_group('Core Patching Flow')
147
- patch_group.add_argument("-s", "--scope", type=str, default=None, help="Name of the scope to use from the scopes file.")
148
- patch_group.add_argument("-t", "--task", type=str, default=None, help="The task instructions to guide the assistant.")
149
- patch_group.add_argument("-p", "--patch", action="store_true", help="Query the LLM and directly apply the file updates from the response. Requires --task.")
150
-
151
- # --- Group: Scope Management ---
152
- scope_group = parser.add_argument_group('Scope Management')
153
- scope_group.add_argument("-i", "--init", action="store_true", help="Create a new scope interactively.")
154
- scope_group.add_argument("-sl", "--list-scopes", action="store_true", help="List all available scopes from the scopes file and exit.")
155
- scope_group.add_argument("-ss", "--show-scope", type=str, help="Display the settings for a specific scope and exit.")
156
-
157
- # --- Group: I/O Utils---
158
- code_io = parser.add_argument_group('Code I/O')
159
- code_io.add_argument("-co", "--context-out", nargs='?', const="context.md", default=None, help="Export the generated context to a file. Defaults to 'context.md'.")
160
- code_io.add_argument("-ci", "--context-in", type=str, default=None, help="Import a previously saved context from a file.")
161
- code_io.add_argument("-tf", "--to-file", nargs='?', const="response.md", default=None, help="Query the LLM and save the response to a file. Requires --task. Defaults to 'response.md'.")
162
- code_io.add_argument("-tc", "--to-clipboard", action="store_true", help="Query the LLM and save the response to the clipboard. Requires --task.")
163
- code_io.add_argument("-ff", "--from-file", type=str, default=None, help="Apply code updates directly from a file.")
164
- code_io.add_argument("-fc", "--from-clipboard", action="store_true", help="Apply code updates directly from the clipboard.")
165
-
166
- # --- Group: General Options ---
167
- options_group = parser.add_argument_group('General Options')
168
- options_group.add_argument("-m", "--model", type=str, default="gemini/gemini-2.5-flash", help="Model name to use (e.g., 'gpt-4o', 'claude-3-sonnet').")
169
- options_group.add_argument("-v", "--voice", type=str, default="False", help="Enable voice interaction for providing task instructions. (True/False)")
170
-
171
- args = parser.parse_args()
172
-
173
- try:
174
- scopes = load_from_py_file(scopes_file_path, "scopes")
175
- except FileNotFoundError:
176
- scopes = {}
177
- if not any([args.init, args.list_scopes, args.show_scope]):
178
- console.print(f"⚠️ Scope file '{scopes_file_path}' not found. You can create one with the --init flag.", style="yellow")
179
-
180
-
181
- if args.list_scopes:
182
- console.print(f"Available scopes in '[bold]{scopes_file_path}[/]':", style="bold")
183
- if not scopes:
184
- console.print(f" -> No scopes found or '{scopes_file_path}' is missing.")
185
- else:
186
- for scope_name in scopes:
187
- console.print(f" - {scope_name}")
188
- return
189
-
190
- if args.show_scope:
191
- scope_name = args.show_scope
192
- if not scopes:
193
- console.print(f"⚠️ Scope file '{scopes_file_path}' not found or is empty.", style="yellow")
194
- return
195
-
196
- scope_data = scopes.get(scope_name)
197
- if scope_data:
198
- pretty_scope = pprint.pformat(scope_data, indent=2)
199
- console.print(
200
- Panel(
201
- pretty_scope,
202
- title=f"[bold cyan]Scope: '{scope_name}'[/]",
203
- subtitle=f"[dim]from {scopes_file_path}[/dim]",
204
- border_style="blue"
205
- )
206
- )
207
- else:
208
- console.print(f"❌ Scope '[bold]{scope_name}[/]' not found in '{scopes_file_path}'.", style="red")
209
- return
210
-
211
- if args.init:
212
- create_new_scope(scopes, scopes_file_path)
213
- return
214
-
215
- if args.from_clipboard:
216
- try:
217
- import pyperclip
218
- updates = pyperclip.paste()
219
- if updates:
220
- console.print("--- Parsing updates from clipboard ---", style="bold")
221
- paste_response(updates)
222
- else:
223
- console.print("⚠️ Clipboard is empty. Nothing to parse.", style="yellow")
224
- except ImportError:
225
- console.print("❌ The 'pyperclip' library is required for clipboard functionality.", style="red")
226
- console.print("Please install it using: pip install pyperclip", style="cyan")
227
- except Exception as e:
228
- console.print(f"❌ An error occurred while reading from the clipboard: {e}", style="red")
229
- return
230
-
231
- if args.from_file:
232
- updates = read_from_file(args.from_file)
233
- paste_response(updates)
234
- return
235
-
236
- system_prompt = textwrap.dedent("""
237
- You are an expert pair programmer. Your purpose is to help users by modifying files based on their instructions.
238
- Follow these rules strictly:
239
- Your output should be a single file including all the updated files. For each file-block:
240
- 1. Only include code for files that need to be updated / edited.
241
- 2. For updated files, do not exclude any code even if it is unchanged code; assume the file code will be copy-pasted full in the file.
242
- 3. Do not include verbose inline comments explaining what every small change does. Try to keep comments concise but informative, if any.
243
- 4. Only update the relevant parts of each file relative to the provided task; do not make irrelevant edits even if you notice areas of improvements elsewhere.
244
- 5. Do not use diffs.
245
- 6. Make sure each file-block is returned in the following exact format. No additional text, comments, or explanations should be outside these blocks.
246
- Expected format for a modified or new file:
247
- <file_path:/absolute/path/to/your/file.py>
248
- ```python
249
- # The full, complete content of /absolute/path/to/your/file.py goes here.
250
- def example_function():
251
- return "Hello, World!"
252
- ```
253
- """)
254
- history = [{"role": "system", "content": system_prompt}]
255
-
256
- context = None
257
- if args.voice not in ["False", "false"]:
258
- from .listener import listen, speak
259
- speak("Say your task instruction.")
260
- task = listen()
261
- if not task:
262
- speak("No instruction heard. Exiting.")
263
- return
264
- speak(f"You said: {task}. Should I proceed?")
265
- confirm = listen()
266
- if confirm and "yes" in confirm.lower():
267
- if not args.scope:
268
- parser.error("A --scope name is required when using --voice.")
269
- context = collect_context(args.scope, scopes)
270
- llm_response = run_llm_query(task, args.model, history, context)
271
- if llm_response:
272
- paste_response(llm_response)
273
- speak("Changes applied.")
274
- else:
275
- speak("Cancelled.")
276
- return
277
-
278
- # --- Main LLM Task Logic ---
279
- if args.task:
280
- action_flags = [args.patch, args.to_file is not None, args.to_clipboard]
281
- if sum(action_flags) == 0:
282
- parser.error("A task was provided, but no action was specified. Use --patch, --to-file, or --to-clipboard.")
283
- if sum(action_flags) > 1:
284
- parser.error("Please specify only one action: --patch, --to-file, or --to-clipboard.")
285
-
286
- if args.context_in:
287
- context = read_from_file(args.context_in)
288
- else:
289
- if not args.scope:
290
- parser.error("A --scope name is required to build context for a task.")
291
- context = collect_context(args.scope, scopes)
292
- if context and args.context_out:
293
- write_to_file(args.context_out, context)
294
-
295
- if not context:
296
- console.print("Proceeding with task but without any file context.", style="yellow")
297
-
298
- llm_response = run_llm_query(args.task, args.model, history, context)
299
-
300
- if llm_response:
301
- if args.patch:
302
- console.print("\n--- Updating files ---", style="bold")
303
- paste_response(llm_response)
304
- console.print("--- File Update Process Finished ---", style="bold")
305
-
306
- elif args.to_file is not None:
307
- write_to_file(args.to_file, llm_response)
308
-
309
- elif args.to_clipboard:
310
- try:
311
- import pyperclip
312
- pyperclip.copy(llm_response)
313
- console.print("✅ Copied LLM response to clipboard.", style="green")
314
- except ImportError:
315
- console.print("❌ The 'pyperclip' library is required for clipboard functionality.", style="red")
316
- console.print("Please install it using: pip install pyperclip", style="cyan")
317
- except Exception as e:
318
- console.print(f"❌ An error occurred while copying to the clipboard: {e}", style="red")
319
-
320
- elif args.scope and args.context_out:
321
- context = collect_context(args.scope, scopes)
322
- if context:
323
- write_to_file(args.context_out, context)
1
+ from .cli.entrypoint import main
324
2
 
325
3
  if __name__ == "__main__":
326
4
  main()
patchllm/parser.py CHANGED
@@ -1,3 +1,4 @@
1
+ import difflib
1
2
  import re
2
3
  from pathlib import Path
3
4
  from rich.console import Console
@@ -6,80 +7,135 @@ from rich.text import Text
6
7
 
7
8
  console = Console()
8
9
 
9
- def paste_response(response_content):
10
+ def _parse_file_blocks(response: str) -> list[tuple[Path, str]]:
11
+ """Parses the LLM response to extract file paths and their content."""
12
+ pattern = r"<file_path:(.*?)>\n```(?:\w+\n)?(.*?)\n```"
13
+ matches = re.findall(pattern, response, re.DOTALL)
14
+
15
+ parsed_blocks = []
16
+ for path_str, content in matches:
17
+ path_obj = Path(path_str.strip()).resolve()
18
+ # --- CORRECTION: Strip leading/trailing whitespace from content ---
19
+ parsed_blocks.append((path_obj, content.strip()))
20
+
21
+ return parsed_blocks
22
+
23
+ def parse_change_summary(response: str) -> str | None:
24
+ """Parses the LLM response to extract the change summary."""
25
+ pattern = r"<change_summary>(.*?)</change_summary>"
26
+ match = re.search(pattern, response, re.DOTALL)
27
+ if match:
28
+ return match.group(1).strip()
29
+ return None
30
+
31
+ def paste_response(response: str):
32
+ """Applies all file updates from the LLM's response to the local filesystem."""
33
+ parsed_blocks = _parse_file_blocks(response)
34
+ if not parsed_blocks:
35
+ console.print("⚠️ Could not find any file blocks to apply in the response.", style="yellow")
36
+ return
37
+
38
+ for file_path, new_content in parsed_blocks:
39
+ try:
40
+ file_path.parent.mkdir(parents=True, exist_ok=True)
41
+ file_path.write_text(new_content, encoding="utf-8")
42
+ console.print(f"✅ Updated [bold cyan]{file_path.name}[/bold cyan]", style="green")
43
+ except Exception as e:
44
+ console.print(f"❌ Failed to write to {file_path}: {e}", style="red")
45
+
46
+ def paste_response_selectively(response: str, files_to_apply: list[str]):
10
47
  """
11
- Parses a response containing code blocks and writes them to files,
12
- handling both absolute and relative paths safely.
48
+ Applies file updates from the LLM's response only to the user-selected files.
13
49
 
14
50
  Args:
15
- response_content (str): The string response from the LLM.
51
+ response (str): The full response from the LLM.
52
+ files_to_apply (list[str]): A list of absolute file path strings to modify.
16
53
  """
17
- pattern = re.compile(
18
- r"<file_path:([^>]+?)>\s*```(?:.*?)\n(.*?)\n```",
19
- re.DOTALL | re.MULTILINE
20
- )
54
+ parsed_blocks = _parse_file_blocks(response)
55
+ if not parsed_blocks:
56
+ console.print("⚠️ Could not find any file blocks to apply in the response.", style="yellow")
57
+ return
21
58
 
22
- matches = pattern.finditer(response_content)
23
-
24
- files_written = []
25
- files_skipped = []
26
- files_failed = []
27
- found_matches = False
28
-
29
- for match in matches:
30
- found_matches = True
31
- file_path_str = match.group(1).strip()
32
- code_content = match.group(2)
33
-
34
- if not file_path_str:
35
- console.print("⚠️ Found a code block with an empty file path. Skipping.", style="yellow")
36
- continue
37
-
38
- console.print(f"Found path in response: '[cyan]{file_path_str}[/]'")
39
- raw_path = Path(file_path_str)
40
-
41
- if raw_path.is_absolute():
42
- target_path = raw_path
59
+ files_to_apply_set = set(files_to_apply)
60
+ applied_count = 0
61
+
62
+ for file_path, new_content in parsed_blocks:
63
+ if file_path.as_posix() in files_to_apply_set:
64
+ try:
65
+ file_path.parent.mkdir(parents=True, exist_ok=True)
66
+ file_path.write_text(new_content, encoding="utf-8")
67
+ console.print(f"✅ Updated [bold cyan]{file_path.name}[/bold cyan]", style="green")
68
+ applied_count += 1
69
+ except Exception as e:
70
+ console.print(f"❌ Failed to write to {file_path}: {e}", style="red")
71
+
72
+ if applied_count == 0:
73
+ console.print("No changes were applied.", style="yellow")
74
+
75
+
76
+ def summarize_changes(response: str) -> dict:
77
+ """Summarizes which files will be created and which will be modified."""
78
+ parsed_blocks = _parse_file_blocks(response)
79
+ summary = {"created": [], "modified": []}
80
+ for file_path, _ in parsed_blocks:
81
+ if file_path.exists():
82
+ summary["modified"].append(file_path.as_posix())
43
83
  else:
44
- target_path = Path.cwd() / raw_path
84
+ summary["created"].append(file_path.as_posix())
85
+ return summary
45
86
 
46
- target_path = target_path.resolve()
87
+ def get_diff_for_file(file_path_str: str, response: str) -> str:
88
+ """Generates a colorized, unified diff for a single file from the response."""
89
+ parsed_blocks = _parse_file_blocks(response)
90
+ file_path = Path(file_path_str).resolve()
91
+
92
+ new_content = None
93
+ for p, content in parsed_blocks:
94
+ if p == file_path:
95
+ new_content = content
96
+ break
97
+
98
+ if new_content is None:
99
+ return f"Could not find content for {file_path_str} in the response."
47
100
 
101
+ original_content = ""
102
+ if file_path.exists():
48
103
  try:
49
- target_path.parent.mkdir(parents=True, exist_ok=True)
50
-
51
- if target_path.exists():
52
- with open(target_path, 'r', encoding='utf-8') as existing_file:
53
- if existing_file.read() == code_content:
54
- console.print(f" -> No changes for '[cyan]{target_path}[/]', skipping.", style="dim")
55
- files_skipped.append(target_path)
56
- continue
104
+ original_content = file_path.read_text(encoding="utf-8")
105
+ except Exception:
106
+ return f"Could not read original content of {file_path_str}."
57
107
 
58
- with open(target_path, 'w', encoding='utf-8') as outfile:
59
- outfile.write(code_content)
108
+ diff = difflib.unified_diff(
109
+ original_content.splitlines(keepends=True),
110
+ new_content.splitlines(keepends=True),
111
+ fromfile=f"a/{file_path.name}",
112
+ tofile=f"b/{file_path.name}",
113
+ )
60
114
 
61
- console.print(f" -> Wrote {len(code_content)} bytes to '[cyan]{target_path}[/]'", style="green")
62
- files_written.append(target_path)
115
+ diff_text = Text()
116
+ for line in diff:
117
+ if line.startswith('+'):
118
+ diff_text.append(line, style="green")
119
+ elif line.startswith('-'):
120
+ diff_text.append(line, style="red")
121
+ elif line.startswith('^'):
122
+ diff_text.append(line, style="blue")
123
+ else:
124
+ diff_text.append(line)
125
+
126
+ return diff_text
63
127
 
64
- except OSError as e:
65
- console.print(f" -> Error writing file '[cyan]{target_path}[/]': {e}", style="red")
66
- files_failed.append(target_path)
67
- except Exception as e:
68
- console.print(f" -> ❌ An unexpected error occurred for file '[cyan]{target_path}[/]': {e}", style="red")
69
- files_failed.append(target_path)
70
-
71
- summary_text = Text()
72
- if not found_matches:
73
- summary_text.append("No file paths and code blocks matching the expected format were found in the response.", style="yellow")
74
- else:
75
- if files_written:
76
- summary_text.append(f"Successfully wrote {len(files_written)} file(s).\n", style="green")
77
- if files_skipped:
78
- summary_text.append(f"Skipped {len(files_skipped)} file(s) (no changes).\n", style="cyan")
79
- if files_failed:
80
- summary_text.append(f"Failed to write {len(files_failed)} file(s).\n", style="red")
128
+ def display_diff(response: str):
129
+ """Displays the diff for all changes proposed in the response."""
130
+ summary = summarize_changes(response)
131
+ all_files = summary.get("modified", []) + summary.get("created", [])
132
+
133
+ console.print("\n--- Proposed Changes (Diff) ---", style="bold yellow")
134
+
135
+ if not all_files:
136
+ console.print("No file changes detected in the response.")
137
+ return
81
138
 
82
- if not any([files_written, files_skipped, files_failed]):
83
- summary_text.append("Found matching blocks, but no files were processed.", style="yellow")
84
-
85
- console.print(Panel(summary_text, title="[bold]Summary[/bold]", border_style="blue"))
139
+ for file_path in all_files:
140
+ diff_text = get_diff_for_file(file_path, response)
141
+ console.print(Panel(diff_text, title=f"[bold cyan]Diff: {Path(file_path).name}[/bold cyan]", border_style="yellow"))
patchllm/patcher.py ADDED
@@ -0,0 +1,118 @@
1
+ import subprocess
2
+ import re
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+ from InquirerPy import prompt
6
+ from InquirerPy.exceptions import InvalidArgument
7
+ import textwrap
8
+
9
+ from .parser import get_diff_for_file, _parse_file_blocks, paste_response
10
+ from .scopes.helpers import find_files
11
+
12
+ console = Console()
13
+
14
+ def _is_diff_format(text: str) -> bool:
15
+ """Checks if the text appears to be in the unified diff format."""
16
+ for line in text.splitlines():
17
+ if line.strip(): # Find the first non-empty line
18
+ return line.startswith("--- a/")
19
+ return False
20
+
21
+ def _apply_diff(diff_text: str, base_path: Path):
22
+ """Applies a patch using the system's `patch` command."""
23
+ try:
24
+ # --- MODIFICATION: Added --ignore-whitespace for robustness ---
25
+ proc = subprocess.run(
26
+ ["patch", "-p1", "--ignore-whitespace"],
27
+ input=diff_text,
28
+ text=True,
29
+ capture_output=True,
30
+ check=True,
31
+ cwd=base_path
32
+ )
33
+ console.print("✅ Patch applied successfully.", style="green")
34
+ if proc.stdout:
35
+ console.print(proc.stdout)
36
+ except FileNotFoundError:
37
+ console.print("❌ Failed to apply patch.", style="red")
38
+ console.print("Error: The 'patch' command-line utility was not found.", style="yellow")
39
+ console.print("Please install it to apply diff-formatted patches:", style="yellow")
40
+ console.print(" - On Debian/Ubuntu: sudo apt-get install patch", style="cyan")
41
+ console.print(" - On macOS (Homebrew): brew install patch", style="cyan")
42
+ console.print(" - On Termux: pkg install patch", style="cyan")
43
+ except subprocess.CalledProcessError as e:
44
+ console.print("❌ Failed to apply patch.", style="red")
45
+ console.print("The `patch` command failed. This may be due to a mismatch between the diff and the file content.", style="yellow")
46
+ if e.stderr:
47
+ console.print("Error details:", style="yellow")
48
+ console.print(e.stderr)
49
+ except Exception as e:
50
+ console.print(f"❌ An unexpected error occurred while applying the patch: {e}", style="red")
51
+
52
+
53
+ def _interactive_file_selection(base_path: Path) -> Path | None:
54
+ """Prompts the user to select a file from the project."""
55
+ try:
56
+ all_files = find_files(base_path, ["**/*"])
57
+ choices = [p.relative_to(base_path).as_posix() for p in all_files]
58
+ if not choices:
59
+ console.print("No files found in the project.", style="yellow")
60
+ return None
61
+
62
+ question = {
63
+ "type": "fuzzy", "name": "file", "message": "Which file should this code be applied to?",
64
+ "choices": choices, "border": True
65
+ }
66
+ result = prompt([question], vi_mode=True)
67
+ return base_path / result.get("file") if result else None
68
+ except (InvalidArgument, IndexError, KeyError):
69
+ return None
70
+
71
+ def apply_external_patch(content: str, base_path: Path):
72
+ """
73
+ Intelligently applies code updates from an external source.
74
+ Handles standard diffs, patchllm's format, and ambiguous code blocks.
75
+ """
76
+ if _is_diff_format(content):
77
+ console.print("Detected `diff` format. Attempting to apply with `patch`...", style="cyan")
78
+ _apply_diff(content, base_path)
79
+ return
80
+
81
+ clean_content = textwrap.dedent(content).strip()
82
+
83
+ parsed_blocks = _parse_file_blocks(clean_content)
84
+ if parsed_blocks:
85
+ console.print("Detected `patchllm` format. Applying changes...", style="cyan")
86
+ paste_response(clean_content)
87
+ return
88
+
89
+ console.print("Could not parse a standard format. Entering interactive mode...", style="yellow")
90
+
91
+ code_block_match = re.search(r"```(?:\w+)?\s*\n(.*?)\n\s*```", clean_content, re.DOTALL)
92
+ if not code_block_match:
93
+ console.print("❌ No code blocks found in the input.", style="red")
94
+ return
95
+
96
+ code_to_apply = code_block_match.group(1).strip()
97
+ target_file = _interactive_file_selection(base_path)
98
+
99
+ if not target_file:
100
+ console.print("Cancelled.", style="yellow")
101
+ return
102
+
103
+ response_for_diff = f"<file_path:{target_file.as_posix()}>\n```\n{code_to_apply}\n```"
104
+ diff_text = get_diff_for_file(str(target_file), response_for_diff)
105
+ console.print("\n--- Proposed Changes ---", style="bold yellow")
106
+ console.print(diff_text)
107
+
108
+ try:
109
+ confirm_q = {"type": "confirm", "name": "confirm", "message": f"Apply these changes to '{target_file.name}'?", "default": True}
110
+ result = prompt([confirm_q])
111
+ if result and result.get("confirm"):
112
+ target_file.parent.mkdir(parents=True, exist_ok=True)
113
+ target_file.write_text(code_to_apply, encoding="utf-8")
114
+ console.print(f"✅ Updated [bold cyan]{target_file.name}[/bold cyan]", style="green")
115
+ else:
116
+ console.print("Cancelled.", style="yellow")
117
+ except (InvalidArgument, IndexError, KeyError):
118
+ console.print("Cancelled.", style="yellow")
File without changes