patchllm 0.2.1__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.
- patchllm/__main__.py +0 -0
- patchllm/agent/__init__.py +0 -0
- patchllm/agent/actions.py +73 -0
- patchllm/agent/executor.py +57 -0
- patchllm/agent/planner.py +76 -0
- patchllm/agent/session.py +425 -0
- patchllm/cli/__init__.py +0 -0
- patchllm/cli/entrypoint.py +120 -0
- patchllm/cli/handlers.py +192 -0
- patchllm/cli/helpers.py +72 -0
- patchllm/interactive/__init__.py +0 -0
- patchllm/interactive/selector.py +100 -0
- patchllm/llm.py +39 -0
- patchllm/main.py +1 -283
- patchllm/parser.py +120 -64
- patchllm/patcher.py +118 -0
- patchllm/scopes/__init__.py +0 -0
- patchllm/scopes/builder.py +55 -0
- patchllm/scopes/constants.py +70 -0
- patchllm/scopes/helpers.py +147 -0
- patchllm/scopes/resolvers.py +82 -0
- patchllm/scopes/structure.py +64 -0
- patchllm/tui/__init__.py +0 -0
- patchllm/tui/completer.py +153 -0
- patchllm/tui/interface.py +703 -0
- patchllm/utils.py +19 -1
- patchllm/voice/__init__.py +0 -0
- patchllm/{listener.py → voice/listener.py} +8 -1
- patchllm-1.0.0.dist-info/METADATA +153 -0
- patchllm-1.0.0.dist-info/RECORD +51 -0
- patchllm-1.0.0.dist-info/entry_points.txt +2 -0
- {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/top_level.txt +1 -0
- tests/__init__.py +0 -0
- tests/conftest.py +112 -0
- tests/test_actions.py +62 -0
- tests/test_agent.py +383 -0
- tests/test_completer.py +121 -0
- tests/test_context.py +140 -0
- tests/test_executor.py +60 -0
- tests/test_interactive.py +64 -0
- tests/test_parser.py +70 -0
- tests/test_patcher.py +71 -0
- tests/test_planner.py +53 -0
- tests/test_recipes.py +111 -0
- tests/test_scopes.py +47 -0
- tests/test_structure.py +48 -0
- tests/test_tui.py +397 -0
- tests/test_utils.py +31 -0
- patchllm/context.py +0 -238
- patchllm-0.2.1.dist-info/METADATA +0 -127
- patchllm-0.2.1.dist-info/RECORD +0 -12
- patchllm-0.2.1.dist-info/entry_points.txt +0 -2
- {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
- {patchllm-0.2.1.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")
|