tunacode-cli 0.0.55__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.

Files changed (114) hide show
  1. tunacode/cli/commands/__init__.py +2 -2
  2. tunacode/cli/commands/implementations/__init__.py +2 -3
  3. tunacode/cli/commands/implementations/command_reload.py +48 -0
  4. tunacode/cli/commands/implementations/debug.py +2 -2
  5. tunacode/cli/commands/implementations/development.py +10 -8
  6. tunacode/cli/commands/implementations/model.py +357 -29
  7. tunacode/cli/commands/implementations/quickstart.py +43 -0
  8. tunacode/cli/commands/implementations/system.py +96 -3
  9. tunacode/cli/commands/implementations/template.py +0 -2
  10. tunacode/cli/commands/registry.py +139 -5
  11. tunacode/cli/commands/slash/__init__.py +32 -0
  12. tunacode/cli/commands/slash/command.py +157 -0
  13. tunacode/cli/commands/slash/loader.py +135 -0
  14. tunacode/cli/commands/slash/processor.py +294 -0
  15. tunacode/cli/commands/slash/types.py +93 -0
  16. tunacode/cli/commands/slash/validator.py +400 -0
  17. tunacode/cli/main.py +23 -2
  18. tunacode/cli/repl.py +217 -190
  19. tunacode/cli/repl_components/command_parser.py +38 -4
  20. tunacode/cli/repl_components/error_recovery.py +85 -4
  21. tunacode/cli/repl_components/output_display.py +12 -1
  22. tunacode/cli/repl_components/tool_executor.py +1 -1
  23. tunacode/configuration/defaults.py +12 -3
  24. tunacode/configuration/key_descriptions.py +284 -0
  25. tunacode/configuration/settings.py +0 -1
  26. tunacode/constants.py +12 -40
  27. tunacode/core/agents/__init__.py +43 -2
  28. tunacode/core/agents/agent_components/__init__.py +7 -0
  29. tunacode/core/agents/agent_components/agent_config.py +249 -55
  30. tunacode/core/agents/agent_components/agent_helpers.py +43 -13
  31. tunacode/core/agents/agent_components/node_processor.py +179 -139
  32. tunacode/core/agents/agent_components/response_state.py +123 -6
  33. tunacode/core/agents/agent_components/state_transition.py +116 -0
  34. tunacode/core/agents/agent_components/streaming.py +296 -0
  35. tunacode/core/agents/agent_components/task_completion.py +19 -6
  36. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  37. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  38. tunacode/core/agents/main.py +522 -370
  39. tunacode/core/agents/main_legact.py +538 -0
  40. tunacode/core/agents/prompts.py +66 -0
  41. tunacode/core/agents/utils.py +29 -121
  42. tunacode/core/code_index.py +83 -29
  43. tunacode/core/setup/__init__.py +0 -2
  44. tunacode/core/setup/config_setup.py +110 -20
  45. tunacode/core/setup/config_wizard.py +230 -0
  46. tunacode/core/setup/coordinator.py +14 -5
  47. tunacode/core/state.py +16 -20
  48. tunacode/core/token_usage/usage_tracker.py +5 -3
  49. tunacode/core/tool_authorization.py +352 -0
  50. tunacode/core/tool_handler.py +67 -40
  51. tunacode/exceptions.py +119 -5
  52. tunacode/prompts/system.xml +751 -0
  53. tunacode/services/mcp.py +125 -7
  54. tunacode/setup.py +5 -25
  55. tunacode/tools/base.py +163 -0
  56. tunacode/tools/bash.py +110 -1
  57. tunacode/tools/glob.py +332 -34
  58. tunacode/tools/grep.py +179 -82
  59. tunacode/tools/grep_components/result_formatter.py +98 -4
  60. tunacode/tools/list_dir.py +132 -2
  61. tunacode/tools/prompts/bash_prompt.xml +72 -0
  62. tunacode/tools/prompts/glob_prompt.xml +45 -0
  63. tunacode/tools/prompts/grep_prompt.xml +98 -0
  64. tunacode/tools/prompts/list_dir_prompt.xml +31 -0
  65. tunacode/tools/prompts/react_prompt.xml +23 -0
  66. tunacode/tools/prompts/read_file_prompt.xml +54 -0
  67. tunacode/tools/prompts/run_command_prompt.xml +64 -0
  68. tunacode/tools/prompts/update_file_prompt.xml +53 -0
  69. tunacode/tools/prompts/write_file_prompt.xml +37 -0
  70. tunacode/tools/react.py +153 -0
  71. tunacode/tools/read_file.py +91 -0
  72. tunacode/tools/run_command.py +114 -0
  73. tunacode/tools/schema_assembler.py +167 -0
  74. tunacode/tools/update_file.py +94 -0
  75. tunacode/tools/write_file.py +86 -0
  76. tunacode/tools/xml_helper.py +83 -0
  77. tunacode/tutorial/__init__.py +9 -0
  78. tunacode/tutorial/content.py +98 -0
  79. tunacode/tutorial/manager.py +182 -0
  80. tunacode/tutorial/steps.py +124 -0
  81. tunacode/types.py +20 -27
  82. tunacode/ui/completers.py +434 -50
  83. tunacode/ui/config_dashboard.py +585 -0
  84. tunacode/ui/console.py +63 -11
  85. tunacode/ui/input.py +20 -3
  86. tunacode/ui/keybindings.py +7 -4
  87. tunacode/ui/model_selector.py +395 -0
  88. tunacode/ui/output.py +40 -19
  89. tunacode/ui/panels.py +212 -43
  90. tunacode/ui/path_heuristics.py +91 -0
  91. tunacode/ui/prompt_manager.py +5 -1
  92. tunacode/ui/tool_ui.py +33 -10
  93. tunacode/utils/api_key_validation.py +93 -0
  94. tunacode/utils/config_comparator.py +340 -0
  95. tunacode/utils/json_utils.py +206 -0
  96. tunacode/utils/message_utils.py +14 -4
  97. tunacode/utils/models_registry.py +593 -0
  98. tunacode/utils/ripgrep.py +332 -9
  99. tunacode/utils/text_utils.py +18 -1
  100. tunacode/utils/user_configuration.py +45 -0
  101. tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
  102. tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
  103. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
  104. tunacode/cli/commands/implementations/todo.py +0 -217
  105. tunacode/context.py +0 -71
  106. tunacode/core/setup/git_safety_setup.py +0 -182
  107. tunacode/prompts/system.md +0 -731
  108. tunacode/tools/read_file_async_poc.py +0 -196
  109. tunacode/tools/todo.py +0 -349
  110. tunacode_cli-0.0.55.dist-info/METADATA +0 -322
  111. tunacode_cli-0.0.55.dist-info/RECORD +0 -126
  112. tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
  113. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  114. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
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
- # Fallback list of commands
53
- command_names = ["/help", "/clear", "/dump", "/yolo", "/branch", "/compact", "/model"]
69
+ command_names = list(self._DEFAULT_COMMANDS)
54
70
 
