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.

Files changed (90) hide show
  1. tunacode/cli/commands/__init__.py +0 -2
  2. tunacode/cli/commands/implementations/__init__.py +0 -3
  3. tunacode/cli/commands/implementations/debug.py +2 -2
  4. tunacode/cli/commands/implementations/development.py +10 -8
  5. tunacode/cli/commands/implementations/model.py +357 -29
  6. tunacode/cli/commands/implementations/system.py +3 -2
  7. tunacode/cli/commands/implementations/template.py +0 -2
  8. tunacode/cli/commands/registry.py +8 -7
  9. tunacode/cli/commands/slash/loader.py +2 -1
  10. tunacode/cli/commands/slash/validator.py +2 -1
  11. tunacode/cli/main.py +19 -1
  12. tunacode/cli/repl.py +90 -229
  13. tunacode/cli/repl_components/command_parser.py +2 -1
  14. tunacode/cli/repl_components/error_recovery.py +8 -5
  15. tunacode/cli/repl_components/output_display.py +1 -10
  16. tunacode/cli/repl_components/tool_executor.py +1 -13
  17. tunacode/configuration/defaults.py +2 -2
  18. tunacode/configuration/key_descriptions.py +284 -0
  19. tunacode/configuration/settings.py +0 -1
  20. tunacode/constants.py +6 -42
  21. tunacode/core/agents/__init__.py +43 -2
  22. tunacode/core/agents/agent_components/__init__.py +7 -0
  23. tunacode/core/agents/agent_components/agent_config.py +162 -158
  24. tunacode/core/agents/agent_components/agent_helpers.py +31 -2
  25. tunacode/core/agents/agent_components/node_processor.py +180 -146
  26. tunacode/core/agents/agent_components/response_state.py +123 -6
  27. tunacode/core/agents/agent_components/state_transition.py +116 -0
  28. tunacode/core/agents/agent_components/streaming.py +296 -0
  29. tunacode/core/agents/agent_components/task_completion.py +19 -6
  30. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  31. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  32. tunacode/core/agents/main.py +522 -370
  33. tunacode/core/agents/main_legact.py +538 -0
  34. tunacode/core/agents/prompts.py +66 -0
  35. tunacode/core/agents/utils.py +29 -122
  36. tunacode/core/setup/__init__.py +0 -2
  37. tunacode/core/setup/config_setup.py +88 -227
  38. tunacode/core/setup/config_wizard.py +230 -0
  39. tunacode/core/setup/coordinator.py +2 -1
  40. tunacode/core/state.py +16 -64
  41. tunacode/core/token_usage/usage_tracker.py +3 -1
  42. tunacode/core/tool_authorization.py +352 -0
  43. tunacode/core/tool_handler.py +67 -60
  44. tunacode/prompts/system.xml +751 -0
  45. tunacode/services/mcp.py +97 -1
  46. tunacode/setup.py +0 -23
  47. tunacode/tools/base.py +54 -1
  48. tunacode/tools/bash.py +14 -0
  49. tunacode/tools/glob.py +4 -2
  50. tunacode/tools/grep.py +7 -17
  51. tunacode/tools/prompts/glob_prompt.xml +1 -1
  52. tunacode/tools/prompts/grep_prompt.xml +1 -0
  53. tunacode/tools/prompts/list_dir_prompt.xml +1 -1
  54. tunacode/tools/prompts/react_prompt.xml +23 -0
  55. tunacode/tools/prompts/read_file_prompt.xml +1 -1
  56. tunacode/tools/react.py +153 -0
  57. tunacode/tools/run_command.py +15 -0
  58. tunacode/types.py +14 -79
  59. tunacode/ui/completers.py +434 -50
  60. tunacode/ui/config_dashboard.py +585 -0
  61. tunacode/ui/console.py +63 -11
  62. tunacode/ui/input.py +8 -3
  63. tunacode/ui/keybindings.py +0 -18
  64. tunacode/ui/model_selector.py +395 -0
  65. tunacode/ui/output.py +40 -19
  66. tunacode/ui/panels.py +173 -49
  67. tunacode/ui/path_heuristics.py +91 -0
  68. tunacode/ui/prompt_manager.py +1 -20
  69. tunacode/ui/tool_ui.py +30 -8
  70. tunacode/utils/api_key_validation.py +93 -0
  71. tunacode/utils/config_comparator.py +340 -0
  72. tunacode/utils/models_registry.py +593 -0
  73. tunacode/utils/text_utils.py +18 -1
  74. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/METADATA +80 -12
  75. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/RECORD +78 -74
  76. tunacode/cli/commands/implementations/plan.py +0 -50
  77. tunacode/cli/commands/implementations/todo.py +0 -217
  78. tunacode/context.py +0 -71
  79. tunacode/core/setup/git_safety_setup.py +0 -186
  80. tunacode/prompts/system.md +0 -359
  81. tunacode/prompts/system.md.bak +0 -487
  82. tunacode/tools/exit_plan_mode.py +0 -273
  83. tunacode/tools/present_plan.py +0 -288
  84. tunacode/tools/prompts/exit_plan_mode_prompt.xml +0 -25
  85. tunacode/tools/prompts/present_plan_prompt.xml +0 -20
  86. tunacode/tools/prompts/todo_prompt.xml +0 -96
  87. tunacode/tools/todo.py +0 -456
  88. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +0 -0
  89. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  90. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
