tunacode-cli 0.0.70__py3-none-any.whl → 0.0.78.6__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/commands/__init__.py +0 -2
- tunacode/cli/commands/implementations/__init__.py +0 -3
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/system.py +3 -2
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +8 -7
- tunacode/cli/commands/slash/loader.py +2 -1
- tunacode/cli/commands/slash/validator.py +2 -1
- tunacode/cli/main.py +19 -1
- tunacode/cli/repl.py +90 -229
- tunacode/cli/repl_components/command_parser.py +2 -1
- tunacode/cli/repl_components/error_recovery.py +8 -5
- tunacode/cli/repl_components/output_display.py +1 -10
- tunacode/cli/repl_components/tool_executor.py +1 -13
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +6 -42
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +162 -158
- tunacode/core/agents/agent_components/agent_helpers.py +31 -2
- tunacode/core/agents/agent_components/node_processor.py +180 -146
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -122
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +88 -227
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +2 -1
- tunacode/core/state.py +16 -64
- tunacode/core/token_usage/usage_tracker.py +3 -1
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -60
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +97 -1
- tunacode/setup.py +0 -23
- tunacode/tools/base.py +54 -1
- tunacode/tools/bash.py +14 -0
- tunacode/tools/glob.py +4 -2
- tunacode/tools/grep.py +7 -17
- tunacode/tools/prompts/glob_prompt.xml +1 -1
- tunacode/tools/prompts/grep_prompt.xml +1 -0
- tunacode/tools/prompts/list_dir_prompt.xml +1 -1
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +1 -1
- tunacode/tools/react.py +153 -0
- tunacode/tools/run_command.py +15 -0
- tunacode/types.py +14 -79
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +8 -3
- tunacode/ui/keybindings.py +0 -18
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +173 -49
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +1 -20
- tunacode/ui/tool_ui.py +30 -8
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/text_utils.py +18 -1
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/METADATA +80 -12
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/RECORD +78 -74
- tunacode/cli/commands/implementations/plan.py +0 -50
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -186
- tunacode/prompts/system.md +0 -359
- tunacode/prompts/system.md.bak +0 -487
- tunacode/tools/exit_plan_mode.py +0 -273
- tunacode/tools/present_plan.py +0 -288
- tunacode/tools/prompts/exit_plan_mode_prompt.xml +0 -25
- tunacode/tools/prompts/present_plan_prompt.xml +0 -20
- tunacode/tools/prompts/todo_prompt.xml +0 -96
- tunacode/tools/todo.py +0 -456
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
tunacode/types.py
CHANGED
|
@@ -6,7 +6,6 @@ used throughout the TunaCode codebase.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
-
from datetime import datetime
|
|
10
9
|
|
|
11
10
|
# Plan types will be defined below
|
|
12
11
|
from enum import Enum
|
|
@@ -17,39 +16,20 @@ from typing import (
|
|
|
17
16
|
Callable,
|
|
18
17
|
Dict,
|
|
19
18
|
List,
|
|
20
|
-
Literal,
|
|
21
19
|
Optional,
|
|
22
20
|
Protocol,
|
|
23
21
|
Tuple,
|
|
24
22
|
Union,
|
|
25
23
|
)
|
|
26
24
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
from pydantic_ai.messages import ModelRequest, ToolReturnPart
|
|
25
|
+
# Import pydantic-ai types (required dependency)
|
|
26
|
+
from pydantic_ai import Agent
|
|
27
|
+
from pydantic_ai.messages import ModelRequest, ToolReturnPart
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
except ImportError:
|
|
37
|
-
# Fallback if pydantic-ai is not available
|
|
38
|
-
PydanticAgent = Any
|
|
39
|
-
MessagePart = Any # type: ignore[misc]
|
|
40
|
-
ModelRequest = Any
|
|
41
|
-
ModelResponse = Any # type: ignore[misc]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@dataclass
|
|
45
|
-
class TodoItem:
|
|
46
|
-
id: str
|
|
47
|
-
content: str
|
|
48
|
-
status: Literal["pending", "in_progress", "completed"]
|
|
49
|
-
priority: Literal["high", "medium", "low"]
|
|
50
|
-
created_at: datetime
|
|
51
|
-
completed_at: Optional[datetime] = None
|
|
52
|
-
tags: list[str] = field(default_factory=list)
|
|
29
|
+
PydanticAgent = Agent
|
|
30
|
+
MessagePart = Union[ToolReturnPart, Any]
|
|
31
|
+
ModelRequest = ModelRequest # type: ignore[misc]
|
|
32
|
+
ModelResponse = Any
|
|
53
33
|
|
|
54
34
|
|
|
55
35
|
# =============================================================================
|
|
@@ -129,6 +109,7 @@ class ToolConfirmationResponse:
|
|
|
129
109
|
approved: bool
|
|
130
110
|
skip_future: bool = False
|
|
131
111
|
abort: bool = False
|
|
112
|
+
instructions: str = ""
|
|
132
113
|
|
|
133
114
|
|
|
134
115
|
# =============================================================================
|
|
@@ -195,59 +176,13 @@ class SimpleResult:
|
|
|
195
176
|
output: str
|
|
196
177
|
|
|
197
178
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
# =============================================================================
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
class PlanPhase(Enum):
|
|
204
|
-
"""Plan Mode phases."""
|
|
205
|
-
|
|
206
|
-
PLANNING_RESEARCH = "research"
|
|
207
|
-
PLANNING_DRAFT = "draft"
|
|
208
|
-
PLAN_READY = "ready"
|
|
209
|
-
REVIEW_DECISION = "review"
|
|
179
|
+
class AgentState(Enum):
|
|
180
|
+
"""Agent loop states for enhanced completion detection."""
|
|
210
181
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
""
|
|
215
|
-
|
|
216
|
-
# Required sections
|
|
217
|
-
title: str
|
|
218
|
-
overview: str
|
|
219
|
-
steps: List[str]
|
|
220
|
-
files_to_modify: List[str]
|
|
221
|
-
files_to_create: List[str]
|
|
222
|
-
|
|
223
|
-
# Optional but recommended sections
|
|
224
|
-
risks: List[str] = field(default_factory=list)
|
|
225
|
-
tests: List[str] = field(default_factory=list)
|
|
226
|
-
rollback: Optional[str] = None
|
|
227
|
-
open_questions: List[str] = field(default_factory=list)
|
|
228
|
-
success_criteria: List[str] = field(default_factory=list)
|
|
229
|
-
references: List[str] = field(default_factory=list)
|
|
230
|
-
|
|
231
|
-
def validate(self) -> Tuple[bool, List[str]]:
|
|
232
|
-
"""
|
|
233
|
-
Validate the plan document.
|
|
234
|
-
|
|
235
|
-
Returns:
|
|
236
|
-
tuple: (is_valid, list_of_missing_sections)
|
|
237
|
-
"""
|
|
238
|
-
missing = []
|
|
239
|
-
|
|
240
|
-
# Check required fields
|
|
241
|
-
if not self.title or not self.title.strip():
|
|
242
|
-
missing.append("title")
|
|
243
|
-
if not self.overview or not self.overview.strip():
|
|
244
|
-
missing.append("overview")
|
|
245
|
-
if not self.steps:
|
|
246
|
-
missing.append("steps")
|
|
247
|
-
if not self.files_to_modify and not self.files_to_create:
|
|
248
|
-
missing.append("files_to_modify or files_to_create")
|
|
249
|
-
|
|
250
|
-
return len(missing) == 0, missing
|
|
182
|
+
USER_INPUT = "user_input" # Initial: user prompt received
|
|
183
|
+
ASSISTANT = "assistant" # Reasoning/deciding phase
|
|
184
|
+
TOOL_EXECUTION = "tool_execution" # Tool execution phase
|
|
185
|
+
RESPONSE = "response" # Handling results, may complete or loop
|
|
251
186
|
|
|
252
187
|
|
|
253
188
|
# =============================================================================
|
tunacode/ui/completers.py
CHANGED
|
@@ -1,23 +1,40 @@
|
|
|
1
1
|
"""Completers for file references and commands."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
from typing import TYPE_CHECKING, Iterable, Optional
|
|
4
|
+
from typing import TYPE_CHECKING, Iterable, List, Optional, Sequence, Set, Tuple
|
|
5
5
|
|
|
6
6
|
from prompt_toolkit.completion import (
|
|
7
7
|
CompleteEvent,
|
|
8
8
|
Completer,
|
|
9
9
|
Completion,
|
|
10
|
+
FuzzyCompleter,
|
|
11
|
+
FuzzyWordCompleter,
|
|
12
|
+
PathCompleter,
|
|
10
13
|
merge_completers,
|
|
11
14
|
)
|
|
12
15
|
from prompt_toolkit.document import Document
|
|
13
16
|
|
|
17
|
+
from .path_heuristics import prioritize_roots, should_skip_directory
|
|
18
|
+
|
|
14
19
|
if TYPE_CHECKING:
|
|
15
20
|
from ..cli.commands import CommandRegistry
|
|
21
|
+
from ..utils.models_registry import ModelInfo, ModelsRegistry
|
|
16
22
|
|
|
17
23
|
|
|
18
24
|
class CommandCompleter(Completer):
|
|
19
25
|
"""Completer for slash commands."""
|
|
20
26
|
|
|
27
|
+
_DEFAULT_COMMANDS: Sequence[str] = (
|
|
28
|
+
"/help",
|
|
29
|
+
"/clear",
|
|
30
|
+
"/dump",
|
|
31
|
+
"/yolo",
|
|
32
|
+
"/branch",
|
|
33
|
+
"/compact",
|
|
34
|
+
"/model",
|
|
35
|
+
)
|
|
36
|
+
_FUZZY_WORD_MODE = True
|
|
37
|
+
|
|
21
38
|
def __init__(self, command_registry: Optional["CommandRegistry"] = None):
|
|
22
39
|
self.command_registry = command_registry
|
|
23
40
|
|
|
@@ -49,30 +66,36 @@ class CommandCompleter(Completer):
|
|
|
49
66
|
if self.command_registry:
|
|
50
67
|
command_names = self.command_registry.get_command_names()
|
|
51
68
|
else:
|
|
52
|
-
|
|
53
|
-
command_names = ["/help", "/clear", "/dump", "/yolo", "/branch", "/compact", "/model"]
|
|
69
|
+
command_names = list(self._DEFAULT_COMMANDS)
|
|
54
70
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
start_position=-len(word_before_cursor),
|
|
64
|
-
display=cmd,
|
|
65
|
-
display_meta="command",
|
|
66
|
-
)
|
|
71
|
+
fuzzy_completer = FuzzyWordCompleter(command_names, WORD=self._FUZZY_WORD_MODE)
|
|
72
|
+
for completion in fuzzy_completer.get_completions(document, _complete_event):
|
|
73
|
+
yield Completion(
|
|
74
|
+
text=completion.text,
|
|
75
|
+
start_position=completion.start_position,
|
|
76
|
+
display=completion.display,
|
|
77
|
+
display_meta="command",
|
|
78
|
+
)
|
|
67
79
|
|
|
68
80
|
|
|
69
81
|
class FileReferenceCompleter(Completer):
|
|
70
82
|
"""Completer for @file references that provides file path suggestions."""
|
|
71
83
|
|
|
84
|
+
_FUZZY_WORD_MODE = True
|
|
85
|
+
_FUZZY_RESULT_LIMIT = 10
|
|
86
|
+
_GLOBAL_ROOT_CACHE: Optional[List[str]] = None
|
|
87
|
+
_GLOBAL_ROOT_LIMIT = 128
|
|
88
|
+
_GLOBAL_MAX_DEPTH = 20
|
|
89
|
+
|
|
72
90
|
def get_completions(
|
|
73
91
|
self, document: Document, _complete_event: CompleteEvent
|
|
74
92
|
) -> Iterable[Completion]:
|
|
75
|
-
"""Get completions for @file references.
|
|
93
|
+
"""Get completions for @file references.
|
|
94
|
+
|
|
95
|
+
Favors file matches before directory matches while allowing fuzzy
|
|
96
|
+
near-miss suggestions. Ordering:
|
|
97
|
+
exact files > fuzzy files > exact dirs > fuzzy dirs
|
|
98
|
+
"""
|
|
76
99
|
# Get the word before cursor
|
|
77
100
|
word_before_cursor = document.get_word_before_cursor(WORD=True)
|
|
78
101
|
|
|
@@ -93,44 +116,405 @@ class FileReferenceCompleter(Completer):
|
|
|
93
116
|
dir_path = "."
|
|
94
117
|
prefix = path_part
|
|
95
118
|
|
|
96
|
-
#
|
|
119
|
+
# If prefix itself is an existing directory (without trailing slash),
|
|
120
|
+
# treat it as browsing inside that directory
|
|
121
|
+
candidate_dir = os.path.join(dir_path, prefix) if dir_path != "." else prefix
|
|
122
|
+
if prefix and os.path.isdir(candidate_dir) and not path_part.endswith("/"):
|
|
123
|
+
dir_path = candidate_dir
|
|
124
|
+
prefix = ""
|
|
125
|
+
|
|
126
|
+
# Get matching files using prefix matching
|
|
97
127
|
try:
|
|
98
128
|
if os.path.exists(dir_path) and os.path.isdir(dir_path):
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
129
|
+
items = sorted(os.listdir(dir_path))
|
|
130
|
+
|
|
131
|
+
# Separate files vs dirs; skip hidden unless explicitly requested
|
|
132
|
+
show_hidden = prefix.startswith(".")
|
|
133
|
+
files: List[str] = []
|
|
134
|
+
dirs: List[str] = []
|
|
135
|
+
for item in items:
|
|
136
|
+
if item.startswith(".") and not show_hidden:
|
|
137
|
+
continue
|
|
138
|
+
full_item_path = os.path.join(dir_path, item) if dir_path != "." else item
|
|
139
|
+
if os.path.isdir(full_item_path):
|
|
140
|
+
dirs.append(item)
|
|
141
|
+
else:
|
|
142
|
+
files.append(item)
|
|
143
|
+
|
|
144
|
+
# Exact prefix matches (case-insensitive)
|
|
145
|
+
prefix_lower = prefix.lower()
|
|
146
|
+
exact_files = [f for f in files if f.lower().startswith(prefix_lower)]
|
|
147
|
+
exact_dirs = [d for d in dirs if d.lower().startswith(prefix_lower)]
|
|
148
|
+
|
|
149
|
+
fuzzy_file_candidates = [f for f in files if f not in exact_files]
|
|
150
|
+
fuzzy_dir_candidates = [d for d in dirs if d not in exact_dirs]
|
|
151
|
+
|
|
152
|
+
fuzzy_files = self._collect_fuzzy_matches(prefix, fuzzy_file_candidates)
|
|
153
|
+
fuzzy_dirs = self._collect_fuzzy_matches(prefix, fuzzy_dir_candidates)
|
|
154
|
+
|
|
155
|
+
ordered: List[tuple[str, str, bool]] = (
|
|
156
|
+
[("file", name, False) for name in exact_files]
|
|
157
|
+
+ [("file", name, False) for name in fuzzy_files]
|
|
158
|
+
+ [("dir", name, False) for name in exact_dirs]
|
|
159
|
+
+ [("dir", name, False) for name in fuzzy_dirs]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
local_seen: Set[str] = {
|
|
163
|
+
os.path.normpath(os.path.join(dir_path, name))
|
|
164
|
+
if dir_path != "."
|
|
165
|
+
else os.path.normpath(name)
|
|
166
|
+
for name in (*exact_files, *fuzzy_files, *exact_dirs, *fuzzy_dirs)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
global_matches = self._collect_global_path_matches(
|
|
170
|
+
prefix,
|
|
171
|
+
dir_path,
|
|
172
|
+
local_seen,
|
|
173
|
+
)
|
|
174
|
+
ordered += global_matches
|
|
175
|
+
|
|
176
|
+
start_position = -len(path_part)
|
|
177
|
+
for kind, name, is_global in ordered:
|
|
178
|
+
if is_global:
|
|
179
|
+
full_path = name
|
|
180
|
+
display = name + "/" if kind == "dir" else name
|
|
181
|
+
else:
|
|
182
|
+
full_path = os.path.join(dir_path, name) if dir_path != "." else name
|
|
183
|
+
display = name + "/" if kind == "dir" else name
|
|
184
|
+
if kind == "dir":
|
|
185
|
+
completion_text = full_path + "/"
|
|
186
|
+
else:
|
|
187
|
+
completion_text = full_path
|
|
188
|
+
|
|
189
|
+
yield Completion(
|
|
190
|
+
text=completion_text,
|
|
191
|
+
start_position=start_position,
|
|
192
|
+
display=display,
|
|
193
|
+
display_meta="dir" if kind == "dir" else "file",
|
|
194
|
+
)
|
|
124
195
|
except (OSError, PermissionError):
|
|
125
196
|
# Silently ignore inaccessible directories
|
|
126
197
|
pass
|
|
127
198
|
|
|
199
|
+
@classmethod
|
|
200
|
+
# CLAUDE_ANCHOR[key=1f0911c7] Prompt Toolkit fuzzy matching consolidates file and
|
|
201
|
+
# directory suggestions
|
|
202
|
+
def _collect_fuzzy_matches(cls, prefix: str, candidates: Sequence[str]) -> List[str]:
|
|
203
|
+
"""Return fuzzy-ordered candidate names respecting configured limit."""
|
|
128
204
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
]
|
|
136
|
-
|
|
205
|
+
if not prefix or not candidates:
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
fuzzy_completer = FuzzyWordCompleter(candidates, WORD=cls._FUZZY_WORD_MODE)
|
|
209
|
+
prefix_document = Document(text=prefix)
|
|
210
|
+
event = CompleteEvent(completion_requested=True)
|
|
211
|
+
matches: List[str] = []
|
|
212
|
+
for completion in fuzzy_completer.get_completions(prefix_document, event):
|
|
213
|
+
candidate = completion.text
|
|
214
|
+
if candidate in candidates and candidate not in matches:
|
|
215
|
+
matches.append(candidate)
|
|
216
|
+
if len(matches) >= cls._FUZZY_RESULT_LIMIT:
|
|
217
|
+
break
|
|
218
|
+
return matches
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def _collect_global_path_matches(
|
|
222
|
+
cls,
|
|
223
|
+
prefix: str,
|
|
224
|
+
current_dir: str,
|
|
225
|
+
seen: Set[str],
|
|
226
|
+
) -> List[Tuple[str, str, bool]]:
|
|
227
|
+
"""Return global fuzzy matches outside the current directory."""
|
|
228
|
+
|
|
229
|
+
if not prefix:
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
roots = cls._global_roots()
|
|
233
|
+
if not roots:
|
|
234
|
+
return []
|
|
235
|
+
|
|
236
|
+
event = CompleteEvent(completion_requested=True)
|
|
237
|
+
document = Document(text=prefix)
|
|
238
|
+
matches: List[Tuple[str, str, bool]] = []
|
|
239
|
+
normalized_current = os.path.normpath(current_dir or ".")
|
|
240
|
+
|
|
241
|
+
for root in roots:
|
|
242
|
+
normalized_root = os.path.normpath(root)
|
|
243
|
+
if normalized_root == normalized_current:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
completer = FuzzyCompleter(
|
|
247
|
+
PathCompleter(only_directories=False, get_paths=lambda root=normalized_root: [root])
|
|
248
|
+
)
|
|
249
|
+
for completion in completer.get_completions(document, event):
|
|
250
|
+
candidate_path = os.path.normpath(os.path.join(normalized_root, completion.text))
|
|
251
|
+
if candidate_path in seen:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
seen.add(candidate_path)
|
|
255
|
+
normalized_display = os.path.relpath(candidate_path, start=".").replace("\\", "/")
|
|
256
|
+
matches.append(
|
|
257
|
+
(
|
|
258
|
+
"dir" if os.path.isdir(candidate_path) else "file",
|
|
259
|
+
normalized_display,
|
|
260
|
+
True,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
if len(matches) >= cls._FUZZY_RESULT_LIMIT:
|
|
264
|
+
return matches
|
|
265
|
+
|
|
266
|
+
return matches
|
|
267
|
+
|
|
268
|
+
@classmethod
|
|
269
|
+
def _global_roots(cls) -> List[str]:
|
|
270
|
+
"""Compute cached directory list for global fuzzy lookups."""
|
|
271
|
+
|
|
272
|
+
if cls._GLOBAL_ROOT_CACHE is not None:
|
|
273
|
+
return cls._GLOBAL_ROOT_CACHE
|
|
274
|
+
|
|
275
|
+
roots: List[str] = []
|
|
276
|
+
limit = cls._GLOBAL_ROOT_LIMIT
|
|
277
|
+
max_depth = cls._GLOBAL_MAX_DEPTH
|
|
278
|
+
|
|
279
|
+
for root, dirs, _ in os.walk(".", topdown=True):
|
|
280
|
+
rel_root = os.path.relpath(root, ".")
|
|
281
|
+
normalized = "." if rel_root == "." else rel_root
|
|
282
|
+
depth = 0 if normalized == "." else normalized.count(os.sep) + 1
|
|
283
|
+
if depth > max_depth:
|
|
284
|
+
dirs[:] = []
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
if should_skip_directory(normalized):
|
|
288
|
+
dirs[:] = []
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
if dirs:
|
|
292
|
+
rel_dir = os.path.relpath(root, ".")
|
|
293
|
+
base = "." if rel_dir == "." else rel_dir
|
|
294
|
+
filtered_dirs = []
|
|
295
|
+
for directory in dirs:
|
|
296
|
+
candidate = directory if base == "." else f"{base}/{directory}"
|
|
297
|
+
if should_skip_directory(candidate):
|
|
298
|
+
continue
|
|
299
|
+
filtered_dirs.append(directory)
|
|
300
|
+
dirs[:] = filtered_dirs
|
|
301
|
+
|
|
302
|
+
if normalized not in roots:
|
|
303
|
+
roots.append(normalized)
|
|
304
|
+
|
|
305
|
+
if len(roots) >= limit:
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
cls._GLOBAL_ROOT_CACHE = prioritize_roots(roots)
|
|
309
|
+
return cls._GLOBAL_ROOT_CACHE
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class ModelCompleter(Completer):
|
|
313
|
+
"""Completer for model names in /model command."""
|
|
314
|
+
|
|
315
|
+
def __init__(self, registry: Optional["ModelsRegistry"] = None):
|
|
316
|
+
"""Initialize the model completer."""
|
|
317
|
+
self.registry = registry
|
|
318
|
+
self._models_cache: Optional[List[ModelInfo]] = None
|
|
319
|
+
self._registry_loaded = False
|
|
320
|
+
|
|
321
|
+
async def _ensure_registry_loaded(self):
|
|
322
|
+
"""Ensure the models registry is loaded."""
|
|
323
|
+
if self.registry and not self._registry_loaded:
|
|
324
|
+
try:
|
|
325
|
+
# Try to load models (this will be fast if already loaded)
|
|
326
|
+
await self.registry.load()
|
|
327
|
+
self._registry_loaded = True
|
|
328
|
+
self._models_cache = (
|
|
329
|
+
list(self.registry.models.values()) if self.registry.models else []
|
|
330
|
+
)
|
|
331
|
+
except Exception:
|
|
332
|
+
# If loading fails, use empty cache
|
|
333
|
+
self._models_cache = []
|
|
334
|
+
self._registry_loaded = True
|
|
335
|
+
|
|
336
|
+
def get_completions(
|
|
337
|
+
self, document: Document, _complete_event: CompleteEvent
|
|
338
|
+
) -> Iterable[Completion]:
|
|
339
|
+
"""Get completions for model names."""
|
|
340
|
+
if not self.registry:
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
text = document.text_before_cursor
|
|
344
|
+
|
|
345
|
+
# Check if we're in a /model command context
|
|
346
|
+
lines = text.split("\n")
|
|
347
|
+
current_line = lines[-1].strip()
|
|
348
|
+
|
|
349
|
+
# Must start with /model
|
|
350
|
+
if not current_line.startswith("/model"):
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# Try to load registry synchronously if not loaded
|
|
354
|
+
# Note: This is a compromise - ideally we'd use async completion
|
|
355
|
+
if not self._registry_loaded:
|
|
356
|
+
try:
|
|
357
|
+
# Quick attempt to load cached data only
|
|
358
|
+
if self.registry._is_cache_valid() and self.registry._load_from_cache():
|
|
359
|
+
self._registry_loaded = True
|
|
360
|
+
self._models_cache = list(self.registry.models.values())
|
|
361
|
+
elif not self._models_cache:
|
|
362
|
+
# Use fallback models for immediate completion
|
|
363
|
+
self.registry._load_fallback_models()
|
|
364
|
+
self._registry_loaded = True
|
|
365
|
+
self._models_cache = list(self.registry.models.values())
|
|
366
|
+
except Exception:
|
|
367
|
+
return # Skip completion if we can't load models
|
|
368
|
+
|
|
369
|
+
# Get the part after /model
|
|
370
|
+
parts = current_line.split()
|
|
371
|
+
if len(parts) < 2:
|
|
372
|
+
# Just "/model" - suggest popular searches and top models
|
|
373
|
+
popular_searches = ["claude", "gpt", "gemini", "openai", "anthropic"]
|
|
374
|
+
for search_term in popular_searches:
|
|
375
|
+
yield Completion(
|
|
376
|
+
text=search_term, display=f"{search_term} (search)", display_meta="search term"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Also show top 3 most popular models if we have them
|
|
380
|
+
if self._models_cache:
|
|
381
|
+
popular_models = []
|
|
382
|
+
# Look for common popular models
|
|
383
|
+
for model in self._models_cache:
|
|
384
|
+
if any(pop in model.id.lower() for pop in ["gpt-4o", "claude-3", "gemini-2"]):
|
|
385
|
+
popular_models.append(model)
|
|
386
|
+
if len(popular_models) >= 3:
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
for model in popular_models:
|
|
390
|
+
display = f"{model.full_id} - {model.name}"
|
|
391
|
+
if model.cost.input is not None:
|
|
392
|
+
display += f" (${model.cost.input}/{model.cost.output})"
|
|
393
|
+
|
|
394
|
+
yield Completion(
|
|
395
|
+
text=model.full_id, display=display, display_meta=f"{model.provider} model"
|
|
396
|
+
)
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
# Get the current word being typed
|
|
400
|
+
word_before_cursor = document.get_word_before_cursor(WORD=True)
|
|
401
|
+
if not word_before_cursor or not self._models_cache:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
query = word_before_cursor.lower()
|
|
405
|
+
|
|
406
|
+
# Use the new grouped approach to find base models with variants
|
|
407
|
+
base_models = self.registry.find_base_models(query)
|
|
408
|
+
|
|
409
|
+
if not base_models:
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
results = []
|
|
413
|
+
shown_base_models = 0
|
|
414
|
+
|
|
415
|
+
# Sort base models by popularity/relevance
|
|
416
|
+
sorted_base_models = sorted(
|
|
417
|
+
base_models.items(),
|
|
418
|
+
key=lambda x: (
|
|
419
|
+
# Popular models first
|
|
420
|
+
-1
|
|
421
|
+
if any(
|
|
422
|
+
pop in x[0] for pop in ["gpt-4o", "gpt-4", "claude-3", "gemini-2", "o3", "o1"]
|
|
423
|
+
)
|
|
424
|
+
else 0,
|
|
425
|
+
# Then by name
|
|
426
|
+
x[0],
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
for base_model_name, variants in sorted_base_models:
|
|
431
|
+
if shown_base_models >= 5: # Limit to top 5 base models
|
|
432
|
+
break
|
|
433
|
+
|
|
434
|
+
shown_variants = 0
|
|
435
|
+
for i, model in enumerate(variants):
|
|
436
|
+
if shown_variants >= 3: # Show max 3 variants per base model
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
# Calculate start position for replacement
|
|
440
|
+
start_pos = -len(word_before_cursor)
|
|
441
|
+
|
|
442
|
+
# Build display text with enhanced info
|
|
443
|
+
cost_str = ""
|
|
444
|
+
if model.cost.input is not None:
|
|
445
|
+
if model.cost.input == 0:
|
|
446
|
+
cost_str = " (FREE)"
|
|
447
|
+
else:
|
|
448
|
+
cost_str = f" (${model.cost.input}/{model.cost.output})"
|
|
449
|
+
|
|
450
|
+
# Format provider info
|
|
451
|
+
provider_display = self._get_provider_display_name(model.provider)
|
|
452
|
+
|
|
453
|
+
# Primary variant gets the bullet, others get indentation
|
|
454
|
+
if i == 0:
|
|
455
|
+
# First variant - primary option with bullet
|
|
456
|
+
display = f"● {model.full_id} - {model.name}{cost_str}"
|
|
457
|
+
if model.cost.input == 0:
|
|
458
|
+
display += " ⭐" # Star for free models
|
|
459
|
+
else:
|
|
460
|
+
# Additional variants - indented
|
|
461
|
+
display = f" {model.full_id} - {model.name}{cost_str}"
|
|
462
|
+
if model.cost.input == 0:
|
|
463
|
+
display += " ⭐"
|
|
464
|
+
|
|
465
|
+
meta_info = f"{provider_display}"
|
|
466
|
+
if len(variants) > 1:
|
|
467
|
+
meta_info += f" ({len(variants)} sources)"
|
|
468
|
+
|
|
469
|
+
results.append(
|
|
470
|
+
Completion(
|
|
471
|
+
text=model.full_id,
|
|
472
|
+
start_position=start_pos,
|
|
473
|
+
display=display,
|
|
474
|
+
display_meta=meta_info,
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
shown_variants += 1
|
|
479
|
+
|
|
480
|
+
shown_base_models += 1
|
|
481
|
+
|
|
482
|
+
# Limit total results for readability
|
|
483
|
+
for completion in results[:20]:
|
|
484
|
+
yield completion
|
|
485
|
+
|
|
486
|
+
def _get_provider_display_name(self, provider: str) -> str:
|
|
487
|
+
"""Get a user-friendly provider display name."""
|
|
488
|
+
provider_names = {
|
|
489
|
+
"openai": "OpenAI Direct",
|
|
490
|
+
"anthropic": "Anthropic Direct",
|
|
491
|
+
"google": "Google Direct",
|
|
492
|
+
"google-gla": "Google Labs",
|
|
493
|
+
"openrouter": "OpenRouter",
|
|
494
|
+
"github-models": "GitHub Models (FREE)",
|
|
495
|
+
"azure": "Azure OpenAI",
|
|
496
|
+
"fastrouter": "FastRouter",
|
|
497
|
+
"requesty": "Requesty",
|
|
498
|
+
"cloudflare-workers-ai": "Cloudflare",
|
|
499
|
+
"amazon-bedrock": "AWS Bedrock",
|
|
500
|
+
"chutes": "Chutes AI",
|
|
501
|
+
"deepinfra": "DeepInfra",
|
|
502
|
+
"venice": "Venice AI",
|
|
503
|
+
}
|
|
504
|
+
return provider_names.get(provider, provider.title())
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def create_completer(
|
|
508
|
+
command_registry: Optional["CommandRegistry"] = None,
|
|
509
|
+
models_registry: Optional["ModelsRegistry"] = None,
|
|
510
|
+
) -> Completer:
|
|
511
|
+
"""Create a merged completer for commands, file references, and models."""
|
|
512
|
+
completers = [
|
|
513
|
+
CommandCompleter(command_registry),
|
|
514
|
+
FileReferenceCompleter(),
|
|
515
|
+
]
|
|
516
|
+
|
|
517
|
+
if models_registry:
|
|
518
|
+
completers.append(ModelCompleter(models_registry))
|
|
519
|
+
|
|
520
|
+
return merge_completers(completers)
|