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
@@ -0,0 +1,703 @@
1
+ from rich.console import Console
2
+ from rich.panel import Panel
3
+ from rich.text import Text
4
+ from rich.markup import escape
5
+ from rich.json import JSON
6
+ from pathlib import Path
7
+ import argparse
8
+ import json
9
+ import os
10
+ import re
11
+ import pprint
12
+
13
+ from prompt_toolkit import PromptSession
14
+ from prompt_toolkit.history import FileHistory
15
+ from prompt_toolkit.completion import FuzzyCompleter
16
+ from litellm import model_list
17
+
18
+ from .completer import PatchLLMCompleter
19
+ from ..agent.session import AgentSession
20
+ from ..agent import actions
21
+ from ..interactive.selector import select_files_interactively
22
+ from ..patcher import apply_external_patch
23
+ from ..cli.handlers import handle_scope_management
24
+ from ..scopes.builder import helpers
25
+ from ..utils import write_scopes_to_file
26
+
27
+ SESSION_FILE_PATH = Path(".patchllm_session.json")
28
+
29
+ def _print_help():
30
+ help_text = Text()
31
+ help_text.append("PatchLLM Agent Commands\n\n", style="bold")
32
+ help_text.append("Agent Workflow:\n", style="bold cyan")
33
+ help_text.append(" /task <goal>", style="bold"); help_text.append("\n ↳ Sets the high-level goal.\n")
34
+ help_text.append(" /plan", style="bold"); help_text.append("\n ↳ Generates a plan or opens interactive management if a plan exists.\n")
35
+ help_text.append(" /ask <question>", style="bold"); help_text.append("\n ↳ Ask a question about the plan or the code context.\n")
36
+ help_text.append(" /refine <feedback>", style="bold"); help_text.append("\n ↳ Refine the plan with new feedback/ideas.\n")
37
+ help_text.append(" /plan --edit <N> <text>", style="bold"); help_text.append("\n ↳ Edits step N of the plan.\n")
38
+ help_text.append(" /plan --rm <N>", style="bold"); help_text.append("\n ↳ Removes step N from the plan.\n")
39
+ help_text.append(" /plan --add <text>", style="bold"); help_text.append("\n ↳ Adds a new step to the end of the plan.\n")
40
+ help_text.append(" /run", style="bold"); help_text.append("\n ↳ Executes only the next step in the plan.\n")
41
+ help_text.append(" /run all", style="bold"); help_text.append("\n ↳ Executes all remaining steps as a single task.\n")
42
+ help_text.append(" /skip", style="bold"); help_text.append("\n ↳ Skips the current step.\n")
43
+ help_text.append(" /diff [all|filename]", style="bold"); help_text.append("\n ↳ Shows the full diff for a file or all files.\n")
44
+ help_text.append(" /approve", style="bold"); help_text.append("\n ↳ Interactively select and apply changes from the last run.\n")
45
+ help_text.append(" /retry <feedback>", style="bold"); help_text.append("\n ↳ Retries the last step with new feedback.\n")
46
+ help_text.append(" /revert", style="bold"); help_text.append("\n ↳ Reverts the changes from the last /approve.\n\n")
47
+ help_text.append("Context Management:\n", style="bold cyan")
48
+ help_text.append(" /context <scope>\n", style="bold"); help_text.append(" ↳ Sets the context using a scope (e.g., @git:staged).\n")
49
+ help_text.append(" /scopes\n", style="bold"); help_text.append(" ↳ Opens an interactive menu to create and manage scopes.\n\n")
50
+ help_text.append("Menu & Session:\n", style="bold cyan")
51
+ help_text.append(" /show [goal|plan|context|history|step]\n", style="bold"); help_text.append(" ↳ Shows session state.\n")
52
+ help_text.append(" /settings\n", style="bold"); help_text.append(" ↳ Configure the model and API keys.\n")
53
+ help_text.append(" /help\n", style="bold"); help_text.append(" ↳ Shows this help message.\n")
54
+ help_text.append(" /exit\n", style="bold"); help_text.append(" ↳ Exits the agent session.\n")
55
+ return Panel(help_text, title="Help", border_style="green")
56
+
57
+ def _display_execution_summary(result, console):
58
+ if not result:
59
+ console.print("❌ Step failed to produce a result.", style="red")
60
+ return
61
+
62
+ change_summary = result.get("change_summary")
63
+ if change_summary:
64
+ console.print(Panel(Text(change_summary, justify="left"), title="Change Summary", border_style="green", expand=False))
65
+
66
+ summary = result.get("summary", {})
67
+ modified = summary.get("modified", [])
68
+ created = summary.get("created", [])
69
+
70
+ if not modified and not created:
71
+ if not change_summary:
72
+ console.print("✅ Step finished, but no file changes were detected.", style="yellow")
73
+ return
74
+
75
+ summary_text = Text()
76
+ if modified:
77
+ summary_text.append("Modified:\n", style="bold yellow")
78
+ for f in modified: summary_text.append(f" - {f}\n")
79
+ if created:
80
+ if modified:
81
+ summary_text.append("\n")
82
+ summary_text.append("Created:\n", style="bold green")
83
+ for f in created: summary_text.append(f" - {f}\n")
84
+
85
+ console.print(Panel(summary_text, title="Proposed File Changes", border_style="cyan", expand=False))
86
+
87
+ def _save_session(session: AgentSession):
88
+ with open(SESSION_FILE_PATH, 'w') as f: json.dump(session.to_dict(), f, indent=2)
89
+
90
+ def _clear_session():
91
+ if SESSION_FILE_PATH.exists(): os.remove(SESSION_FILE_PATH)
92
+
93
+ def _run_settings_tui(session: AgentSession, console: Console):
94
+ """A sub-TUI for managing agent settings."""
95
+ try:
96
+ from InquirerPy import prompt
97
+ from InquirerPy.exceptions import InvalidArgument
98
+ from InquirerPy.validator import EmptyInputValidator
99
+ except ImportError:
100
+ console.print("❌ 'InquirerPy' is required. `pip install 'patchllm[interactive]'`", style="red"); return
101
+
102
+ console.print("\n--- Agent Settings ---", style="bold yellow")
103
+ while True:
104
+ try:
105
+ current_model = session.args.model
106
+ api_keys_count = len(session.api_keys)
107
+ action_q = {
108
+ "type": "list",
109
+ "name": "action",
110
+ "message": "Select a setting to configure:",
111
+ "choices": [
112
+ f"Change Model (current: {current_model})",
113
+ f"Manage API Keys ({api_keys_count} saved)",
114
+ "Back to agent"
115
+ ],
116
+ "border": True, "cycle": False
117
+ }
118
+ result = prompt([action_q])
119
+ action = result.get("action") if result else "Back to agent"
120
+
121
+ if action == "Back to agent": break
122
+
123
+ if action.startswith("Change Model"):
124
+ model_q = {
125
+ "type": "fuzzy",
126
+ "name": "model",
127
+ "message": "Fuzzy search for a model:",
128
+ "choices": model_list,
129
+ "default": current_model
130
+ }
131
+ model_r = prompt([model_q])
132
+ new_model = model_r.get("model") if model_r else None
133
+ if new_model:
134
+ session.args.model = new_model
135
+ session.save_settings()
136
+ console.print(f"✅ Default model set to '[bold]{new_model}[/bold]'. This will be saved.", style="green")
137
+
138
+ elif action.startswith("Manage API Keys"):
139
+ while True:
140
+ saved_keys = list(session.api_keys.keys())
141
+ key_choices = ["Add/Update a saved API Key"]
142
+ if saved_keys:
143
+ key_choices.append("Remove a saved API Key")
144
+ key_choices.append("Back")
145
+
146
+ key_action_q = {"type": "list", "name": "key_action", "message": "Manage your saved API keys", "choices": key_choices}
147
+ key_action_r = prompt([key_action_q])
148
+ key_action = key_action_r.get("key_action") if key_action_r else "Back"
149
+
150
+ if key_action == "Back": break
151
+
152
+ if key_action.startswith("Add/Update"):
153
+ env_var_q = {"type": "input", "name": "env_var", "message": "Enter the environment variable name (e.g., OPENAI_API_KEY):", "validate": EmptyInputValidator()}
154
+ env_var_r = prompt([env_var_q])
155
+ env_var_name = env_var_r.get("env_var") if env_var_r else None
156
+ if not env_var_name: continue
157
+
158
+ key_q = {"type": "password", "name": "key", "message": f"Enter the value for {env_var_name}:"}
159
+ key_r = prompt([key_q])
160
+ api_key = key_r.get("key") if key_r else None
161
+ if api_key:
162
+ session.set_api_key(env_var_name, api_key)
163
+ console.print(f"✅ Key '{env_var_name}' has been saved and applied to the current session.", style="green")
164
+
165
+ elif key_action.startswith("Remove"):
166
+ remove_q = {"type": "checkbox", "name": "keys", "message": "Select keys to remove:", "choices": saved_keys}
167
+ remove_r = prompt([remove_q])
168
+ keys_to_remove = remove_r.get("keys") if remove_r else []
169
+ if keys_to_remove:
170
+ for key in keys_to_remove:
171
+ session.remove_api_key(key)
172
+ console.print(f"✅ Removed {len(keys_to_remove)} key(s).", style="green")
173
+
174
+ except (KeyboardInterrupt, InvalidArgument, IndexError, KeyError, TypeError): break
175
+ console.print("\n--- Returning to Agent ---", style="bold yellow")
176
+
177
+ def _edit_string_list_interactive(current_list: list[str], item_name: str, console: Console) -> list[str] | None:
178
+ """Helper TUI to add/remove items from a simple list of strings."""
179
+ try:
180
+ from InquirerPy import prompt
181
+ from InquirerPy.validator import EmptyInputValidator
182
+ except ImportError: return None
183
+
184
+ edited_list = current_list[:]
185
+ while True:
186
+ console.print(Panel(f"[bold]Current {item_name}s:[/]\n" + "\n".join(f"- {i}" for i in edited_list) if edited_list else " (empty)", expand=False))
187
+ action_q = {"type": "list", "name": "action", "message": f"Manage {item_name}s", "choices": [f"Add a {item_name}", f"Remove a {item_name}", "Done"], "border": True}
188
+ action_r = prompt([action_q])
189
+ action = action_r.get("action") if action_r else "Done"
190
+
191
+ if action == "Done": return edited_list
192
+ if action == f"Add a {item_name}":
193
+ item_q = {"type": "input", "name": "item", "message": "Enter new item:", "validate": EmptyInputValidator()}
194
+ item_r = prompt([item_q])
195
+ new_item = item_r.get("item") if item_r else None
196
+ if new_item: edited_list.append(new_item)
197
+ elif action == f"Remove a {item_name}":
198
+ if not edited_list: console.print(f"No {item_name}s to remove.", style="yellow"); continue
199
+ remove_q = {"type": "checkbox", "name": "items", "message": "Select items to remove", "choices": edited_list}
200
+ remove_r = prompt([remove_q])
201
+ to_remove = remove_r.get("items", []) if remove_r else []
202
+ edited_list = [item for item in edited_list if item not in to_remove]
203
+
204
+ def _edit_patterns_interactive(current_list: list[str], pattern_type: str, console: Console) -> list[str] | None:
205
+ """Helper TUI to add/remove glob patterns, with an option for interactive selection."""
206
+ try:
207
+ from InquirerPy import prompt
208
+ from InquirerPy.validator import EmptyInputValidator
209
+ except ImportError: return None
210
+
211
+ edited_list = current_list[:]
212
+ while True:
213
+ console.print(Panel(f"[bold]Current {pattern_type} Patterns:[/]\n" + "\n".join(f"- {i}" for i in edited_list) if edited_list else " (empty)", expand=False))
214
+ choices = ["Add pattern manually", "Remove a pattern", "Add from interactive selector", "Done"]
215
+ action_q = {"type": "list", "name": "action", "message": "Manage patterns", "choices": choices, "border": True}
216
+ action_r = prompt([action_q])
217
+ action = action_r.get("action") if action_r else "Done"
218
+
219
+ if action == "Done": return edited_list
220
+ elif action == "Add pattern manually":
221
+ item_q = {"type": "input", "name": "item", "message": "Enter glob pattern (e.g., 'src/**/*.py'):", "validate": EmptyInputValidator()}
222
+ item_r = prompt([item_q])
223
+ new_item = item_r.get("item") if item_r else None
224
+ if new_item: edited_list.append(new_item)
225
+ elif action == "Remove a pattern":
226
+ if not edited_list: console.print("No patterns to remove.", style="yellow"); continue
227
+ remove_q = {"type": "checkbox", "name": "items", "message": "Select patterns to remove", "choices": edited_list}
228
+ remove_r = prompt([remove_q])
229
+ to_remove = remove_r.get("items", []) if remove_r else []
230
+ edited_list = [item for item in edited_list if item not in to_remove]
231
+ elif action == "Add from interactive selector":
232
+ base_path = Path(".").resolve()
233
+ selected_files = select_files_interactively(base_path)
234
+ if selected_files:
235
+ new_patterns = [p.relative_to(base_path).as_posix() for p in selected_files]
236
+ edited_list.extend(new_patterns)
237
+ console.print(f"✅ Added {len(new_patterns)} file/folder pattern(s).", style="green")
238
+
239
+ def _interactive_scope_editor(console: Console, existing_scope: dict | None = None) -> dict | None:
240
+ """A TUI for creating or editing a single scope dictionary."""
241
+ try:
242
+ from InquirerPy import prompt
243
+ except ImportError:
244
+ console.print("❌ 'InquirerPy' is required. `pip install 'patchllm[interactive]'`", style="red"); return None
245
+
246
+ if existing_scope:
247
+ scope_data = existing_scope.copy()
248
+ else: # Default for a new scope
249
+ scope_data = {"path": ".", "include_patterns": ["**/*"], "exclude_patterns": [], "search_words": [], "urls": [], "exclude_extensions": []}
250
+
251
+ while True:
252
+ console.print(Panel(JSON(json.dumps(scope_data)), title="Current Scope Configuration", border_style="blue"))
253
+
254
+ choices = [
255
+ f"Edit base path",
256
+ f"Manage include patterns ({len(scope_data.get('include_patterns', []))})",
257
+ f"Manage exclude patterns ({len(scope_data.get('exclude_patterns', []))})",
258
+ f"Manage search keywords ({len(scope_data.get('search_words', []))})",
259
+ f"Manage URLs ({len(scope_data.get('urls', []))})",
260
+ f"Manage excluded extensions ({len(scope_data.get('exclude_extensions', []))})",
261
+ "Save and Return",
262
+ "Cancel and Discard"
263
+ ]
264
+ action_q = {"type": "list", "name": "action", "message": "Select field to edit", "choices": choices, "border": True}
265
+ action_r = prompt([action_q])
266
+ action = action_r.get("action") if action_r else "Cancel and Discard"
267
+
268
+ if action == "Save and Return": return scope_data
269
+ if action == "Cancel and Discard": return None
270
+
271
+ if action.startswith("Edit base path"):
272
+ path_q = {"type": "input", "name": "path", "message": "Enter new base path:", "default": scope_data.get('path', '.')}
273
+ path_r = prompt([path_q])
274
+ if path_r and path_r.get("path") is not None: scope_data['path'] = path_r.get("path")
275
+ elif action.startswith("Manage include patterns"):
276
+ new_list = _edit_patterns_interactive(scope_data.get('include_patterns', []), "Include", console)
277
+ if new_list is not None: scope_data['include_patterns'] = new_list
278
+ elif action.startswith("Manage exclude patterns"):
279
+ new_list = _edit_patterns_interactive(scope_data.get('exclude_patterns', []), "Exclude", console)
280
+ if new_list is not None: scope_data['exclude_patterns'] = new_list
281
+ elif action.startswith("Manage search keywords"):
282
+ new_list = _edit_string_list_interactive(scope_data.get('search_words', []), "keyword", console)
283
+ if new_list is not None: scope_data['search_words'] = new_list
284
+ elif action.startswith("Manage URLs"):
285
+ new_list = _edit_string_list_interactive(scope_data.get('urls', []), "URL", console)
286
+ if new_list is not None: scope_data['urls'] = new_list
287
+ elif action.startswith("Manage excluded extensions"):
288
+ new_list = _edit_string_list_interactive(scope_data.get('exclude_extensions', []), "extension", console)
289
+ if new_list is not None: scope_data['exclude_extensions'] = new_list
290
+
291
+ def _run_scope_management_tui(scopes, scopes_file_path, console):
292
+ """A sub-TUI for managing scopes, reusing the core handler logic."""
293
+ try:
294
+ from InquirerPy import prompt
295
+ from InquirerPy.validator import EmptyInputValidator
296
+ from InquirerPy.exceptions import InvalidArgument
297
+ except ImportError:
298
+ console.print("❌ 'InquirerPy' is required. `pip install 'patchllm[interactive]'`", style="red"); return
299
+
300
+ console.print("\n--- Scope Management ---", style="bold yellow")
301
+ while True:
302
+ try:
303
+ choices = ["List scopes", "Show a scope", "Add a new scope", "Update a scope", "Remove a scope", "Back to agent"]
304
+ action_q = {"type": "list", "name": "action", "message": "Select an action:", "choices": choices, "border": True, "cycle": False}
305
+ result = prompt([action_q])
306
+ action = result.get("action") if result else "Back to agent"
307
+ if action == "Back to agent": break
308
+
309
+ # --- MODIFICATION: Create a base Namespace with all expected keys ---
310
+ base_args = argparse.Namespace(
311
+ list_scopes=False, show_scope=None, add_scope=None,
312
+ remove_scope=None, update_scope=None
313
+ )
314
+
315
+ if action == "List scopes":
316
+ base_args.list_scopes = True
317
+ handle_scope_management(base_args, scopes, scopes_file_path, None)
318
+
319
+ elif action == "Show a scope":
320
+ if not scopes: console.print("No scopes to show.", style="yellow"); continue
321
+ scope_q = {"type": "fuzzy", "name": "scope", "message": "Which scope to show?", "choices": sorted(scopes.keys())}
322
+ scope_r = prompt([scope_q])
323
+ if scope_r and scope_r.get("scope"):
324
+ base_args.show_scope = scope_r.get("scope")
325
+ handle_scope_management(base_args, scopes, scopes_file_path, None)
326
+
327
+ elif action == "Remove a scope":
328
+ if not scopes: console.print("No scopes to remove.", style="yellow"); continue
329
+ scope_q = {"type": "fuzzy", "name": "scope", "message": "Which scope to remove?", "choices": sorted(scopes.keys())}
330
+ scope_r = prompt([scope_q])
331
+ if scope_r and scope_r.get("scope"):
332
+ base_args.remove_scope = scope_r.get("scope")
333
+ handle_scope_management(base_args, scopes, scopes_file_path, None)
334
+
335
+ elif action == "Add a new scope":
336
+ name_q = {"type": "input", "name": "name", "message": "Enter name for the new scope:", "validate": EmptyInputValidator()}
337
+ name_r = prompt([name_q])
338
+ scope_name = name_r.get("name") if name_r else None
339
+ if not scope_name: continue
340
+ if scope_name in scopes: console.print(f"❌ Scope '{scope_name}' already exists.", style="red"); continue
341
+
342
+ new_scope_data = _interactive_scope_editor(console, existing_scope=None)
343
+ if new_scope_data:
344
+ scopes[scope_name] = new_scope_data
345
+ write_scopes_to_file(scopes_file_path, scopes)
346
+ console.print(f"✅ Scope '{scope_name}' created.", style="green")
347
+
348
+ elif action == "Update a scope":
349
+ if not scopes: console.print("No scopes to update.", style="yellow"); continue
350
+ scope_q = {"type": "fuzzy", "name": "scope", "message": "Which scope to update?", "choices": sorted(scopes.keys())}
351
+ scope_r = prompt([scope_q])
352
+ scope_name = scope_r.get("scope") if scope_r else None
353
+ if not scope_name: continue
354
+
355
+ updated_scope_data = _interactive_scope_editor(console, existing_scope=scopes[scope_name])
356
+ if updated_scope_data:
357
+ scopes[scope_name] = updated_scope_data
358
+ write_scopes_to_file(scopes_file_path, scopes)
359
+ console.print(f"✅ Scope '{scope_name}' updated.", style="green")
360
+
361
+ except (KeyboardInterrupt, InvalidArgument, IndexError, KeyError, TypeError): break
362
+ console.print("\n--- Returning to Agent ---", style="bold yellow")
363
+
364
+ def _run_plan_management_tui(session: AgentSession, console: Console):
365
+ """A sub-TUI for interactively managing the execution plan."""
366
+ try:
367
+ from InquirerPy import prompt
368
+ from InquirerPy.validator import EmptyInputValidator
369
+ from InquirerPy.exceptions import InvalidArgument
370
+ except ImportError:
371
+ console.print("❌ 'InquirerPy' is required. `pip install 'patchllm[interactive]'`", style="red"); return
372
+
373
+ console.print("\n--- Interactive Plan Management ---", style="bold yellow")
374
+ while True:
375
+ try:
376
+ if not session.plan:
377
+ console.print("The plan is now empty.", style="yellow")
378
+ break
379
+
380
+ choices = [f"{i+1}. {step}" for i, step in enumerate(session.plan)]
381
+
382
+ action_q = {
383
+ "type": "list", "name": "action", "message": "Select a step to manage or an action:",
384
+ "choices": choices + ["Add a new step", "Reorder steps", "Done"],
385
+ "border": True, "cycle": False,
386
+ "long_instruction": "Use arrow keys. Select a step to Edit/Remove it."
387
+ }
388
+ result = prompt([action_q])
389
+ action_choice = result.get("action") if result else "Done"
390
+
391
+ if action_choice == "Done": break
392
+
393
+ if action_choice == "Add a new step":
394
+ add_q = {"type": "input", "name": "text", "message": "Enter the new step instruction:", "validate": EmptyInputValidator()}
395
+ add_r = prompt([add_q])
396
+ if add_r and add_r.get("text"):
397
+ session.add_plan_step(add_r.get("text"))
398
+ console.print("✅ Step added to the end of the plan.", style="green")
399
+
400
+ elif action_choice == "Reorder steps":
401
+ if len(session.plan) < 2:
402
+ console.print("⚠️ Not enough steps to reorder.", style="yellow"); continue
403
+
404
+ reorder_choices = [f"{i+1}. {step}" for i, step in enumerate(session.plan)]
405
+
406
+ from_q = {
407
+ "type": "list", "name": "from", "message": "Select the step to move:",
408
+ "choices": reorder_choices, "cycle": False
409
+ }
410
+ from_r = prompt([from_q])
411
+ if not from_r or not from_r.get("from"): continue
412
+ from_index = int(from_r.get("from").split('.')[0]) - 1
413
+
414
+ to_choices = [f"Move to position {i+1}" for i in range(len(session.plan))]
415
+ to_q = {
416
+ "type": "list", "name": "to", "message": f"Where should '{session.plan[from_index]}' move?",
417
+ "choices": to_choices, "cycle": False
418
+ }
419
+ to_r = prompt([to_q])
420
+ if not to_r or not to_r.get("to"): continue
421
+ to_index = int(re.search(r'\d+', to_r.get("to")).group()) - 1
422
+
423
+ item_to_move = session.plan.pop(from_index)
424
+ session.plan.insert(to_index, item_to_move)
425
+ console.print(f"✅ Step moved from position {from_index + 1} to {to_index + 1}.", style="green")
426
+
427
+ else:
428
+ step_index = int(action_choice.split('.')[0]) - 1
429
+ step_text = session.plan[step_index]
430
+
431
+ edit_or_rm_q = {
432
+ "type": "list", "name": "sub_action", "message": f"Step {step_index + 1}: {step_text}",
433
+ "choices": ["Edit", "Remove", "Cancel"]
434
+ }
435
+ edit_or_rm_r = prompt([edit_or_rm_q])
436
+ sub_action = edit_or_rm_r.get("sub_action") if edit_or_rm_r else "Cancel"
437
+
438
+ if sub_action == "Edit":
439
+ edit_q = {"type": "input", "name": "text", "message": "Enter the new instruction:", "default": step_text, "validate": EmptyInputValidator()}
440
+ edit_r = prompt([edit_q])
441
+ if edit_r and edit_r.get("text"):
442
+ session.edit_plan_step(step_index + 1, edit_r.get("text"))
443
+ console.print(f"✅ Step {step_index + 1} updated.", style="green")
444
+
445
+ elif sub_action == "Remove":
446
+ session.remove_plan_step(step_index + 1)
447
+ console.print(f"✅ Step {step_index + 1} removed.", style="green")
448
+
449
+ except (KeyboardInterrupt, InvalidArgument, IndexError, KeyError, TypeError): break
450
+
451
+ _save_session(session)
452
+ console.print("\n--- Returning to Agent ---", style="bold yellow")
453
+
454
+ def run_tui(args, scopes, recipes, scopes_file_path):
455
+ console = Console()
456
+ session = AgentSession(args, scopes, recipes)
457
+
458
+ if SESSION_FILE_PATH.exists():
459
+ if console.input("Found saved session. [bold]Resume?[/bold] (Y/n) ").lower() in ['y', 'yes', '']:
460
+ try:
461
+ with open(SESSION_FILE_PATH, 'r') as f: session.from_dict(json.load(f))
462
+ console.print("✅ Session resumed.", style="green")
463
+ except Exception as e: console.print(f"⚠️ Could not resume session: {e}", style="yellow"); _clear_session()
464
+ else: _clear_session()
465
+
466
+ completer = PatchLLMCompleter(scopes=session.scopes)
467
+ prompt_session = PromptSession(history=FileHistory(Path("~/.patchllm_history").expanduser()))
468
+
469
+ console.print("🤖 Welcome to the PatchLLM Agent. Type `/` and [TAB] for commands. `/exit` to quit.", style="bold blue")
470
+
471
+ try:
472
+ while True:
473
+ completer.set_session_state(
474
+ has_goal=bool(session.goal),
475
+ has_plan=bool(session.plan),
476
+ has_pending_changes=bool(session.last_execution_result),
477
+ can_revert=bool(session.last_revert_state),
478
+ has_context=bool(session.context)
479
+ )
480
+
481
+ text = prompt_session.prompt(">>> ", completer=FuzzyCompleter(completer)).strip()
482
+ if not text: continue
483
+
484
+ command, _, arg_string = text.partition(' ')
485
+ command = command.lower()
486
+
487
+ if command == '/exit': _clear_session(); break
488
+ elif command == '/help': console.print(_print_help())
489
+ elif command == '/task':
490
+ session.set_goal(arg_string); console.print("✅ Goal set.", style="green"); _save_session(session)
491
+
492
+ elif command == '/ask':
493
+ if not session.plan and not session.context:
494
+ console.print("❌ No plan or context to ask about. Use `/context` to load files or `/plan` to generate a plan.", style="red"); continue
495
+ if not arg_string: console.print("❌ Please provide a question.", style="red"); continue
496
+ with console.status("[cyan]Asking assistant..."): response = session.ask_question(arg_string)
497
+ if response:
498
+ console.print(Panel(response, title="Assistant's Answer", border_style="blue"))
499
+ else: console.print("❌ Failed to get a response.", style="red")
500
+
501
+ elif command == '/refine':
502
+ if not session.plan: console.print("❌ No plan to refine. Generate one with `/plan` first.", style="red"); continue
503
+ if not arg_string: console.print("❌ Please provide feedback or an idea.", style="red"); continue
504
+ with console.status("[cyan]Refining plan..."): success = session.refine_plan(arg_string)
505
+ if success:
506
+ console.print(Panel("\n".join(f"{i+1}. {s}" for i, s in enumerate(session.plan)), title="Refined Execution Plan", border_style="magenta"))
507
+ _save_session(session)
508
+ else: console.print("❌ Failed to refine the plan.", style="red")
509
+
510
+ elif command == '/plan':
511
+ if not arg_string:
512
+ if not session.goal and not session.plan:
513
+ console.print("❌ No goal set. Set one with `/task <your goal>`.", style="red"); continue
514
+
515
+ if session.plan:
516
+ _run_plan_management_tui(session, console)
517
+ if session.plan:
518
+ console.print(Panel("\n".join(f"{i+1}. {s}" for i, s in enumerate(session.plan)), title="Current Execution Plan", border_style="magenta"))
519
+ continue
520
+
521
+ with console.status("[cyan]Generating plan..."): success = session.create_plan()
522
+ if success:
523
+ console.print(Panel("\n".join(f"{i+1}. {s}" for i, s in enumerate(session.plan)), title="Execution Plan", border_style="magenta")); _save_session(session)
524
+ else: console.print("❌ Failed to generate a plan.", style="red")
525
+ else:
526
+ if not session.plan:
527
+ console.print("❌ No plan to manage. Generate one with `/plan` first.", style="red"); continue
528
+
529
+ edit_match = re.match(r"--edit\s+(\d+)\s+(.*)", arg_string, re.DOTALL)
530
+ rm_match = re.match(r"--rm\s+(\d+)", arg_string)
531
+ add_match = re.match(r"--add\s+(.*)", arg_string, re.DOTALL)
532
+
533
+ if edit_match:
534
+ step_num, new_text = int(edit_match.group(1)), edit_match.group(2)
535
+ if session.edit_plan_step(step_num, new_text):
536
+ console.print(f"✅ Step {step_num} updated.", style="green"); _save_session(session)
537
+ else: console.print(f"❌ Invalid step number: {step_num}.", style="red")
538
+ elif rm_match:
539
+ step_num = int(rm_match.group(1))
540
+ if session.remove_plan_step(step_num):
541
+ console.print(f"✅ Step {step_num} removed.", style="green"); _save_session(session)
542
+ else: console.print(f"❌ Invalid step number: {step_num}.", style="red")
543
+ elif add_match:
544
+ new_text = add_match.group(1)
545
+ session.add_plan_step(new_text)
546
+ console.print("✅ New step added to the end of the plan.", style="green"); _save_session(session)
547
+ else:
548
+ console.print(f"❌ Unknown argument for /plan: '{arg_string}'. Use --edit, --rm, or --add.", style="red")
549
+
550
+ console.print(Panel("\n".join(f"{i+1}. {s}" for i, s in enumerate(session.plan)), title="Updated Execution Plan", border_style="magenta"))
551
+
552
+ elif command == '/run':
553
+ result = None
554
+ if session.plan:
555
+ if session.current_step >= len(session.plan):
556
+ console.print("✅ Plan complete.", style="green")
557
+ continue
558
+
559
+ if arg_string == 'all':
560
+ remaining_count = len(session.plan) - session.current_step
561
+ console.print(f"\n--- Executing all {remaining_count} remaining steps ---", style="bold yellow")
562
+ with console.status("[cyan]Agent is working..."):
563
+ result = session.run_all_remaining_steps()
564
+ else:
565
+ console.print(f"\n--- Executing Step {session.current_step + 1}/{len(session.plan)} ---", style="bold yellow")
566
+ with console.status("[cyan]Agent is working..."):
567
+ result = session.run_next_step()
568
+ else:
569
+ if not session.goal:
570
+ console.print("❌ No plan or goal is set. Use `/task <goal>` to set a goal first.", style="red")
571
+ continue
572
+
573
+ console.print("\n--- Executing Goal Directly (No Plan) ---", style="bold yellow")
574
+ with console.status("[cyan]Agent is working..."):
575
+ result = session.run_goal_directly()
576
+
577
+ _display_execution_summary(result, console)
578
+ if result:
579
+ console.print("✅ Preview ready. Use `/diff` to review.", style="green")
580
+
581
+ elif command == '/skip':
582
+ if not session.plan: console.print("❌ No plan to skip from.", style="red"); continue
583
+ if session.skip_step():
584
+ console.print(f"✅ Step {session.current_step} skipped. Now at step {session.current_step + 1}.", style="green")
585
+ _save_session(session)
586
+ else:
587
+ console.print("✅ Plan already complete. Nothing to skip.", style="green")
588
+
589
+ elif command == '/diff':
590
+ if not session.last_execution_result or not session.last_execution_result.get("diffs"): console.print("❌ No diff to display.", style="red"); continue
591
+ diffs = session.last_execution_result["diffs"]
592
+ if arg_string and arg_string != 'all': diffs = [d for d in diffs if Path(d['file_path']).name == arg_string]
593
+ for diff in diffs: console.print(Panel(diff["diff_text"], title=f"Diff: {Path(diff['file_path']).name}", border_style="yellow"))
594
+
595
+ elif command == '/approve':
596
+ if not session.last_execution_result: console.print("❌ No changes to approve.", style="red"); continue
597
+
598
+ try:
599
+ from InquirerPy import prompt
600
+ summary = session.last_execution_result.get("summary", {})
601
+ all_files = summary.get("modified", []) + summary.get("created", [])
602
+
603
+ if not all_files:
604
+ console.print("✅ No file changes were proposed to approve.", style="yellow")
605
+ session.last_execution_result = None
606
+ continue
607
+
608
+ approve_q = {
609
+ "type": "checkbox", "name": "files", "message": "Select the changes you wish to apply:",
610
+ "choices": all_files, "validate": lambda r: len(r) > 0,
611
+ "invalid_message": "You must select at least one file to apply.",
612
+ "transformer": lambda r: f"{len(r)} file(s) selected."
613
+ }
614
+ result = prompt([approve_q])
615
+ files_to_approve = result.get("files") if result else []
616
+
617
+ if not files_to_approve:
618
+ console.print("Approval cancelled.", style="yellow"); continue
619
+
620
+ with console.status("[cyan]Applying..."):
621
+ is_full_approval = session.approve_changes(files_to_approve)
622
+
623
+ if is_full_approval:
624
+ console.print("✅ All changes applied. Moving to the next step.", style="green")
625
+ else:
626
+ console.print("✅ Partial changes applied.", style="green")
627
+ console.print("👉 Use `/retry <feedback>` to fix the remaining files, or `/skip` to move on.", style="cyan")
628
+
629
+ _save_session(session)
630
+
631
+ except (KeyboardInterrupt, ImportError):
632
+ console.print("Approval cancelled.", style="yellow")
633
+
634
+ elif command == '/retry':
635
+ if not session.last_execution_result:
636
+ console.print("❌ Nothing to retry.", style="red")
637
+ continue
638
+ if not arg_string:
639
+ console.print("❌ Please provide feedback for the retry.", style="red")
640
+ continue
641
+
642
+ console.print("\n--- Retrying ---", style="bold yellow")
643
+ with console.status("[cyan]Agent is working..."):
644
+ result = session.retry_step(arg_string)
645
+ _display_execution_summary(result, console)
646
+
647
+ elif command == '/revert':
648
+ if not session.last_revert_state: console.print("❌ No approved changes to revert.", style="red"); continue
649
+ with console.status("[cyan]Reverting changes..."): success = session.revert_last_approval()
650
+ if success:
651
+ console.print("✅ Last approved changes have been reverted.", style="green"); _save_session(session)
652
+ else: console.print("❌ Failed to revert changes.", style="red")
653
+
654
+ elif command == '/show':
655
+ if arg_string == 'goal':
656
+ if not session.goal: console.print("No goal set.", style="yellow")
657
+ else: console.print(Panel(escape(session.goal), title="Current Goal", border_style="blue"))
658
+ elif arg_string == 'plan':
659
+ if not session.plan: console.print("No plan exists.", style="yellow")
660
+ else: console.print(Panel("\n".join(f"{i+1}. {s}" for i, s in enumerate(session.plan)), title="Execution Plan", border_style="magenta"))
661
+ elif arg_string == 'step':
662
+ if not session.plan:
663
+ console.print("No plan exists.", style="yellow")
664
+ elif session.current_step >= len(session.plan):
665
+ console.print("✅ Plan is complete.", style="green")
666
+ else:
667
+ step_text = Text()
668
+ step_text.append(f"Current Step ({session.current_step + 1}/{len(session.plan)}):\n", style="bold green")
669
+ step_text.append(f" -> {escape(session.plan[session.current_step])}\n")
670
+ if session.current_step + 1 < len(session.plan):
671
+ step_text.append(f"\nNext Step ({session.current_step + 2}/{len(session.plan)}):\n", style="bold blue")
672
+ step_text.append(f" -> {escape(session.plan[session.current_step + 1])}")
673
+ console.print(Panel(step_text, title="Current Step", border_style="magenta"))
674
+ elif arg_string == 'context':
675
+ if not session.context_files: console.print("Context is empty.", style="yellow")
676
+ else:
677
+ tree = helpers.generate_source_tree(Path(".").resolve(), session.context_files)
678
+ console.print(Panel(tree, title="Context Tree", border_style="cyan"))
679
+ elif arg_string == 'history':
680
+ if not session.action_history: console.print("No actions recorded yet.", style="yellow")
681
+ else:
682
+ history_text = Text()
683
+ for i, entry in enumerate(session.action_history): history_text.append(f"{i+1}. {escape(entry)}\n")
684
+ console.print(Panel(history_text, title="Session History", border_style="blue"))
685
+ else:
686
+ console.print("Usage: /show [goal|plan|context|history|step]", style="yellow")
687
+
688
+ elif command == '/context':
689
+ with console.status("[cyan]Building..."): summary = session.load_context_from_scope(arg_string)
690
+ console.print(Panel(summary, title="Context Summary", border_style="cyan")); _save_session(session)
691
+
692
+ elif command == '/scopes':
693
+ _run_scope_management_tui(session.scopes, scopes_file_path, console)
694
+ session.reload_scopes(scopes_file_path)
695
+
696
+ elif command == '/settings':
697
+ _run_settings_tui(session, console)
698
+
699
+ else:
700
+ console.print(f"Unknown command: '{text}'.", style="yellow")
701
+ except (KeyboardInterrupt, EOFError): console.print()
702
+ except Exception as e: console.print(f"An unexpected error occurred: {e}", style="bold red")
703
+ console.print("\n👋 Exiting agent session. Goodbye!", style="yellow")