@@ -29,7 +29,6 @@ from .implementations import (
29
29
  ParseToolsCommand,
30
30
  RefreshConfigCommand,
31
31
  ThoughtsCommand,
32
- TodoCommand,
33
32
  UpdateCommand,
34
33
  YoloCommand,
35
34
  )
@@ -63,6 +62,5 @@ __all__ = [
63
62
  "UpdateCommand",
64
63
  "ModelCommand",
65
64
  "InitCommand",
66
- "TodoCommand",
67
65
  "CommandReloadCommand",
68
66
  ]
@@ -20,7 +20,6 @@ from .system import (
20
20
  StreamingCommand,
21
21
  UpdateCommand,
22
22
  )
23
- from .todo import TodoCommand
24
23
 
25
24
  __all__ = [
26
25
  # System commands
@@ -44,6 +43,4 @@ __all__ = [
44
43
  "ModelCommand",
45
44
  # Conversation commands
46
45
  "CompactCommand",
47
- # Todo commands
48
- "TodoCommand",
49
46
  ]
@@ -122,7 +122,7 @@ class FixCommand(SimpleCommand):
122
122
  )
123
123
 
124
124
  async def execute(self, args: List[str], context: CommandContext) -> None:
125
- from tunacode.core.agents.main import patch_tool_messages
125
+ from tunacode.core.agents import patch_tool_messages
126
126
 
127
127
  # Count current messages
128
128
  before_count = len(context.state_manager.session.messages)
@@ -152,7 +152,7 @@ class ParseToolsCommand(SimpleCommand):
152
152
  )
153
153
 
154
154
  async def execute(self, args: List[str], context: CommandContext) -> None:
155
- from tunacode.core.agents.main import extract_and_execute_tool_calls
155
+ from tunacode.core.agents import extract_and_execute_tool_calls
156
156
 
157
157
  # Find the last model response in messages
158
158
  messages = context.state_manager.session.messages
@@ -49,27 +49,29 @@ class BranchCommand(SimpleCommand):
49
49
 
50
50
 
51
51
  class InitCommand(SimpleCommand):
52
- """Creates or updates TUNACODE.md with project-specific context."""
52
+ """Creates or updates AGENTS.md with project-specific context."""
53
53
 
54
54
  spec = CommandSpec(
55
55
  name="/init",
56
56
  aliases=[],
57
- description="Analyze codebase and create/update TUNACODE.md file",
57
+ description="Analyze codebase and create/update AGENTS.md file",
58
58
  category=CommandCategory.DEVELOPMENT,
59
59
  )
60
60
 
61
61
  async def execute(self, args, context: CommandContext) -> CommandResult:
62
62
  """Execute the init command."""
63
63
  # Minimal implementation to make test pass
64
- prompt = """Please analyze this codebase and create a TUNACODE.md file containing:
64
+ prompt = """Please analyze this codebase and create a AGENTS.md file containing:
65
65
  1. Build/lint/test commands - especially for running a single test