55
- # Get the partial command (without /)
56
- partial = word_before_cursor[1:].lower()
57
-
58
- # Yield completions for matching commands
59
- for cmd in command_names:
60
- if cmd.startswith("/") and cmd[1:].lower().startswith(partial):
61
- yield Completion(
62
- text=cmd,
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
- # Get matching files
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
- for item in sorted(os.listdir(dir_path)):
100
- if item.startswith(prefix):
101
- full_path = os.path.join(dir_path, item) if dir_path != "." else item
102
-
103
- # Skip hidden files unless explicitly requested
104
- if item.startswith(".") and not prefix.startswith("."):
105
- continue
106
-
107
- # Add / for directories
108
- if os.path.isdir(full_path):
109
- display = item + "/"
110
- completion = full_path + "/"
111
- else:
112
- display = item
113
- completion = full_path
114
-
115
- # Calculate how much to replace
116
- start_position = -len(path_part)
117
-
118
- yield Completion(
119
- text=completion,
120
- start_position=start_position,
121
- display=display,
122
- display_meta="dir" if os.path.isdir(full_path) else "file",
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
- def create_completer(command_registry: Optional["CommandRegistry"] = None) -> Completer:
130
- """Create a merged completer for both commands and file references."""
131
- return merge_completers(
132
- [
133
- CommandCompleter(command_registry),
134
- FileReferenceCompleter(),
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)