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
@@ -18,6 +18,7 @@ from .base import Command, CommandCategory, CommandSpec, SimpleCommand
18
18
  from .implementations import (
19
19
  BranchCommand,
20
20
  ClearCommand,
21
+ CommandReloadCommand,
21
22
  CompactCommand,
22
23
  DumpCommand,
23
24
  FixCommand,
@@ -28,7 +29,6 @@ from .implementations import (
28
29
  ParseToolsCommand,
29
30
  RefreshConfigCommand,
30
31
  ThoughtsCommand,
31
- TodoCommand,
32
32
  UpdateCommand,
33
33
  YoloCommand,
34
34
  )
@@ -62,5 +62,5 @@ __all__ = [
62
62
  "UpdateCommand",
63
63
  "ModelCommand",
64
64
  "InitCommand",
65
- "TodoCommand",
65
+ "CommandReloadCommand",
66
66
  ]
@@ -1,6 +1,7 @@
1
1
  """Command implementations for TunaCode CLI."""
2
2
 
3
3
  # Import all command classes for easy access
4
+ from .command_reload import CommandReloadCommand
4
5
  from .conversation import CompactCommand
5
6
  from .debug import (
6
7
  DumpCommand,
@@ -19,7 +20,6 @@ from .system import (
19
20
  StreamingCommand,
20
21
  UpdateCommand,
21
22
  )
22
- from .todo import TodoCommand
23
23
 
24
24
  __all__ = [
25
25
  # System commands
@@ -28,6 +28,7 @@ __all__ = [
28
28
  "RefreshConfigCommand",
29
29
  "StreamingCommand",
30
30
  "UpdateCommand",
31
+ "CommandReloadCommand",
31
32
  # Debug commands
32
33
  "YoloCommand",
33
34
  "DumpCommand",
@@ -42,6 +43,4 @@ __all__ = [
42
43
  "ModelCommand",
43
44
  # Conversation commands
44
45
  "CompactCommand",
45
- # Todo commands
46
- "TodoCommand",
47
46
  ]
@@ -0,0 +1,48 @@
1
+ """Command reload implementation."""
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from ....types import CommandContext
7
+ from ....ui import console as ui
8
+ from ..base import CommandCategory, CommandSpec, SimpleCommand
9
+
10
+
11
+ class CommandReloadCommand(SimpleCommand):
12
+ """Reload slash commands to discover new commands."""
13
+
14
+ spec = CommandSpec(
15
+ name="command-reload",
16
+ aliases=["/command-reload"],
17
+ description="Reload slash commands to discover newly added commands",
18
+ category=CommandCategory.DEVELOPMENT,
19
+ )
20
+
21
+ def __init__(self, command_registry=None):
22
+ self._command_registry = command_registry
23
+
24
+ async def execute(self, args: List[str], context: CommandContext) -> None:
25
+ # Check if any command directories exist
26
+ command_dirs = [
27
+ Path(".tunacode/commands"),
28
+ Path(".claude/commands"),
29
+ Path.home() / ".tunacode/commands",
30
+ Path.home() / ".claude/commands",
31
+ ]
32
+
33
+ dirs_exist = any(cmd_dir.exists() for cmd_dir in command_dirs)
34
+
35
+ if not dirs_exist:
36
+ await ui.info("No commands directory found")
37
+ return
38
+
39
+ # Reload commands using registry
40
+ if self._command_registry:
41
+ try:
42
+ self._command_registry.reload_slash_commands()
43
+ await ui.success("Commands reloaded")
44
+ return
45
+ except Exception as e:
46
+ await ui.error(f"Reload failed: {e}")
47
+ else:
48
+ await ui.error("Command registry not available")
@@ -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())
@@ -0,0 +1,43 @@
1
+ """
2
+ Module: tunacode.cli.commands.implementations.quickstart
3
+
4
+ QuickStart command implementation for interactive tutorial system.
5
+ Provides guided introduction to TunaCode for new users.
6
+ """
7
+
8
+ import logging
9
+
10
+ from tunacode.types import CommandContext, CommandResult
11
+
12
+ from ..base import CommandCategory, CommandSpec, SimpleCommand
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class QuickStartCommand(SimpleCommand):
18
+ """Interactive quickstart tutorial command."""
19
+
20
+ spec = CommandSpec(
21
+ name="quickstart",
22
+ aliases=["/quickstart", "/qs"],
23
+ description="Interactive tutorial for getting started with TunaCode",
24
+ category=CommandCategory.SYSTEM,
25
+ )
26
+
27
+ async def execute(self, _args: list[str], context: CommandContext) -> CommandResult:
28
+ """Execute the quickstart tutorial."""
29
+ try:
30
+ from ....tutorial import TutorialManager
31
+ except ImportError:
32
+ await context.ui.error("Tutorial system is not available")
33
+ return
34
+
35
+ tutorial_manager = TutorialManager(context.state_manager)
36
+
37
+ # Always run tutorial when explicitly requested
38
+ success = await tutorial_manager.run_tutorial()
39
+
40
+ if success:
41
+ await context.ui.success("✅ Tutorial completed successfully!")
42
+ else:
43
+ await context.ui.info("Tutorial was cancelled or interrupted")