66
- 2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
66
+ 2. Code style guidelines including imports, formatting, types, naming conventions,
67
+ error handling, etc.
67
68
 
68
- The file you create will be given to agentic coding agents (such as yourself) that operate in this repository.
69
+ The file you create will be given to agentic coding agents (such as yourself)
70
+ that operate in this repository.
69
71
  Make it about 20 lines long.
70
- If there's already a TUNACODE.md, improve it.
71
- If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md),
72
- make sure to include them."""
72
+ If there's already a AGENTS.md, improve it.
73
+ If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules
74
+ (in .github/copilot-instructions.md), make sure to include them."""
73
75
 
74
76
  # Call the agent to analyze and create/update the file
75
77
  if context.process_request:
@@ -1,61 +1,389 @@
1
1
  """Model management commands for TunaCode CLI."""
2
2
 
3
- from typing import Optional
3
+ from typing import Dict, List, Optional
4
4
 
5
- from .... import utils
6
5
  from ....exceptions import ConfigurationError
7
6
  from ....types import CommandArgs, CommandContext
8
7
  from ....ui import console as ui
8
+ from ....ui.model_selector import select_model_interactive
9
+ from ....utils import user_configuration
10
+ from ....utils.models_registry import ModelInfo, ModelsRegistry
9
11
  from ..base import CommandCategory, CommandSpec, SimpleCommand
10
12
 
11
13
 
12
14
  class ModelCommand(SimpleCommand):
13
- """Manage model selection."""
15
+ """Manage model selection with models.dev integration."""
14
16
 
15
17
  spec = CommandSpec(
16
18
  name="model",
17
19
  aliases=["/model"],
18
- description="Switch model (e.g., /model gpt-4 or /model openai:gpt-4)",
20
+ description="Switch model with interactive selection or search",
19
21
  category=CommandCategory.MODEL,
20
22
  )
21
23
 
24
+ def __init__(self):
25
+ """Initialize the model command."""
26
+ super().__init__()
27
+ self.registry = ModelsRegistry()
28
+ self._registry_loaded = False
29
+
30
+ async def _ensure_registry(self) -> bool:
31
+ """Ensure the models registry is loaded."""
32
+ if not self._registry_loaded:
33
+ self._registry_loaded = await self.registry.load()
34
+ return self._registry_loaded
35
+
22
36
  async def execute(self, args: CommandArgs, context: CommandContext) -> Optional[str]:
23
- # No arguments - show current model
37
+ # Handle special flags
38
+ if args and args[0] in ["--list", "-l"]:
39
+ return await self._list_models()
40
+
41
+ if args and args[0] in ["--info", "-i"]:
42
+ if len(args) < 2:
43
+ await ui.error("Usage: /model --info <model-id>")
44
+ return None
45
+ return await self._show_model_info(args[1])
46
+
47
+ # No arguments - show interactive selector
24
48
  if not args:
25
- current_model = context.state_manager.session.current_model
26
- await ui.info(f"Current model: {current_model}")
27
- await ui.muted("Usage: /model <provider:model-name> [default]")
28
- await ui.muted("Example: /model openai:gpt-4.1")
49
+ return await self._interactive_select(context)
50
+
51
+ # Single argument - could be search query or model ID
52
+ model_query = args[0]
53
+
54
+ # Check for flags
55
+ if model_query in ["--search", "-s"]:
56
+ search_query = " ".join(args[1:]) if len(args) > 1 else ""
57
+ return await self._interactive_select(context, search_query)
58
+
59
+ # Direct model specification
60
+ return await self._set_model(model_query, args[1:], context)
61
+
62
+ async def _interactive_select(
63
+ self, context: CommandContext, initial_query: str = ""
64
+ ) -> Optional[str]:
65
+ """Show interactive model selector."""
66
+ await self._ensure_registry()
67
+
68
+ # Show current model
69
+ current_model = context.state_manager.session.current_model
70
+ await ui.info(f"Current model: {current_model}")
71
+
72
+ # Check if we have models loaded
73
+ if not self.registry.models:
74
+ await ui.error("No models available. Try /model --list to see if models can be loaded.")
29
75
  return None
