tunacode-cli 0.0.5__py3-none-any.whl → 0.0.7__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.py +91 -33
- tunacode/cli/main.py +6 -0
- tunacode/cli/model_selector.py +178 -0
- tunacode/cli/repl.py +11 -10
- tunacode/configuration/models.py +11 -1
- tunacode/constants.py +10 -10
- tunacode/context.py +1 -3
- tunacode/core/agents/main.py +52 -94
- tunacode/core/agents/tinyagent_main.py +173 -0
- tunacode/core/setup/git_safety_setup.py +39 -51
- tunacode/core/setup/optimized_coordinator.py +73 -0
- tunacode/exceptions.py +0 -2
- tunacode/services/enhanced_undo_service.py +322 -0
- tunacode/services/project_undo_service.py +311 -0
- tunacode/services/undo_service.py +13 -16
- tunacode/tools/base.py +11 -20
- tunacode/tools/tinyagent_tools.py +103 -0
- tunacode/tools/update_file.py +24 -14
- tunacode/tools/write_file.py +9 -7
- tunacode/ui/completers.py +98 -33
- tunacode/ui/input.py +8 -7
- tunacode/ui/keybindings.py +1 -3
- tunacode/ui/lexers.py +16 -17
- tunacode/ui/output.py +9 -3
- tunacode/ui/panels.py +4 -4
- tunacode/ui/prompt_manager.py +6 -4
- tunacode/utils/lazy_imports.py +59 -0
- tunacode/utils/regex_cache.py +33 -0
- tunacode/utils/system.py +40 -0
- tunacode_cli-0.0.7.dist-info/METADATA +262 -0
- {tunacode_cli-0.0.5.dist-info → tunacode_cli-0.0.7.dist-info}/RECORD +35 -27
- tunacode_cli-0.0.5.dist-info/METADATA +0 -247
- {tunacode_cli-0.0.5.dist-info → tunacode_cli-0.0.7.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.5.dist-info → tunacode_cli-0.0.7.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.5.dist-info → tunacode_cli-0.0.7.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.5.dist-info → tunacode_cli-0.0.7.dist-info}/top_level.txt +0 -0
tunacode/core/agents/main.py
CHANGED
|
@@ -2,49 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
Main agent functionality and coordination for the Sidekick CLI.
|
|
4
4
|
Provides agent creation, message processing, and tool call management.
|
|
5
|
+
Now using tinyAgent instead of pydantic-ai.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
|
-
from datetime import datetime, timezone
|
|
8
8
|
from typing import Optional
|
|
9
9
|
|
|
10
|
-
from pydantic_ai import Agent, Tool
|
|
11
|
-
from pydantic_ai.messages import ModelRequest, ToolReturnPart
|
|
12
|
-
|
|
13
10
|
from tunacode.core.state import StateManager
|
|
14
|
-
from tunacode.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
from
|
|
18
|
-
from
|
|
19
|
-
from
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if part.part_kind == "tool-call" and tool_callback:
|
|
31
|
-
await tool_callback(part, node)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def get_or_create_agent(model: ModelName, state_manager: StateManager) -> PydanticAgent:
|
|
35
|
-
if model not in state_manager.session.agents:
|
|
36
|
-
max_retries = state_manager.session.user_config["settings"]["max_retries"]
|
|
37
|
-
state_manager.session.agents[model] = Agent(
|
|
38
|
-
model=model,
|
|
39
|
-
tools=[
|
|
40
|
-
Tool(read_file, max_retries=max_retries),
|
|
41
|
-
Tool(run_command, max_retries=max_retries),
|
|
42
|
-
Tool(update_file, max_retries=max_retries),
|
|
43
|
-
Tool(write_file, max_retries=max_retries),
|
|
44
|
-
],
|
|
45
|
-
mcp_servers=get_mcp_servers(state_manager),
|
|
46
|
-
)
|
|
47
|
-
return state_manager.session.agents[model]
|
|
11
|
+
from tunacode.types import AgentRun, ErrorMessage, ModelName, ToolCallback
|
|
12
|
+
|
|
13
|
+
# Import tinyAgent implementation
|
|
14
|
+
from .tinyagent_main import get_or_create_react_agent
|
|
15
|
+
from .tinyagent_main import patch_tool_messages as tinyagent_patch_tool_messages
|
|
16
|
+
from .tinyagent_main import process_request_with_tinyagent
|
|
17
|
+
|
|
18
|
+
# Wrapper functions for backward compatibility with pydantic-ai interface
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_or_create_agent(model: ModelName, state_manager: StateManager):
|
|
22
|
+
"""
|
|
23
|
+
Wrapper for backward compatibility.
|
|
24
|
+
Returns the ReactAgent instance from tinyAgent.
|
|
25
|
+
"""
|
|
26
|
+
return get_or_create_react_agent(model, state_manager)
|
|
48
27
|
|
|
49
28
|
|
|
50
29
|
def patch_tool_messages(
|
|
@@ -52,57 +31,10 @@ def patch_tool_messages(
|
|
|
52
31
|
state_manager: StateManager = None,
|
|
53
32
|
):
|
|
54
33
|
"""
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Ignores tools that have corresponding retry prompts as the model is already
|
|
59
|
-
addressing them.
|
|
34
|
+
Wrapper for backward compatibility.
|
|
35
|
+
TinyAgent handles tool errors internally, so this is mostly a no-op.
|
|
60
36
|
"""
|
|
61
|
-
|
|
62
|
-
raise ValueError("state_manager is required for patch_tool_messages")
|
|
63
|
-
|
|
64
|
-
messages = state_manager.session.messages
|
|
65
|
-
|
|
66
|
-
if not messages:
|
|
67
|
-
return
|
|
68
|
-
|
|
69
|
-
# Map tool calls to their tool returns
|
|
70
|
-
tool_calls: dict[ToolCallId, ToolName] = {} # tool_call_id -> tool_name
|
|
71
|
-
tool_returns: set[ToolCallId] = set() # set of tool_call_ids with returns
|
|
72
|
-
retry_prompts: set[ToolCallId] = set() # set of tool_call_ids with retry prompts
|
|
73
|
-
|
|
74
|
-
for message in messages:
|
|
75
|
-
if hasattr(message, "parts"):
|
|
76
|
-
for part in message.parts:
|
|
77
|
-
if (
|
|
78
|
-
hasattr(part, "part_kind")
|
|
79
|
-
and hasattr(part, "tool_call_id")
|
|
80
|
-
and part.tool_call_id
|
|
81
|
-
):
|
|
82
|
-
if part.part_kind == "tool-call":
|
|
83
|
-
tool_calls[part.tool_call_id] = part.tool_name
|
|
84
|
-
elif part.part_kind == "tool-return":
|
|
85
|
-
tool_returns.add(part.tool_call_id)
|
|
86
|
-
elif part.part_kind == "retry-prompt":
|
|
87
|
-
retry_prompts.add(part.tool_call_id)
|
|
88
|
-
|
|
89
|
-
# Identify orphaned tools (those without responses and not being retried)
|
|
90
|
-
for tool_call_id, tool_name in list(tool_calls.items()):
|
|
91
|
-
if tool_call_id not in tool_returns and tool_call_id not in retry_prompts:
|
|
92
|
-
messages.append(
|
|
93
|
-
ModelRequest(
|
|
94
|
-
parts=[
|
|
95
|
-
ToolReturnPart(
|
|
96
|
-
tool_name=tool_name,
|
|
97
|
-
content=error_message,
|
|
98
|
-
tool_call_id=tool_call_id,
|
|
99
|
-
timestamp=datetime.now(timezone.utc),
|
|
100
|
-
part_kind="tool-return",
|
|
101
|
-
)
|
|
102
|
-
],
|
|
103
|
-
kind="request",
|
|
104
|
-
)
|
|
105
|
-
)
|
|
37
|
+
tinyagent_patch_tool_messages(error_message, state_manager)
|
|
106
38
|
|
|
107
39
|
|
|
108
40
|
async def process_request(
|
|
@@ -111,9 +43,35 @@ async def process_request(
|
|
|
111
43
|
state_manager: StateManager,
|
|
112
44
|
tool_callback: Optional[ToolCallback] = None,
|
|
113
45
|
) -> AgentRun:
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
46
|
+
"""
|
|
47
|
+
Process a request using tinyAgent.
|
|
48
|
+
Returns a result that mimics the pydantic-ai AgentRun structure.
|
|
49
|
+
"""
|
|
50
|
+
result = await process_request_with_tinyagent(model, message, state_manager, tool_callback)
|
|
51
|
+
|
|
52
|
+
# Create a mock AgentRun object for compatibility
|
|
53
|
+
class MockAgentRun:
|
|
54
|
+
def __init__(self, result_dict):
|
|
55
|
+
self._result = result_dict
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def result(self):
|
|
59
|
+
class MockResult:
|
|
60
|
+
def __init__(self, content):
|
|
61
|
+
self._content = content
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def output(self):
|
|
65
|
+
return self._content
|
|
66
|
+
|
|
67
|
+
return MockResult(self._result.get("result", ""))
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def messages(self):
|
|
71
|
+
return state_manager.session.messages
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def model(self):
|
|
75
|
+
return self._result.get("model", model)
|
|
76
|
+
|
|
77
|
+
return MockAgentRun(result)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""TinyAgent-based agent implementation."""
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from tinyagent import ReactAgent
|
|
8
|
+
|
|
9
|
+
from tunacode.core.state import StateManager
|
|
10
|
+
from tunacode.tools.tinyagent_tools import read_file, run_command, update_file, write_file
|
|
11
|
+
from tunacode.types import ModelName, ToolCallback
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_or_create_react_agent(model: ModelName, state_manager: StateManager) -> ReactAgent:
|
|
15
|
+
"""
|
|
16
|
+
Get or create a ReactAgent for the specified model.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
model: The model name (e.g., "openai:gpt-4o", "openrouter:openai/gpt-4.1")
|
|
20
|
+
state_manager: The state manager instance
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
ReactAgent instance configured for the model
|
|
24
|
+
"""
|
|
25
|
+
agents = state_manager.session.agents
|
|
26
|
+
|
|
27
|
+
if model not in agents:
|
|
28
|
+
# Parse model string to determine provider and actual model name
|
|
29
|
+
# Format: "provider:model" or "openrouter:provider/model"
|
|
30
|
+
if model.startswith("openrouter:"):
|
|
31
|
+
# OpenRouter model - extract the actual model name
|
|
32
|
+
actual_model = model.replace("openrouter:", "")
|
|
33
|
+
# Set environment to use OpenRouter base URL
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
os.environ["OPENAI_BASE_URL"] = "https://openrouter.ai/api/v1"
|
|
37
|
+
# Use OpenRouter API key if available
|
|
38
|
+
if state_manager.session.user_config["env"].get("OPENROUTER_API_KEY"):
|
|
39
|
+
os.environ["OPENAI_API_KEY"] = state_manager.session.user_config["env"][
|
|
40
|
+
"OPENROUTER_API_KEY"
|
|
41
|
+
]
|
|
42
|
+
else:
|
|
43
|
+
# Direct provider (openai, anthropic, google-gla)
|
|
44
|
+
provider, actual_model = model.split(":", 1)
|
|
45
|
+
# Reset to default base URL for direct providers
|
|
46
|
+
import os
|
|
47
|
+
|
|
48
|
+
if provider == "openai":
|
|
49
|
+
os.environ["OPENAI_BASE_URL"] = "https://api.openai.com/v1"
|
|
50
|
+
# Set appropriate API key
|
|
51
|
+
provider_key_map = {
|
|
52
|
+
"openai": "OPENAI_API_KEY",
|
|
53
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
54
|
+
"google-gla": "GEMINI_API_KEY",
|
|
55
|
+
}
|
|
56
|
+
if provider in provider_key_map:
|
|
57
|
+
key_name = provider_key_map[provider]
|
|
58
|
+
if state_manager.session.user_config["env"].get(key_name):
|
|
59
|
+
os.environ[key_name] = state_manager.session.user_config["env"][key_name]
|
|
60
|
+
|
|
61
|
+
# Create new ReactAgent with tools
|
|
62
|
+
# Note: tinyAgent gets model from environment variables, not constructor
|
|
63
|
+
agent = ReactAgent(tools=[read_file, write_file, update_file, run_command])
|
|
64
|
+
|
|
65
|
+
# Add MCP compatibility method
|
|
66
|
+
@asynccontextmanager
|
|
67
|
+
async def run_mcp_servers():
|
|
68
|
+
# TinyAgent doesn't have built-in MCP support yet
|
|
69
|
+
# This is a placeholder for compatibility
|
|
70
|
+
yield
|
|
71
|
+
|
|
72
|
+
agent.run_mcp_servers = run_mcp_servers
|
|
73
|
+
|
|
74
|
+
# Cache the agent
|
|
75
|
+
agents[model] = agent
|
|
76
|
+
|
|
77
|
+
return agents[model]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def process_request_with_tinyagent(
|
|
81
|
+
model: ModelName,
|
|
82
|
+
message: str,
|
|
83
|
+
state_manager: StateManager,
|
|
84
|
+
tool_callback: Optional[ToolCallback] = None,
|
|
85
|
+
) -> Dict[str, Any]:
|
|
86
|
+
"""
|
|
87
|
+
Process a request using TinyAgent's ReactAgent.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
model: The model to use
|
|
91
|
+
message: The user message
|
|
92
|
+
state_manager: State manager instance
|
|
93
|
+
tool_callback: Optional callback for tool execution (for UI updates)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Dict containing the result and any metadata
|
|
97
|
+
"""
|
|
98
|
+
agent = get_or_create_react_agent(model, state_manager)
|
|
99
|
+
|
|
100
|
+
# Convert message history to format expected by tinyAgent
|
|
101
|
+
# Note: tinyAgent handles message history differently than pydantic-ai
|
|
102
|
+
# We'll need to adapt based on tinyAgent's actual API
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# Run the agent with the message
|
|
106
|
+
# The new API's run() method might be synchronous based on the examples
|
|
107
|
+
import asyncio
|
|
108
|
+
if asyncio.iscoroutinefunction(agent.run):
|
|
109
|
+
result = await agent.run(message)
|
|
110
|
+
else:
|
|
111
|
+
result = agent.run(message)
|
|
112
|
+
|
|
113
|
+
# Update message history in state_manager
|
|
114
|
+
# This will need to be adapted based on how tinyAgent returns messages
|
|
115
|
+
state_manager.session.messages.append(
|
|
116
|
+
{
|
|
117
|
+
"role": "user",
|
|
118
|
+
"content": message,
|
|
119
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
state_manager.session.messages.append(
|
|
124
|
+
{
|
|
125
|
+
"role": "assistant",
|
|
126
|
+
"content": result,
|
|
127
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return {"result": result, "success": True, "model": model}
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
# Handle errors
|
|
135
|
+
error_result = {
|
|
136
|
+
"result": f"Error: {str(e)}",
|
|
137
|
+
"success": False,
|
|
138
|
+
"model": model,
|
|
139
|
+
"error": str(e),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Still update message history with the error
|
|
143
|
+
state_manager.session.messages.append(
|
|
144
|
+
{
|
|
145
|
+
"role": "user",
|
|
146
|
+
"content": message,
|
|
147
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
148
|
+
}
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
state_manager.session.messages.append(
|
|
152
|
+
{
|
|
153
|
+
"role": "assistant",
|
|
154
|
+
"content": f"Error occurred: {str(e)}",
|
|
155
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
156
|
+
"error": True,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return error_result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def patch_tool_messages(
|
|
164
|
+
error_message: str = "Tool operation failed",
|
|
165
|
+
state_manager: StateManager = None,
|
|
166
|
+
):
|
|
167
|
+
"""
|
|
168
|
+
Compatibility function for patching tool messages.
|
|
169
|
+
With tinyAgent, this may not be needed as it handles tool errors differently.
|
|
170
|
+
"""
|
|
171
|
+
# TinyAgent handles tool retries and errors internally
|
|
172
|
+
# This function is kept for compatibility but may be simplified
|
|
173
|
+
pass
|
|
@@ -13,15 +13,12 @@ from tunacode.ui.panels import panel
|
|
|
13
13
|
async def yes_no_prompt(question: str, default: bool = True) -> bool:
|
|
14
14
|
"""Simple yes/no prompt."""
|
|
15
15
|
default_text = "[Y/n]" if default else "[y/N]"
|
|
16
|
-
response = await prompt_input(
|
|
17
|
-
|
|
18
|
-
pretext=f"{question} {default_text}: "
|
|
19
|
-
)
|
|
20
|
-
|
|
16
|
+
response = await prompt_input(session_key="yes_no", pretext=f"{question} {default_text}: ")
|
|
17
|
+
|
|
21
18
|
if not response.strip():
|
|
22
19
|
return default
|
|
23
|
-
|
|
24
|
-
return response.lower().strip() in [
|
|
20
|
+
|
|
21
|
+
return response.lower().strip() in ["y", "yes"]
|
|
25
22
|
|
|
26
23
|
|
|
27
24
|
class GitSafetySetup(BaseSetup):
|
|
@@ -29,7 +26,7 @@ class GitSafetySetup(BaseSetup):
|
|
|
29
26
|
|
|
30
27
|
def __init__(self, state_manager: StateManager):
|
|
31
28
|
super().__init__(state_manager)
|
|
32
|
-
|
|
29
|
+
|
|
33
30
|
@property
|
|
34
31
|
def name(self) -> str:
|
|
35
32
|
"""Return the name of this setup step."""
|
|
@@ -45,18 +42,15 @@ class GitSafetySetup(BaseSetup):
|
|
|
45
42
|
try:
|
|
46
43
|
# Check if git is installed
|
|
47
44
|
result = subprocess.run(
|
|
48
|
-
["git", "--version"],
|
|
49
|
-
capture_output=True,
|
|
50
|
-
text=True,
|
|
51
|
-
check=False
|
|
45
|
+
["git", "--version"], capture_output=True, text=True, check=False
|
|
52
46
|
)
|
|
53
|
-
|
|
47
|
+
|
|
54
48
|
if result.returncode != 0:
|
|
55
49
|
await panel(
|
|
56
50
|
"⚠️ Git Not Found",
|
|
57
51
|
"Git is not installed or not in PATH. TunaCode will modify files directly.\n"
|
|
58
52
|
"It's strongly recommended to install Git for safety.",
|
|
59
|
-
border_style="yellow"
|
|
53
|
+
border_style="yellow",
|
|
60
54
|
)
|
|
61
55
|
return
|
|
62
56
|
|
|
@@ -66,33 +60,31 @@ class GitSafetySetup(BaseSetup):
|
|
|
66
60
|
capture_output=True,
|
|
67
61
|
text=True,
|
|
68
62
|
check=False,
|
|
69
|
-
cwd=Path.cwd()
|
|
63
|
+
cwd=Path.cwd(),
|
|
70
64
|
)
|
|
71
|
-
|
|
65
|
+
|
|
72
66
|
if result.returncode != 0:
|
|
73
67
|
await panel(
|
|
74
68
|
"⚠️ Not a Git Repository",
|
|
75
69
|
"This directory is not a Git repository. TunaCode will modify files directly.\n"
|
|
76
70
|
"Consider initializing a Git repository for safety: git init",
|
|
77
|
-
border_style="yellow"
|
|
71
|
+
border_style="yellow",
|
|
78
72
|
)
|
|
79
73
|
return
|
|
80
74
|
|
|
81
75
|
# Get current branch name
|
|
82
76
|
result = subprocess.run(
|
|
83
|
-
["git", "branch", "--show-current"],
|
|
84
|
-
capture_output=True,
|
|
85
|
-
text=True,
|
|
86
|
-
check=True
|
|
77
|
+
["git", "branch", "--show-current"], capture_output=True, text=True, check=True
|
|
87
78
|
)
|
|
88
79
|
current_branch = result.stdout.strip()
|
|
89
|
-
|
|
80
|
+
|
|
90
81
|
if not current_branch:
|
|
91
82
|
# Detached HEAD state
|
|
92
83
|
await panel(
|
|
93
84
|
"⚠️ Detached HEAD State",
|
|
94
|
-
"You're in a detached HEAD state. TunaCode will continue
|
|
95
|
-
|
|
85
|
+
"You're in a detached HEAD state. TunaCode will continue "
|
|
86
|
+
"without creating a branch.",
|
|
87
|
+
border_style="yellow",
|
|
96
88
|
)
|
|
97
89
|
return
|
|
98
90
|
|
|
@@ -103,31 +95,28 @@ class GitSafetySetup(BaseSetup):
|
|
|
103
95
|
|
|
104
96
|
# Propose new branch name
|
|
105
97
|
new_branch = f"{current_branch}-tunacode"
|
|
106
|
-
|
|
98
|
+
|
|
107
99
|
# Check if there are uncommitted changes
|
|
108
100
|
result = subprocess.run(
|
|
109
|
-
["git", "status", "--porcelain"],
|
|
110
|
-
capture_output=True,
|
|
111
|
-
text=True,
|
|
112
|
-
check=True
|
|
101
|
+
["git", "status", "--porcelain"], capture_output=True, text=True, check=True
|
|
113
102
|
)
|
|
114
|
-
|
|
103
|
+
|
|
115
104
|
has_changes = bool(result.stdout.strip())
|
|
116
|
-
|
|
105
|
+
|
|
117
106
|
# Ask user if they want to create a safety branch
|
|
118
107
|
message = (
|
|
119
|
-
f"For safety, TunaCode can create a new branch '{new_branch}'
|
|
108
|
+
f"For safety, TunaCode can create a new branch '{new_branch}' "
|
|
109
|
+
f"based on '{current_branch}'.\n"
|
|
120
110
|
f"This helps protect your work from unintended changes.\n"
|
|
121
111
|
)
|
|
122
|
-
|
|
112
|
+
|
|
123
113
|
if has_changes:
|
|
124
|
-
message +=
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
114
|
+
message += (
|
|
115
|
+
"\n⚠️ You have uncommitted changes that will be brought to the new branch."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
create_branch = await yes_no_prompt(f"{message}\n\nCreate safety branch?", default=True)
|
|
119
|
+
|
|
131
120
|
if not create_branch:
|
|
132
121
|
# User declined - show warning
|
|
133
122
|
await panel(
|
|
@@ -135,26 +124,25 @@ class GitSafetySetup(BaseSetup):
|
|
|
135
124
|
"You've chosen to work directly on your current branch.\n"
|
|
136
125
|
"TunaCode will modify files in place. Make sure you have backups!\n"
|
|
137
126
|
"You can always use /undo to revert changes.",
|
|
138
|
-
border_style="red"
|
|
127
|
+
border_style="red",
|
|
139
128
|
)
|
|
140
129
|
# Save preference
|
|
141
130
|
self.state_manager.session.user_config["skip_git_safety"] = True
|
|
142
131
|
return
|
|
143
|
-
|
|
132
|
+
|
|
144
133
|
# Create and checkout the new branch
|
|
145
134
|
try:
|
|
146
135
|
# Check if branch already exists
|
|
147
136
|
result = subprocess.run(
|
|
148
137
|
["git", "show-ref", "--verify", f"refs/heads/{new_branch}"],
|
|
149
138
|
capture_output=True,
|
|
150
|
-
check=False
|
|
139
|
+
check=False,
|
|
151
140
|
)
|
|
152
|
-
|
|
141
|
+
|
|
153
142
|
if result.returncode == 0:
|
|
154
143
|
# Branch exists, ask to use it
|
|
155
144
|
use_existing = await yes_no_prompt(
|
|
156
|
-
f"Branch '{new_branch}' already exists. Switch to it?",
|
|
157
|
-
default=True
|
|
145
|
+
f"Branch '{new_branch}' already exists. Switch to it?", default=True
|
|
158
146
|
)
|
|
159
147
|
if use_existing:
|
|
160
148
|
subprocess.run(["git", "checkout", new_branch], check=True)
|
|
@@ -165,24 +153,24 @@ class GitSafetySetup(BaseSetup):
|
|
|
165
153
|
# Create new branch
|
|
166
154
|
subprocess.run(["git", "checkout", "-b", new_branch], check=True)
|
|
167
155
|
await ui.success(f"Created and switched to new branch: {new_branch}")
|
|
168
|
-
|
|
156
|
+
|
|
169
157
|
except subprocess.CalledProcessError as e:
|
|
170
158
|
await panel(
|
|
171
159
|
"❌ Failed to Create Branch",
|
|
172
160
|
f"Could not create branch '{new_branch}': {str(e)}\n"
|
|
173
161
|
"Continuing on current branch.",
|
|
174
|
-
border_style="red"
|
|
162
|
+
border_style="red",
|
|
175
163
|
)
|
|
176
|
-
|
|
164
|
+
|
|
177
165
|
except Exception as e:
|
|
178
166
|
# Non-fatal error - just warn the user
|
|
179
167
|
await panel(
|
|
180
168
|
"⚠️ Git Safety Setup Failed",
|
|
181
169
|
f"Could not set up Git safety: {str(e)}\n"
|
|
182
170
|
"TunaCode will continue without branch protection.",
|
|
183
|
-
border_style="yellow"
|
|
171
|
+
border_style="yellow",
|
|
184
172
|
)
|
|
185
173
|
|
|
186
174
|
async def validate(self) -> bool:
|
|
187
175
|
"""Validate git safety setup - always returns True as this is optional."""
|
|
188
|
-
return True
|
|
176
|
+
return True
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Optimized setup coordinator with deferred loading."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import List, Set
|
|
5
|
+
|
|
6
|
+
from tunacode.core.setup.base import BaseSetup
|
|
7
|
+
from tunacode.core.state import StateManager
|
|
8
|
+
from tunacode.ui import console as ui
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OptimizedSetupCoordinator:
|
|
12
|
+
"""Optimized coordinator that defers non-critical setup steps."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, state_manager: StateManager):
|
|
15
|
+
self.state_manager = state_manager
|
|
16
|
+
self.critical_steps: List[BaseSetup] = []
|
|
17
|
+
self.deferred_steps: List[BaseSetup] = []
|
|
18
|
+
self._deferred_task = None
|
|
19
|
+
|
|
20
|
+
# Define critical steps that must run at startup
|
|
21
|
+
self.critical_step_names: Set[str] = {
|
|
22
|
+
"Configuration", # Need config to know which model to use
|
|
23
|
+
"Environment Variables", # Need API keys
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def register_step(self, step: BaseSetup) -> None:
|
|
27
|
+
"""Register a setup step, separating critical from deferred."""
|
|
28
|
+
if step.name in self.critical_step_names:
|
|
29
|
+
self.critical_steps.append(step)
|
|
30
|
+
else:
|
|
31
|
+
self.deferred_steps.append(step)
|
|
32
|
+
|
|
33
|
+
async def run_setup(self, force_setup: bool = False) -> None:
|
|
34
|
+
"""Run critical setup immediately, defer the rest."""
|
|
35
|
+
# Run critical steps synchronously
|
|
36
|
+
for step in self.critical_steps:
|
|
37
|
+
try:
|
|
38
|
+
if await step.should_run(force_setup):
|
|
39
|
+
await step.execute(force_setup)
|
|
40
|
+
if not await step.validate():
|
|
41
|
+
await ui.error(f"Setup validation failed: {step.name}")
|
|
42
|
+
raise RuntimeError(f"Setup step '{step.name}' failed validation")
|
|
43
|
+
except Exception as e:
|
|
44
|
+
await ui.error(f"Setup failed at step '{step.name}': {str(e)}")
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
# Schedule deferred steps to run in background
|
|
48
|
+
if self.deferred_steps and not self._deferred_task:
|
|
49
|
+
self._deferred_task = asyncio.create_task(self._run_deferred_steps(force_setup))
|
|
50
|
+
|
|
51
|
+
async def _run_deferred_steps(self, force_setup: bool) -> None:
|
|
52
|
+
"""Run deferred steps in the background."""
|
|
53
|
+
# Wait a moment to let the main UI start
|
|
54
|
+
await asyncio.sleep(0.1)
|
|
55
|
+
|
|
56
|
+
for step in self.deferred_steps:
|
|
57
|
+
try:
|
|
58
|
+
if await step.should_run(force_setup):
|
|
59
|
+
await step.execute(force_setup)
|
|
60
|
+
# Don't validate deferred steps - they're non-critical
|
|
61
|
+
except Exception:
|
|
62
|
+
# Log but don't fail on deferred steps
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
async def ensure_deferred_complete(self) -> None:
|
|
66
|
+
"""Ensure deferred steps are complete before certain operations."""
|
|
67
|
+
if self._deferred_task and not self._deferred_task.done():
|
|
68
|
+
await self._deferred_task
|
|
69
|
+
|
|
70
|
+
def clear_steps(self) -> None:
|
|
71
|
+
"""Clear all registered setup steps."""
|
|
72
|
+
self.critical_steps.clear()
|
|
73
|
+
self.deferred_steps.clear()
|