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,70 @@
|
|
1
|
+
import re
|
2
|
+
import textwrap
|
3
|
+
|
4
|
+
# --- Constants for File Exclusion ---
|
5
|
+
|
6
|
+
DEFAULT_EXCLUDE_EXTENSIONS = [
|
7
|
+
# General
|
8
|
+
".log", ".lock", ".env", ".bak", ".tmp", ".swp", ".swo", ".db", ".sqlite3",
|
9
|
+
# Python
|
10
|
+
".pyc", ".pyo", ".pyd",
|
11
|
+
# JS/Node
|
12
|
+
".next", ".svelte-kit",
|
13
|
+
# OS-specific
|
14
|
+
".DS_Store",
|
15
|
+
# Media/Binary files
|
16
|
+
".mp3", ".mp4", ".mov", ".avi", ".pdf",
|
17
|
+
".o", ".so", ".dll", ".exe",
|
18
|
+
# Unity specific
|
19
|
+
".meta",
|
20
|
+
]
|
21
|
+
|
22
|
+
STRUCTURE_EXCLUDE_DIRS = ['.git', '__pycache__', 'node_modules', '.venv', 'dist', 'build']
|
23
|
+
|
24
|
+
# --- Templates ---
|
25
|
+
|
26
|
+
BASE_TEMPLATE = textwrap.dedent('''
|
27
|
+
Source Tree:
|
28
|
+
------------
|
29
|
+
```
|
30
|
+
{{source_tree}}
|
31
|
+
```
|
32
|
+
{{url_contents}}
|
33
|
+
Relevant Files:
|
34
|
+
---------------
|
35
|
+
{{files_content}}
|
36
|
+
''')
|
37
|
+
|
38
|
+
URL_CONTENT_TEMPLATE = textwrap.dedent('''
|
39
|
+
URL Contents:
|
40
|
+
-------------
|
41
|
+
{{content}}
|
42
|
+
''')
|
43
|
+
|
44
|
+
STRUCTURE_TEMPLATE = textwrap.dedent('''
|
45
|
+
Project Structure Outline:
|
46
|
+
--------------------------
|
47
|
+
{{structure_content}}
|
48
|
+
''')
|
49
|
+
|
50
|
+
# --- Language Patterns for @structure ---
|
51
|
+
|
52
|
+
LANGUAGE_PATTERNS = {
|
53
|
+
'python': {
|
54
|
+
'extensions': ['.py'],
|
55
|
+
'patterns': [
|
56
|
+
('imports', re.compile(r"^\s*(?:from\s+[\w\.]+\s+)?import\s+[\w\.\*,\s\(\)]+")),
|
57
|
+
('class', re.compile(r"^\s*class\s+.*?:")),
|
58
|
+
('function', re.compile(r"^\s*(?:async\s+)?def\s+.*?\(.*?\).*?:")),
|
59
|
+
]
|
60
|
+
},
|
61
|
+
'javascript': {
|
62
|
+
'extensions': ['.js', '.jsx', '.ts', '.tsx'],
|
63
|
+
'patterns': [
|
64
|
+
('imports', re.compile(r"^\s*import\s+.*from\s+.*|^\s*(?:const|let|var)\s+.*?=\s*require\(.*")),
|
65
|
+
('class', re.compile(r"^\s*(?:export\s+)?class\s+\w+.*\{.*\}")),
|
66
|
+
# --- FIX: Modified regex to capture the entire line for single-line functions ---
|
67
|
+
('function', re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+\w+\(.*\)\s*\{.*|^\s*(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async)?\s*\(.*\)\s*=>\s*\{?.*")),
|
68
|
+
]
|
69
|
+
}
|
70
|
+
}
|
@@ -0,0 +1,147 @@
|
|
1
|
+
import glob
|
2
|
+
import textwrap
|
3
|
+
import subprocess
|
4
|
+
import shutil
|
5
|
+
from pathlib import Path
|
6
|
+
from rich.console import Console
|
7
|
+
import base64
|
8
|
+
import mimetypes
|
9
|
+
|
10
|
+
from .constants import BASE_TEMPLATE, URL_CONTENT_TEMPLATE
|
11
|
+
|
12
|
+
console = Console()
|
13
|
+
|
14
|
+
SUPPORTED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
15
|
+
|
16
|
+
def find_files(base_path: Path, include_patterns: list[str], exclude_patterns: list[str] = None) -> list[Path]:
|
17
|
+
"""Finds all files using glob patterns."""
|
18
|
+
exclude_patterns = exclude_patterns or []
|
19
|
+
included_files = set()
|
20
|
+
for pattern in include_patterns:
|
21
|
+
search_path = base_path / pattern
|
22
|
+
for match in glob.glob(str(search_path), recursive=True):
|
23
|
+
path_obj = Path(match)
|
24
|
+
if path_obj.is_file():
|
25
|
+
included_files.add(path_obj.resolve())
|
26
|
+
|
27
|
+
excluded_files = set()
|
28
|
+
for pattern in exclude_patterns:
|
29
|
+
search_path = base_path / pattern
|
30
|
+
for match in glob.glob(str(search_path), recursive=True):
|
31
|
+
path_obj = Path(match)
|
32
|
+
if path_obj.is_file():
|
33
|
+
excluded_files.add(path_obj.resolve())
|
34
|
+
|
35
|
+
return sorted(list(included_files - excluded_files))
|
36
|
+
|
37
|
+
def filter_files_by_keyword(file_paths: list[Path], search_words: list[str]) -> list[Path]:
|
38
|
+
"""Returns files that contain any of the search words."""
|
39
|
+
if not search_words:
|
40
|
+
return file_paths
|
41
|
+
matching_files = []
|
42
|
+
for file_path in file_paths:
|
43
|
+
try:
|
44
|
+
if any(word in file_path.read_text(encoding='utf-8', errors='ignore') for word in search_words):
|
45
|
+
matching_files.append(file_path)
|
46
|
+
except Exception:
|
47
|
+
pass
|
48
|
+
return matching_files
|
49
|
+
|
50
|
+
def generate_source_tree(base_path: Path, file_paths: list[Path]) -> str:
|
51
|
+
"""Generates a string representation of the file paths as a tree."""
|
52
|
+
tree = {}
|
53
|
+
for path in file_paths:
|
54
|
+
try:
|
55
|
+
rel_path = path.relative_to(base_path)
|
56
|
+
level = tree
|
57
|
+
for part in rel_path.parts:
|
58
|
+
level = level.setdefault(part, {})
|
59
|
+
except ValueError:
|
60
|
+
continue
|
61
|
+
|
62
|
+
def _format_tree(tree_dict, indent=""):
|
63
|
+
lines = []
|
64
|
+
items = sorted(tree_dict.items(), key=lambda i: (not i[1], i[0]))
|
65
|
+
for i, (name, node) in enumerate(items):
|
66
|
+
connector = "└── " if i == len(items) - 1 else "├── "
|
67
|
+
lines.append(f"{indent}{connector}{name}")
|
68
|
+
if node:
|
69
|
+
new_indent = indent + (" " if i == len(items) - 1 else "│ ")
|
70
|
+
lines.extend(_format_tree(node, new_indent))
|
71
|
+
return lines
|
72
|
+
|
73
|
+
return f"{base_path.name}\n" + "\n".join(_format_tree(tree))
|
74
|
+
|
75
|
+
def _format_context(file_paths: list[Path], urls: list[str], base_path: Path) -> dict | None:
|
76
|
+
"""Helper to format the final context string and preserve the file list."""
|
77
|
+
source_tree_str = generate_source_tree(base_path, file_paths)
|
78
|
+
file_contents = []
|
79
|
+
image_files_data = []
|
80
|
+
|
81
|
+
for file_path in file_paths:
|
82
|
+
if file_path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS:
|
83
|
+
try:
|
84
|
+
mime_type, _ = mimetypes.guess_type(file_path)
|
85
|
+
if mime_type and mime_type.startswith('image/'):
|
86
|
+
with open(file_path, "rb") as f:
|
87
|
+
content_base64 = base64.b64encode(f.read()).decode('utf-8')
|
88
|
+
image_files_data.append({
|
89
|
+
"path": file_path,
|
90
|
+
"mime_type": mime_type,
|
91
|
+
"content_base64": content_base64
|
92
|
+
})
|
93
|
+
except Exception as e:
|
94
|
+
console.print(f"⚠️ Could not process image file {file_path}: {e}", style="yellow")
|
95
|
+
else:
|
96
|
+
try:
|
97
|
+
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
98
|
+
file_contents.append(f"<file_path:{file_path.as_posix()}>\n```\n{content}\n```")
|
99
|
+
except Exception as e:
|
100
|
+
console.print(f"⚠️ Could not read file {file_path}: {e}", style="yellow")
|
101
|
+
|
102
|
+
files_content_str = "\n\n".join(file_contents)
|
103
|
+
url_contents_str = fetch_and_process_urls(urls)
|
104
|
+
|
105
|
+
final_context = BASE_TEMPLATE.replace("{{source_tree}}", source_tree_str)
|
106
|
+
final_context = final_context.replace("{{url_contents}}", url_contents_str)
|
107
|
+
final_context = final_context.replace("{{files_content}}", files_content_str)
|
108
|
+
|
109
|
+
# --- CORRECTION: Return the original, pristine list of Path objects ---
|
110
|
+
return {
|
111
|
+
"tree": source_tree_str,
|
112
|
+
"context": final_context,
|
113
|
+
"files": file_paths,
|
114
|
+
"images": image_files_data
|
115
|
+
}
|
116
|
+
|
117
|
+
def fetch_and_process_urls(urls: list[str]) -> str:
|
118
|
+
"""Downloads and converts a list of URLs to text."""
|
119
|
+
if not urls: return ""
|
120
|
+
try:
|
121
|
+
import html2text
|
122
|
+
except ImportError:
|
123
|
+
console.print("⚠️ 'html2text' is required. Install with: pip install 'patchllm[url]'", style="yellow")
|
124
|
+
return ""
|
125
|
+
|
126
|
+
downloader = "curl" if shutil.which("curl") else "wget" if shutil.which("wget") else None
|
127
|
+
if not downloader:
|
128
|
+
console.print("⚠️ 'curl' or 'wget' not found.", style="yellow")
|
129
|
+
return ""
|
130
|
+
|
131
|
+
h = html2text.HTML2Text()
|
132
|
+
h.ignore_links = True
|
133
|
+
h.ignore_images = True
|
134
|
+
all_url_contents = []
|
135
|
+
|
136
|
+
for url in urls:
|
137
|
+
try:
|
138
|
+
command = ["curl", "-s", "-L", url] if downloader == "curl" else ["wget", "-q", "-O", "-", url]
|
139
|
+
result = subprocess.run(command, capture_output=True, text=True, check=True, timeout=15)
|
140
|
+
text_content = h.handle(result.stdout)
|
141
|
+
all_url_contents.append(f"<url_content:{url}>\n```\n{text_content}\n```")
|
142
|
+
except Exception as e:
|
143
|
+
console.print(f"❌ Failed to fetch {url}: {e}", style="red")
|
144
|
+
|
145
|
+
if not all_url_contents: return ""
|
146
|
+
content_str = "\n\n".join(all_url_contents)
|
147
|
+
return URL_CONTENT_TEMPLATE.replace("{{content}}", content_str)
|
@@ -0,0 +1,82 @@
|
|
1
|
+
import subprocess
|
2
|
+
from pathlib import Path
|
3
|
+
import re
|
4
|
+
import os
|
5
|
+
from rich.console import Console
|
6
|
+
|
7
|
+
console = Console()
|
8
|
+
|
9
|
+
def _run_git_command(command: list[str], base_path: Path) -> list[Path]:
|
10
|
+
try:
|
11
|
+
result = subprocess.run(command, capture_output=True, text=True, check=True, cwd=base_path)
|
12
|
+
files = result.stdout.strip().split('\n')
|
13
|
+
return [base_path / f for f in files if f]
|
14
|
+
except (FileNotFoundError, subprocess.CalledProcessError):
|
15
|
+
return []
|
16
|
+
|
17
|
+
def resolve_dynamic_scope(scope_name: str, base_path: Path) -> list[Path]:
|
18
|
+
"""Resolves a dynamic scope string to a list of file paths."""
|
19
|
+
if scope_name.startswith('@error:"') and scope_name.endswith('"'):
|
20
|
+
traceback = scope_name[len('@error:"'):-1]
|
21
|
+
pattern = r'File "([^"]+)"'
|
22
|
+
matches = re.findall(pattern, traceback)
|
23
|
+
return sorted([Path(f).resolve() for f in matches if Path(f).exists()])
|
24
|
+
|
25
|
+
search_match = re.match(r'@search:"([^"]+)"', scope_name)
|
26
|
+
if search_match:
|
27
|
+
from .helpers import find_files, filter_files_by_keyword
|
28
|
+
all_files = find_files(base_path, ["**/*"])
|
29
|
+
return filter_files_by_keyword(all_files, [search_match.group(1)])
|
30
|
+
|
31
|
+
related_match = re.match(r'@related:(.+)', scope_name)
|
32
|
+
if related_match:
|
33
|
+
start_path = (base_path / related_match.group(1).strip()).resolve()
|
34
|
+
if not start_path.exists():
|
35
|
+
return []
|
36
|
+
related_files = {start_path}
|
37
|
+
stem = start_path.stem
|
38
|
+
test_variations = [
|
39
|
+
start_path.parent / f"test_{stem}{start_path.suffix}",
|
40
|
+
base_path / "tests" / f"test_{stem}{start_path.suffix}",
|
41
|
+
start_path.parent.parent / "tests" / start_path.parent.name / f"test_{stem}{start_path.suffix}"
|
42
|
+
]
|
43
|
+
for path in test_variations:
|
44
|
+
if path.exists():
|
45
|
+
related_files.add(path)
|
46
|
+
sibling_exts = ['.css', '.js', '.html', '.scss', '.py', '.md']
|
47
|
+
for ext in sibling_exts:
|
48
|
+
if ext != start_path.suffix:
|
49
|
+
sibling_path = start_path.with_suffix(ext)
|
50
|
+
if sibling_path.exists():
|
51
|
+
related_files.add(sibling_path)
|
52
|
+
return sorted(list(related_files))
|
53
|
+
|
54
|
+
dir_match = re.match(r'@dir:(.+)', scope_name)
|
55
|
+
if dir_match:
|
56
|
+
dir_path = (base_path / dir_match.group(1).strip()).resolve()
|
57
|
+
if not dir_path.is_dir(): return []
|
58
|
+
return sorted([f for f in dir_path.iterdir() if f.is_file()])
|
59
|
+
|
60
|
+
branch_match = re.match(r'@git:branch(?::(.+))?', scope_name)
|
61
|
+
if branch_match:
|
62
|
+
base_branch = branch_match.group(1) or os.environ.get("GIT_BASE_BRANCH", "main")
|
63
|
+
return _run_git_command(["git", "diff", "--name-only", f"{base_branch}...HEAD"], base_path)
|
64
|
+
|
65
|
+
git_commands = {
|
66
|
+
"@git": ["git", "diff", "--name-only", "--cached"],
|
67
|
+
"@git:staged": ["git", "diff", "--name-only", "--cached"],
|
68
|
+
"@git:unstaged": ["git", "diff", "--name-only"],
|
69
|
+
"@git:lastcommit": ["git", "show", "--pretty=format:", "--name-only", "HEAD"],
|
70
|
+
"@git:conflicts": ["git", "diff", "--name-only", "--diff-filter=U"],
|
71
|
+
}
|
72
|
+
if scope_name in git_commands:
|
73
|
+
return _run_git_command(git_commands[scope_name], base_path)
|
74
|
+
|
75
|
+
if scope_name == "@recent":
|
76
|
+
all_files = filter(Path.is_file, base_path.rglob('*'))
|
77
|
+
excluded_dirs = ['.git', '__pycache__', 'node_modules', '.venv']
|
78
|
+
filtered_files = [p for p in all_files if not any(excluded in p.parts for excluded in excluded_dirs)]
|
79
|
+
return sorted(filtered_files, key=lambda p: p.stat().st_mtime, reverse=True)[:5]
|
80
|
+
|
81
|
+
console.print(f"❌ Unknown or invalid dynamic scope '{scope_name}'.", style="red")
|
82
|
+
return []
|
@@ -0,0 +1,64 @@
|
|
1
|
+
import re
|
2
|
+
from pathlib import Path
|
3
|
+
from rich.console import Console
|
4
|
+
|
5
|
+
# --- FIX: Removed the unnecessary import that was causing the circular dependency ---
|
6
|
+
# from patchllm.scopes.builder import build_context
|
7
|
+
|
8
|
+
from .constants import (
|
9
|
+
STRUCTURE_EXCLUDE_DIRS,
|
10
|
+
DEFAULT_EXCLUDE_EXTENSIONS,
|
11
|
+
LANGUAGE_PATTERNS,
|
12
|
+
STRUCTURE_TEMPLATE
|
13
|
+
)
|
14
|
+
|
15
|
+
console = Console()
|
16
|
+
|
17
|
+
def _extract_symbols_by_regex(content: str, lang_patterns: list) -> dict:
|
18
|
+
symbols = {"imports": [], "class": [], "function": []}
|
19
|
+
for line in content.splitlines():
|
20
|
+
for symbol_type, pattern in lang_patterns:
|
21
|
+
match = pattern.match(line)
|
22
|
+
if match:
|
23
|
+
symbols[symbol_type].append(match.group(0).strip())
|
24
|
+
break
|
25
|
+
return symbols
|
26
|
+
|
27
|
+
def _build_structure_context(base_path: Path) -> dict | None:
|
28
|
+
"""Builds a context by extracting symbols from all project files."""
|
29
|
+
all_files = []
|
30
|
+
for p in base_path.rglob('*'):
|
31
|
+
if any(part in STRUCTURE_EXCLUDE_DIRS for part in p.parts):
|
32
|
+
continue
|
33
|
+
if p.is_file() and p.suffix.lower() not in DEFAULT_EXCLUDE_EXTENSIONS:
|
34
|
+
all_files.append(p)
|
35
|
+
|
36
|
+
structure_outputs = []
|
37
|
+
for file_path in sorted(all_files):
|
38
|
+
lang = None
|
39
|
+
for lang_name, config in LANGUAGE_PATTERNS.items():
|
40
|
+
if file_path.suffix in config['extensions']:
|
41
|
+
lang = lang_name
|
42
|
+
break
|
43
|
+
if lang:
|
44
|
+
try:
|
45
|
+
content = file_path.read_text(encoding='utf-8', errors='ignore')
|
46
|
+
symbols = _extract_symbols_by_regex(content, LANGUAGE_PATTERNS[lang]['patterns'])
|
47
|
+
if any(symbols.values()):
|
48
|
+
rel_path = file_path.relative_to(base_path)
|
49
|
+
output = [f"<file_path:{rel_path.as_posix()}>"]
|
50
|
+
if symbols['imports']:
|
51
|
+
output.append("[imports]")
|
52
|
+
output.extend([f"- {s}" for s in symbols['imports']])
|
53
|
+
if symbols['class'] or symbols['function']:
|
54
|
+
output.append("[symbols]")
|
55
|
+
output.extend([f"- {s}" for s in symbols['class']])
|
56
|
+
output.extend([f"- {s}" for s in symbols['function']])
|
57
|
+
structure_outputs.append("\n".join(output))
|
58
|
+
except Exception as e:
|
59
|
+
console.print(f"⚠️ Could not process {file_path}: {e}", style="yellow")
|
60
|
+
|
61
|
+
if not structure_outputs: return None
|
62
|
+
final_content = "\n\n".join(structure_outputs)
|
63
|
+
final_context = STRUCTURE_TEMPLATE.replace("{{structure_content}}", final_content)
|
64
|
+
return {"tree": "Project structure view", "context": final_context}
|
patchllm/tui/__init__.py
ADDED
File without changes
|
@@ -0,0 +1,153 @@
|
|
1
|
+
from prompt_toolkit.completion import Completer, Completion
|
2
|
+
from prompt_toolkit.document import Document
|
3
|
+
|
4
|
+
# A structured definition for all commands, including their display name,
|
5
|
+
# description, and the states in which they are available.
|
6
|
+
COMMAND_DEFINITIONS = [
|
7
|
+
# Agent Workflow
|
8
|
+
{"command": "/approve", "display": "agent - approve changes", "meta": "Applies the changes from the last run.", "states": ["has_pending_changes"]},
|
9
|
+
{"command": "/diff", "display": "agent - view diff", "meta": "Shows the full diff for the proposed changes.", "states": ["has_pending_changes"]},
|
10
|
+
{"command": "/retry", "display": "agent - retry with feedback", "meta": "Retries the last step with new feedback.", "states": ["has_pending_changes"]},
|
11
|
+
{"command": "/revert", "display": "agent - revert last approval", "meta": "Reverts the changes from the last /approve.", "states": ["can_revert"]},
|
12
|
+
{"command": "/run", "display": "agent - run step", "meta": "Executes the next step. Use '/run all' for all steps.", "states": ["has_plan"]},
|
13
|
+
{"command": "/skip", "display": "agent - skip step", "meta": "Skips the current step and moves to the next.", "states": ["has_plan"]},
|
14
|
+
# Context Management
|
15
|
+
{"command": "/context", "display": "context - set context", "meta": "Replaces the context with files from a scope.", "states": ["initial", "has_goal", "has_plan"]},
|
16
|
+
{"command": "/scopes", "display": "context - manage scopes", "meta": "Opens an interactive menu to manage your saved scopes.", "states": ["initial", "has_goal", "has_plan"]},
|
17
|
+
# Planning Workflow
|
18
|
+
{"command": "/ask", "display": "agent - ask question", "meta": "Ask a question about the plan or code context.", "states": ["has_goal", "has_plan", "has_context"]},
|
19
|
+
{"command": "/plan", "display": "plan - generate or manage", "meta": "Generates a plan or manages the existing one.", "states": ["has_goal", "has_plan"]},
|
20
|
+
{"command": "/refine", "display": "plan - refine with feedback", "meta": "Refine the plan based on new feedback or ideas.", "states": ["has_plan"]},
|
21
|
+
# Task Management
|
22
|
+
{"command": "/task", "display": "task - set goal", "meta": "Sets the high-level goal for the agent.", "states": ["initial", "has_goal", "has_plan"]},
|
23
|
+
# Menu / Session Management
|
24
|
+
{"command": "/exit", "display": "menu - exit session", "meta": "Exits the agent session.", "states": ["initial", "has_goal", "has_plan"]},
|
25
|
+
{"command": "/help", "display": "menu - help", "meta": "Shows the detailed help message.", "states": ["initial", "has_goal", "has_plan"]},
|
26
|
+
{"command": "/show", "display": "menu - show state", "meta": "Shows the current goal, plan, context, history, or step.", "states": ["initial", "has_goal", "has_plan"]},
|
27
|
+
{"command": "/settings", "display": "menu - settings", "meta": "Configure the model and API keys.", "states": ["initial", "has_goal", "has_plan"]},
|
28
|
+
]
|
29
|
+
|
30
|
+
|
31
|
+
class PatchLLMCompleter(Completer):
|
32
|
+
"""
|
33
|
+
A custom completer for prompt_toolkit that provides context-aware suggestions
|
34
|
+
for commands and scopes, including descriptive metadata.
|
35
|
+
"""
|
36
|
+
def __init__(self, scopes: dict):
|
37
|
+
self.all_command_defs = sorted(COMMAND_DEFINITIONS, key=lambda x: x['display'])
|
38
|
+
self.scopes = scopes
|
39
|
+
self.static_scopes = sorted(list(scopes.keys()))
|
40
|
+
self.dynamic_scopes = [
|
41
|
+
"@git", "@git:staged", "@git:unstaged", "@git:lastcommit",
|
42
|
+
"@git:conflicts", "@git:branch:", "@recent", "@structure",
|
43
|
+
"@dir:", "@related:", "@search:", "@error:"
|
44
|
+
]
|
45
|
+
self.all_scopes = sorted(self.static_scopes + self.dynamic_scopes)
|
46
|
+
self.plan_sub_commands = ['--edit ', '--rm ', '--add ']
|
47
|
+
self.show_sub_commands = ['goal', 'plan', 'context', 'history', 'step']
|
48
|
+
|
49
|
+
# State flags
|
50
|
+
self.has_goal = False
|
51
|
+
self.has_plan = False
|
52
|
+
self.has_pending_changes = False
|
53
|
+
self.can_revert = False
|
54
|
+
self.has_context = False
|
55
|
+
|
56
|
+
def set_session_state(self, has_goal: bool, has_plan: bool, has_pending_changes: bool, can_revert: bool, has_context: bool):
|
57
|
+
"""Updates the completer's state from the agent session."""
|
58
|
+
self.has_goal = has_goal
|
59
|
+
self.has_plan = has_plan
|
60
|
+
self.has_pending_changes = has_pending_changes
|
61
|
+
self.can_revert = can_revert
|
62
|
+
self.has_context = has_context
|
63
|
+
|
64
|
+
def get_completions(self, document: Document, complete_event):
|
65
|
+
"""
|
66
|
+
Yields completions based on the current user input and agent state.
|
67
|
+
"""
|
68
|
+
text = document.text_before_cursor
|
69
|
+
words = text.lstrip().split()
|
70
|
+
word_count = len(words)
|
71
|
+
|
72
|
+
active_states = {"initial"}
|
73
|
+
if self.has_goal:
|
74
|
+
active_states.add("has_goal")
|
75
|
+
if self.has_plan:
|
76
|
+
active_states.add("has_plan")
|
77
|
+
if self.has_pending_changes:
|
78
|
+
active_states.add("has_pending_changes")
|
79
|
+
if self.can_revert:
|
80
|
+
active_states.add("can_revert")
|
81
|
+
if self.has_context:
|
82
|
+
active_states.add("has_context")
|
83
|
+
|
84
|
+
# Case 1: We are typing the first word (the command)
|
85
|
+
if word_count == 0 or (word_count == 1 and not text.endswith(' ')):
|
86
|
+
command_to_complete = words[0] if words else "/"
|
87
|
+
if command_to_complete.startswith('/'):
|
88
|
+
for definition in self.all_command_defs:
|
89
|
+
is_valid_state = any(s in active_states for s in definition["states"])
|
90
|
+
|
91
|
+
if is_valid_state and definition["command"].startswith(command_to_complete):
|
92
|
+
yield Completion(
|
93
|
+
definition["command"],
|
94
|
+
start_position=-len(command_to_complete),
|
95
|
+
display=definition["display"],
|
96
|
+
display_meta=definition["meta"]
|
97
|
+
)
|
98
|
+
return
|
99
|
+
|
100
|
+
# Special Case: We are typing after /run
|
101
|
+
if words and words[0] == '/run':
|
102
|
+
if word_count == 1 and text.endswith(' '):
|
103
|
+
yield Completion("all", start_position=0, display_meta="Execute all remaining steps.")
|
104
|
+
return
|
105
|
+
if word_count == 2 and not text.endswith(' '):
|
106
|
+
if "all".startswith(words[1]):
|
107
|
+
yield Completion("all", start_position=-len(words[1]), display_meta="Execute all remaining steps.")
|
108
|
+
return
|
109
|
+
|
110
|
+
# Case 2: We are in a "scope" context
|
111
|
+
if words and words[0] in ['/context']:
|
112
|
+
scope_to_complete = words[1] if word_count > 1 else ""
|
113
|
+
|
114
|
+
if word_count == 1 and text.endswith(' '):
|
115
|
+
for scope in self.all_scopes:
|
116
|
+
meta = "Static scope" if scope in self.static_scopes else "Dynamic scope"
|
117
|
+
yield Completion(scope, start_position=0, display_meta=meta)
|
118
|
+
return
|
119
|
+
|
120
|
+
if word_count == 2 and not text.endswith(' '):
|
121
|
+
for scope in self.all_scopes:
|
122
|
+
if scope.startswith(scope_to_complete):
|
123
|
+
meta = "Static scope" if scope in self.static_scopes else "Dynamic scope"
|
124
|
+
yield Completion(scope, start_position=-len(scope_to_complete), display_meta=meta)
|
125
|
+
return
|
126
|
+
|
127
|
+
# Case 3: We are in a "plan" management context
|
128
|
+
if words and words[0] == '/plan':
|
129
|
+
if word_count == 1 and text.endswith(' '):
|
130
|
+
for sub_cmd in self.plan_sub_commands:
|
131
|
+
yield Completion(sub_cmd, start_position=0)
|
132
|
+
return
|
133
|
+
|
134
|
+
if word_count == 2 and not text.endswith(' '):
|
135
|
+
sub_cmd_to_complete = words[1]
|
136
|
+
for sub_cmd in self.plan_sub_commands:
|
137
|
+
if sub_cmd.startswith(sub_cmd_to_complete):
|
138
|
+
yield Completion(sub_cmd, start_position=-len(sub_cmd_to_complete))
|
139
|
+
return
|
140
|
+
|
141
|
+
# Case 4: We are in a "show" context
|
142
|
+
if words and words[0] == '/show':
|
143
|
+
if word_count == 1 and text.endswith(' '):
|
144
|
+
for sub_cmd in self.show_sub_commands:
|
145
|
+
yield Completion(sub_cmd, start_position=0)
|
146
|
+
return
|
147
|
+
|
148
|
+
if word_count == 2 and not text.endswith(' '):
|
149
|
+
sub_cmd_to_complete = words[1]
|
150
|
+
for sub_cmd in self.show_sub_commands:
|
151
|
+
if sub_cmd.startswith(sub_cmd_to_complete):
|
152
|
+
yield Completion(sub_cmd, start_position=-len(sub_cmd_to_complete))
|
153
|
+
return
|