30
76
 
31
- # Get the model name from args
32
- model_name = args[0]
77
+ # For now, use a simple text-based approach instead of complex UI
78
+ # This avoids prompt_toolkit compatibility issues
79
+ if initial_query:
80
+ models = self.registry.search_models(initial_query)
81
+ if not models:
82
+ await ui.error(f"No models found matching '{initial_query}'")
83
+ return None
84
+ else:
85
+ # Show popular models for quick selection
86
+ popular_searches = ["gpt", "claude", "gemini"]
87
+ await ui.info("Popular model searches:")
88
+ for search in popular_searches:
89
+ models = self.registry.search_models(search)[:3] # Top 3
90
+ if models:
91
+ await ui.info(f"\n{search.upper()} models:")
92
+ for model in models:
93
+ await ui.muted(f" • {model.full_id} - {model.name}")
33
94
 
34
- # Check if provider prefix is present
35
- if ":" not in model_name:
36
- await ui.error("Model name must include provider prefix")
37
- await ui.muted("Format: provider:model-name")
38
- await ui.muted(
39
- "Examples: openai:gpt-4.1, anthropic:claude-3-opus, google-gla:gemini-2.0-flash"
40
- )
95
+ await ui.info("\nUsage:")
96
+ await ui.muted(" /model <search-term> - Search for models")
97
+ await ui.muted(" /model --list - Show all models")
98
+ await ui.muted(" /model --info <id> - Show model details")
99
+ await ui.muted(" /model <provider:id> - Set model directly")
100
+ return None
101
+
102
+ # Show search results
103
+ if len(models) == 1:
104
+ # Auto-select single result
105
+ model = models[0]
106
+ context.state_manager.session.current_model = model.full_id
107
+ # Persist selection to config by default
108
+ try:
109
+ user_configuration.set_default_model(model.full_id, context.state_manager)
110
+ await ui.success(
111
+ f"Switched to model: {model.full_id} - {model.name} (saved as default)"
112
+ )
113
+ except ConfigurationError as e:
114
+ await ui.error(str(e))
115
+ await ui.warning("Model switched for this session only; failed to save default.")
41
116
  return None
42
117
 
43
- # No validation - user is responsible for correct model names
44
- await ui.warning("Model set without validation - verify the model name is correct")
118
+ # Show multiple results
119
+ await ui.info(f"Found {len(models)} models:")
120
+ for i, model in enumerate(models[:10], 1): # Show top 10
121
+ details = []
122
+ if model.cost.input is not None:
123
+ details.append(f"${model.cost.input}/{model.cost.output}")
124
+ if model.limits.context:
125
+ details.append(f"{model.limits.context // 1000}k")
126
+ detail_str = f" ({', '.join(details)})" if details else ""
45
127
 
46
- # Set the model
47
- context.state_manager.session.current_model = model_name
128
+ await ui.info(f"{i:2d}. {model.full_id} - {model.name}{detail_str}")
48
129
 
