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
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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)
|
|
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
|
|
71
|
-
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
#
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
await ui.
|
|
37
|
-
await ui.muted("
|
|
38
|
-
await ui.muted(
|
|
39
|
-
|
|
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
|
-
#
|
|
44
|
-
await ui.
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
await ui.
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|