tunacode-cli 0.0.4__py3-none-any.whl → 0.0.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 (36) hide show
  1. tunacode/cli/commands.py +91 -33
  2. tunacode/cli/model_selector.py +178 -0
  3. tunacode/cli/repl.py +11 -10
  4. tunacode/configuration/models.py +11 -1
  5. tunacode/constants.py +11 -11
  6. tunacode/context.py +1 -3
  7. tunacode/core/agents/main.py +52 -94
  8. tunacode/core/agents/tinyagent_main.py +171 -0
  9. tunacode/core/setup/git_safety_setup.py +39 -51
  10. tunacode/core/setup/optimized_coordinator.py +73 -0
  11. tunacode/exceptions.py +13 -15
  12. tunacode/services/enhanced_undo_service.py +322 -0
  13. tunacode/services/project_undo_service.py +311 -0
  14. tunacode/services/undo_service.py +18 -21
  15. tunacode/tools/base.py +11 -20
  16. tunacode/tools/tinyagent_tools.py +103 -0
  17. tunacode/tools/update_file.py +24 -14
  18. tunacode/tools/write_file.py +9 -7
  19. tunacode/types.py +2 -2
  20. tunacode/ui/completers.py +98 -33
  21. tunacode/ui/input.py +8 -7
  22. tunacode/ui/keybindings.py +1 -3
  23. tunacode/ui/lexers.py +16 -17
  24. tunacode/ui/output.py +9 -3
  25. tunacode/ui/panels.py +4 -4
  26. tunacode/ui/prompt_manager.py +6 -4
  27. tunacode/utils/lazy_imports.py +59 -0
  28. tunacode/utils/regex_cache.py +33 -0
  29. tunacode/utils/system.py +13 -13
  30. tunacode_cli-0.0.6.dist-info/METADATA +235 -0
  31. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/RECORD +35 -27
  32. tunacode_cli-0.0.4.dist-info/METADATA +0 -247
  33. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/WHEEL +0 -0
  34. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/entry_points.txt +0 -0
  35. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/licenses/LICENSE +0 -0
  36. {tunacode_cli-0.0.4.dist-info → tunacode_cli-0.0.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,171 @@
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.react.react_agent 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 the actual model name
62
+ agent = ReactAgent(model_override=actual_model)
63
+
64
+ # Register our tools
65
+ for fn in (read_file, write_file, update_file, run_command):
66
+ agent.register_tool(fn._tool)
67
+
68
+ # Add MCP compatibility method
69
+ @asynccontextmanager
70
+ async def run_mcp_servers():
71
+ # TinyAgent doesn't have built-in MCP support yet
72
+ # This is a placeholder for compatibility
73
+ yield
74
+
75
+ agent.run_mcp_servers = run_mcp_servers
76
+
77
+ # Cache the agent
78
+ agents[model] = agent
79
+
80
+ return agents[model]
81
+
82
+
83
+ async def process_request_with_tinyagent(
84
+ model: ModelName,
85
+ message: str,
86
+ state_manager: StateManager,
87
+ tool_callback: Optional[ToolCallback] = None,
88
+ ) -> Dict[str, Any]:
89
+ """
90
+ Process a request using TinyAgent's ReactAgent.
91
+
92
+ Args:
93
+ model: The model to use
94
+ message: The user message
95
+ state_manager: State manager instance
96
+ tool_callback: Optional callback for tool execution (for UI updates)
97
+
98
+ Returns:
99
+ Dict containing the result and any metadata
100
+ """
101
+ agent = get_or_create_react_agent(model, state_manager)
102
+
103
+ # Convert message history to format expected by tinyAgent
104
+ # Note: tinyAgent handles message history differently than pydantic-ai
105
+ # We'll need to adapt based on tinyAgent's actual API
106
+
107
+ try:
108
+ # Run the agent with the message
109
+ result = await agent.run_react(message)
110
+
111
+ # Update message history in state_manager
112
+ # This will need to be adapted based on how tinyAgent returns messages
113
+ state_manager.session.messages.append(
114
+ {
115
+ "role": "user",
116
+ "content": message,
117
+ "timestamp": datetime.now(timezone.utc).isoformat(),
118
+ }
119
+ )
120
+
121
+ state_manager.session.messages.append(
122
+ {
123
+ "role": "assistant",
124
+ "content": result,
125
+ "timestamp": datetime.now(timezone.utc).isoformat(),
126
+ }
127
+ )
128
+
129
+ return {"result": result, "success": True, "model": model}
130
+
131
+ except Exception as e:
132
+ # Handle errors
133
+ error_result = {
134
+ "result": f"Error: {str(e)}",
135
+ "success": False,
136
+ "model": model,
137
+ "error": str(e),
138
+ }
139
+
140
+ # Still update message history with the error
141
+ state_manager.session.messages.append(
142
+ {
143
+ "role": "user",
144
+ "content": message,
145
+ "timestamp": datetime.now(timezone.utc).isoformat(),
146
+ }
147
+ )
148
+
149
+ state_manager.session.messages.append(
150
+ {
151
+ "role": "assistant",
152
+ "content": f"Error occurred: {str(e)}",
153
+ "timestamp": datetime.now(timezone.utc).isoformat(),
154
+ "error": True,
155
+ }
156
+ )
157
+
158
+ return error_result
159
+
160
+
161
+ def patch_tool_messages(
162
+ error_message: str = "Tool operation failed",
163
+ state_manager: StateManager = None,
164
+ ):
165
+ """
166
+ Compatibility function for patching tool messages.
167
+ With tinyAgent, this may not be needed as it handles tool errors differently.
168
+ """
169
+ # TinyAgent handles tool retries and errors internally
170
+ # This function is kept for compatibility but may be simplified
171
+ 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
- session_key="yes_no",
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 ['y', 'yes']
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 without creating a branch.",
95
- border_style="yellow"
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}' based on '{current_branch}'.\n"
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 += "\n⚠️ You have uncommitted changes that will be brought to the new branch."
125
-
126
- create_branch = await yes_no_prompt(
127
- f"{message}\n\nCreate safety branch?",
128
- default=True
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()
tunacode/exceptions.py CHANGED
@@ -1,41 +1,41 @@
1
1
  """
2
- Sidekick CLI exception hierarchy.
2
+ TunaCode CLI exception hierarchy.
3
3
 
4
- This module defines all custom exceptions used throughout the Sidekick CLI.
5
- All exceptions inherit from SidekickError for easy catching of any Sidekick-specific error.
4
+ This module defines all custom exceptions used throughout the TunaCode CLI.
5
+ All exceptions inherit from TunaCodeError for easy catching of any TunaCode-specific error.
6
6
  """
7
7
 
8
8
  from tunacode.types import ErrorMessage, FilePath, OriginalError, ToolName
9
9
 
10
10
 
11
- class SidekickError(Exception):
12
- """Base exception for all Sidekick errors."""
11
+ class TunaCodeError(Exception):
12
+ """Base exception for all TunaCode errors."""
13
13
 
14
14
  pass
15
15
 
16
16
 
17
17
  # Configuration and Setup Exceptions
18
- class ConfigurationError(SidekickError):
18
+ class ConfigurationError(TunaCodeError):
19
19
  """Raised when there's a configuration issue."""
20
20
 
21
21
  pass
22
22
 
23
23
 
24
24
  # User Interaction Exceptions
25
- class UserAbortError(SidekickError):
25
+ class UserAbortError(TunaCodeError):
26
26
  """Raised when user aborts an operation."""
27
27
 
28
28
  pass
29
29
 
30
30
 
31
- class ValidationError(SidekickError):
31
+ class ValidationError(TunaCodeError):
32
32
  """Raised when input validation fails."""
33
33
 
34
34
  pass
35
35
 
36
36
 
37
37
  # Tool and Agent Exceptions
38
- class ToolExecutionError(SidekickError):
38
+ class ToolExecutionError(TunaCodeError):
39
39
  """Raised when a tool fails to execute."""
40
40
 
41
41
  def __init__(
@@ -46,21 +46,21 @@ class ToolExecutionError(SidekickError):
46
46
  super().__init__(f"Tool '{tool_name}' failed: {message}")
47
47
 
48
48
 
49
- class AgentError(SidekickError):
49
+ class AgentError(TunaCodeError):
50
50
  """Raised when agent operations fail."""
51
51
 
52
52
  pass
53
53
 
54
54
 
55
55
  # State Management Exceptions
56
- class StateError(SidekickError):
56
+ class StateError(TunaCodeError):
57
57
  """Raised when there's an issue with application state."""
58
58
 
59
59
  pass
60
60
 
61
61
 
62
62
  # External Service Exceptions
63
- class ServiceError(SidekickError):
63
+ class ServiceError(TunaCodeError):
64
64
  """Base exception for external service failures."""
65
65
 
66
66
  pass
@@ -77,8 +77,6 @@ class MCPError(ServiceError):
77
77
  super().__init__(f"MCP server '{server_name}' error: {message}")
78
78
 
79
79
 
80
-
81
-
82
80
  class GitOperationError(ServiceError):
83
81
  """Raised when Git operations fail."""
84
82
 
@@ -89,7 +87,7 @@ class GitOperationError(ServiceError):
89
87
 
90
88
 
91
89
  # File System Exceptions
92
- class FileOperationError(SidekickError):
90
+ class FileOperationError(TunaCodeError):
93
91
  """Raised when file system operations fail."""
94
92
 
95
93
  def __init__(