49
- # Check if setting as default
50
- if len(args) > 1 and args[1] == "default":
130
+ if len(models) > 10:
131
+ await ui.muted(f"... and {len(models) - 10} more")
132
+
133
+ await ui.muted("Use '/model <provider:model-id>' to select a specific model")
134
+ return None
135
+
136
+ async def _set_model(
137
+ self, model_name: str, extra_args: CommandArgs, context: CommandContext
138
+ ) -> Optional[str]:
139
+ """Set model directly or by search."""
140
+ # Load registry for validation
141
+ await self._ensure_registry()
142
+
143
+ # Check if it's a direct model ID
144
+ if ":" in model_name:
145
+ # Validate against registry if loaded
146
+ if self._registry_loaded:
147
+ model_info = self.registry.get_model(model_name)
148
+ if not model_info:
149
+ # Search for similar models
150
+ similar = self.registry.search_models(model_name.split(":")[-1])
151
+ if similar:
152
+ await ui.warning(f"Model '{model_name}' not found in registry")
153
+ await ui.muted("Did you mean one of these?")
154
+ for model in similar[:5]:
155
+ await ui.muted(f" • {model.full_id} - {model.name}")
156
+ return None
157
+ else:
158
+ await ui.warning("Model not found in registry - setting anyway")
159
+ else:
160
+ # Show model info
161
+ await ui.info(f"Selected: {model_info.name}")
162
+ if model_info.cost.input is not None:
163
+ await ui.muted(f" Pricing: {model_info.cost.format_cost()}")
164
+ if model_info.limits.context:
165
+ await ui.muted(f" Limits: {model_info.limits.format_limits()}")
166
+
167
+ # Set the model
168
+ context.state_manager.session.current_model = model_name
169
+
170
+ # Check if setting as default (preserve existing behavior)
171
+ if extra_args and extra_args[0] == "default":
172
+ try:
173
+ user_configuration.set_default_model(model_name, context.state_manager)
174
+ await ui.muted("Updating default model")
175
+ return "restart"
176
+ except ConfigurationError as e:
177
+ await ui.error(str(e))
178
+ return None
179
+
180
+ # Persist selection to config by default (auto-persist)
181
+ try:
182
+ user_configuration.set_default_model(model_name, context.state_manager)
183
+ await ui.success(f"Switched to model: {model_name} (saved as default)")
184
+ except ConfigurationError as e:
185
+ await ui.error(str(e))
186
+ await ui.warning("Model switched for this session only; failed to save default.")
187
+ return None
188
+
189
+ # No colon - treat as search query
190
+ models = self.registry.search_models(model_name)
191
+
192
+ if not models:
193
+ await ui.error(f"No models found matching '{model_name}'")
194
+ await ui.muted("Try /model --list to see all available models")
195
+ return None
196
+
197
+ if len(models) == 1:
198
+ # Single match - use it
199
+ model = models[0]
200
+ context.state_manager.session.current_model = model.full_id
201
+ # Persist selection to config by default
51
202
  try:
52
- utils.user_configuration.set_default_model(model_name, context.state_manager)
53
- await ui.muted("Updating default model")
54
- return "restart"
203
+ user_configuration.set_default_model(model.full_id, context.state_manager)
204
+ await ui.success(
205
+ f"Switched to model: {model.full_id} - {model.name} (saved as default)"
206
+ )
55
207
  except ConfigurationError as e:
56
208
  await ui.error(str(e))
209
+ await ui.warning("Model switched for this session only; failed to save default.")
210
+ return None
211
+
212
+ # Multiple matches - show interactive selector with results
213
+ await ui.info(f"Found {len(models)} models matching '{model_name}'")
214
+ selected_model = await select_model_interactive(self.registry, model_name)
215
+
216
+ if selected_model:
217
+ context.state_manager.session.current_model = selected_model
218
+ # Persist selection to config by default
219
+ try:
220
+ user_configuration.set_default_model(selected_model, context.state_manager)
221
+ await ui.success(f"Switched to model: {selected_model} (saved as default)")
222
+ except ConfigurationError as e:
223
+ await ui.error(str(e))
224
+ await ui.warning("Model switched for this session only; failed to save default.")
225
+ else:
226
+ await ui.info("Model selection cancelled")
227
+
228
+ return None
229
+
230
+ async def _list_models(self) -> Optional[str]:
231
+ """List all available models."""
232
+ await self._ensure_registry()
233
+
234
+ if not self.registry.models:
235
+ await ui.error("No models available")
236
+ return None
237
+
238
+ # Group by provider
239
+ providers: Dict[str, List[ModelInfo]] = {}
240
+ for model in self.registry.models.values():
241
+ if model.provider not in providers:
242
+ providers[model.provider] = []
243
+ providers[model.provider].append(model)
244
+
245
+ # Display models
246
+ await ui.info(f"Available models ({len(self.registry.models)} total):")
247
+
248
+ for provider_id in sorted(providers.keys()):
249
+ provider_info = self.registry.providers.get(provider_id)
250
+ provider_name = provider_info.name if provider_info else provider_id
251
+
252
+ await ui.print(f"\n{provider_name}:")
253
+
254
+ for model in sorted(providers[provider_id], key=lambda m: m.name):
255
+ line = f" • {model.id}"
256
+ if model.cost.input is not None:
257
+ line += f" (${model.cost.input}/{model.cost.output})"
258
+ if model.limits.context:
259
+ line += f" [{model.limits.context // 1000}k]"
260
+ await ui.muted(line)
261
+
262
+ return None
263
+
264
+ async def _show_model_info(self, model_id: str) -> Optional[str]:
265
+ """Show detailed information about a model."""
266
+ await self._ensure_registry()
267
+
268
+ model = self.registry.get_model(model_id)
269
+ if not model:
270
+ # Try to find similar models or routing options
271
+ base_name = self.registry._extract_base_model_name(model_id)
272
+ variants = self.registry.get_model_variants(base_name)
273
+ if variants:
274
+ await ui.warning(f"Model '{model_id}' not found directly")
275
+ await ui.info(f"Found routing options for '{base_name}':")
276
+
277
+ # Sort variants by cost (FREE first)
278
+ sorted_variants = sorted(
279
+ variants,
280
+ key=lambda m: (
281
+ 0 if m.cost.input == 0 else 1, # FREE first
282
+ m.cost.input or float("inf"), # Then by cost
283
+ m.provider, # Then by provider name
284
+ ),
285
+ )
286
+
287
+ for variant in sorted_variants:
288
+ cost_display = (
289
+ "FREE"
290
+ if variant.cost.input == 0
291
+ else f"${variant.cost.input}/{variant.cost.output}"
292
+ )
293
+ provider_name = self._get_provider_display_name(variant.provider)
294
+
295
+ await ui.muted(f" • {variant.full_id} - {provider_name} ({cost_display})")
296
+
297
+ await ui.muted(
298
+ "\nUse '/model <provider:model-id>' to select a specific routing option"
299
+ )
57
300
  return None
