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.
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 -283
  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.1.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.1.dist-info/METADATA +0 -127
  51. patchllm-0.2.1.dist-info/RECORD +0 -12
  52. patchllm-0.2.1.dist-info/entry_points.txt +0 -2
  53. {patchllm-0.2.1.dist-info → patchllm-1.0.0.dist-info}/WHEEL +0 -0
  54. {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}
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