301
+ else:
302
+ await ui.error(f"Model '{model_id}' not found")
303
+ return None
304
+
305
+ # Display model information
306
+ await ui.info(f"{model.name}")
307
+ await ui.muted(f"ID: {model.full_id}")
308
+
309
+ # Show routing alternatives for this base model
310
+ base_name = self.registry._extract_base_model_name(model)
311
+ variants = self.registry.get_model_variants(base_name)
312
+ if len(variants) > 1:
313
+ await ui.print("\nRouting Options:")
314
+
315
+ # Sort variants by cost (FREE first)
316
+ sorted_variants = sorted(
317
+ variants,
318
+ key=lambda m: (
319
+ 0 if m.cost.input == 0 else 1, # FREE first
320
+ m.cost.input or float("inf"), # Then by cost
321
+ m.provider, # Then by provider name
322
+ ),
323
+ )
324
+
325
+ for variant in sorted_variants:
326
+ cost_display = (
327
+ "FREE"
328
+ if variant.cost.input == 0
329
+ else f"${variant.cost.input}/{variant.cost.output}"
330
+ )
331
+ provider_name = self._get_provider_display_name(variant.provider)
332
+
333
+ # Highlight current selection
334
+ prefix = "→ " if variant.full_id == model.full_id else " "
335
+ free_indicator = " ⭐" if variant.cost.input == 0 else ""
336
+
337
+ await ui.muted(
338
+ f"{prefix}{variant.full_id} - {provider_name} ({cost_display}){free_indicator}"
339
+ )
340
+
341
+ if model.cost.input is not None:
342
+ await ui.print("\nPricing:")
343
+ await ui.muted(f" Input: ${model.cost.input} per 1M tokens")
344
+ await ui.muted(f" Output: ${model.cost.output} per 1M tokens")
345
+
346
+ if model.limits.context or model.limits.output:
347
+ await ui.print("\nLimits:")
348
+ if model.limits.context:
349
+ await ui.muted(f" Context: {model.limits.context:,} tokens")
350
+ if model.limits.output:
351
+ await ui.muted(f" Output: {model.limits.output:,} tokens")
352
+
353
+ caps = []
354
+ if model.capabilities.attachment:
355
+ caps.append("Attachments")
356
+ if model.capabilities.reasoning:
357
+ caps.append("Reasoning")
358
+ if model.capabilities.tool_call:
359
+ caps.append("Tool calling")
360
+
361
+ if caps:
362
+ await ui.print("\nCapabilities:")
363
+ for cap in caps:
364
+ await ui.muted(f" ✓ {cap}")
365
+
366
+ if model.capabilities.knowledge:
367
+ await ui.print(f"\nKnowledge cutoff: {model.capabilities.knowledge}")
58
368
 
59
- # Show success message with the new model
60
- await ui.success(f"Switched to model: {model_name}")
61
369
  return None
370
+
371
+ def _get_provider_display_name(self, provider: str) -> str:
372
+ """Get a user-friendly provider display name."""
373
+ provider_names = {
374
+ "openai": "OpenAI Direct",
375
+ "anthropic": "Anthropic Direct",
376
+ "google": "Google Direct",
377
+ "google-gla": "Google Labs",
378
+ "openrouter": "OpenRouter",
379
+ "github-models": "GitHub Models (FREE)",
380
+ "azure": "Azure OpenAI",
381
+ "fastrouter": "FastRouter",
382
+ "requesty": "Requesty",
383
+ "cloudflare-workers-ai": "Cloudflare",
384
+ "amazon-bedrock": "AWS Bedrock",
385
+ "chutes": "Chutes AI",
386
+ "deepinfra": "DeepInfra",
387
+ "venice": "Venice AI",
388
+ }
389
+ return provider_names.get(provider, provider.title())
@@ -40,7 +40,7 @@ class ClearCommand(SimpleCommand):
40
40
 
41
41
  async def execute(self, args: List[str], context: CommandContext) -> None:
42
42
  # Patch any orphaned tool calls before clearing
43
- from tunacode.core.agents.main import patch_tool_messages
43
+ from tunacode.core.agents import patch_tool_messages
44
44
 
45
45
  patch_tool_messages("Conversation cleared", context.state_manager)
46
46
 
@@ -179,7 +179,8 @@ class UpdateCommand(SimpleCommand):
179
179
  await ui.muted(" pip: pip install --upgrade tunacode-cli")
180
180
  await ui.muted(" uv tool: uv tool upgrade tunacode-cli")
181
181
  await ui.muted(
182
- " venv: uv pip install --python ~/.tunacode-venv/bin/python --upgrade tunacode-cli"
182
+ " venv: uv pip install --python ~/.tunacode-venv/bin/python "
183
+ "--upgrade tunacode-cli"
183
184
  )
184
185
  return
185
186
 
@@ -121,8 +121,6 @@ class TemplateCommand(SimpleCommand):
121
121
  await ui.muted(' "allowed_tools": ["read_file", "grep", "list_dir", "run_command"]')
122
122
  await ui.muted("}")
123
123
 
124
- # TODO: Implement interactive creation when proper input handling is available
125
-
126
124
  async def _clear_template(self, context: CommandContext) -> None:
127
125
  """Clear the currently active template."""
128
126
  if hasattr(context.state_manager, "tool_handler") and context.state_manager.tool_handler:
@@ -26,7 +26,6 @@ from .implementations.debug import (
26
26
  )
27
27
  from .implementations.development import BranchCommand, InitCommand
28
28
  from .implementations.model import ModelCommand
29
- from .implementations.plan import ExitPlanCommand, PlanCommand
30
29
  from .implementations.quickstart import QuickStartCommand
31
30
  from .implementations.system import (
32
31
  ClearCommand,
@@ -36,7 +35,6 @@ from .implementations.system import (
36
35
  UpdateCommand,
37
36
  )
38
37
  from .implementations.template import TemplateCommand
39
- from .implementations.todo import TodoCommand
40
38
  from .template_shortcut import TemplateShortcutCommand
41
39
 
42
40
  logger = logging.getLogger(__name__)
@@ -153,10 +151,7 @@ class CommandRegistry:
153
151
  ModelCommand,
154
152
  InitCommand,
155
153
  TemplateCommand,
156
- TodoCommand,
157
154
  CommandReloadCommand,
158
- PlanCommand, # Add plan command
159
- ExitPlanCommand, # Add exit plan command
160
155
  QuickStartCommand, # Add quickstart command
161
156
  ]
162
157
 
@@ -303,7 +298,7 @@ class CommandRegistry:
303
298
 
304
299
  def find_matching_commands(self, partial_command: str) -> List[str]:
305
300
  """
306
- Find all commands that start with the given partial command.
301
+ Find commands matching the given partial command.
307
302
 
308
303
  Args:
309
304
  partial_command: The partial command to match
@@ -313,7 +308,13 @@ class CommandRegistry:
313
308
  """
314
309
  self.discover_commands()
315
310
  partial = partial_command.lower()
316
- return [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
311
+
312
+ # CLAUDE_ANCHOR[key=86cc1a41] Prefix-only command matching after removing fuzzy fallback
313
+ prefix_matches = [cmd for cmd in self._commands.keys() if cmd.startswith(partial)]
314
+ if prefix_matches:
315
+ return prefix_matches
316
+
317
+ return []
317
318
 
318
319
  def is_command(self, text: str) -> bool:
319
320
  """Check if text starts with a registered command (supports partial matching)."""
@@ -74,7 +74,8 @@ class SlashCommandLoader:
74
74
  logger.error(f"Error scanning {directory}: {e}")
75
75
 
76
76
  logger.info(
77
- f"Discovered {len(all_commands)} slash commands from {stats['scanned_dirs']} directories"
77
+ f"Discovered {len(all_commands)} slash commands from "
78
+ f"{stats['scanned_dirs']} directories"
78
79
  )
79
80
  return CommandDiscoveryResult(all_commands, conflicts, errors, stats)
80
81
 
@@ -203,7 +203,8 @@ class CommandValidator:
203
203
  violations.append(
204
204
  SecurityViolation(
205
205
  type="invalid_subcommand",
206
- message=f"Subcommand '{subcommand}' not allowed for '{base_command}'",
206
+ message=f"Subcommand '{subcommand}' not allowed "
207
+ f"for '{base_command}'",
207
208
  command=command,
208
209
  severity="error",
209
210
  )
tunacode/cli/main.py CHANGED
@@ -16,6 +16,7 @@ from tunacode.core.tool_handler import ToolHandler
16
16
  from tunacode.exceptions import UserAbortError
17
17
  from tunacode.setup import setup
18
18
  from tunacode.ui import console as ui
19
+ from tunacode.ui.config_dashboard import show_config_dashboard
19
20
  from tunacode.utils.system import check_for_updates
20
21
 
21
22
  app_settings = ApplicationSettings()
@@ -30,6 +31,9 @@ def main(
30
31
  wizard: bool = typer.Option(
31
32
  False, "--wizard", help="Run interactive setup wizard for guided configuration."
32
33
  ),
34
+ show_config: bool = typer.Option(
35
+ False, "--show-config", help="Show configuration dashboard and exit."
36
+ ),
33
37
  baseurl: str = typer.Option(
34
38
  None, "--baseurl", help="API base URL (e.g., https://openrouter.ai/api/v1)"
35
39
  ),
@@ -49,6 +53,11 @@ def main(
49
53
  await ui.version()
50
54
  return
51
55
 
56
+ if show_config:
57
+ await ui.banner()
58
+ show_config_dashboard()
59
+ return
60
+
52
61
  await ui.banner()
53
62
 
54
63
  # Start update check in background
@@ -77,7 +86,8 @@ def main(
77
86
  from tunacode.exceptions import ConfigurationError
78
87
 
79
88
  if isinstance(e, ConfigurationError):
80
- # ConfigurationError already printed helpful message, just exit cleanly
89
+ # Display the configuration error message
90
+ await ui.error(str(e))
81
91
  update_task.cancel() # Cancel the update check
82
92
  return
83
93
  import traceback
@@ -87,6 +97,14 @@ def main(
87
97
  has_update, latest_version = await update_task
88
98
  if has_update:
89
99
  await ui.update_available(latest_version)
100
+ else:
101
+ # Normal exit - cleanup MCP servers
102
+ try:
103
+ from tunacode.core.agents import cleanup_mcp_servers
104
+
105
+ await cleanup_mcp_servers()
106
+ except Exception:
107
+ pass # Best effort cleanup
90
108
 
91
109
  asyncio.run(async_main())
92
110