tunacode-cli 0.0.17__py3-none-any.whl → 0.0.19__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 (47) hide show
  1. tunacode/cli/commands.py +73 -41
  2. tunacode/cli/main.py +29 -26
  3. tunacode/cli/repl.py +91 -37
  4. tunacode/cli/textual_app.py +69 -66
  5. tunacode/cli/textual_bridge.py +33 -32
  6. tunacode/configuration/settings.py +2 -9
  7. tunacode/constants.py +2 -4
  8. tunacode/context.py +1 -1
  9. tunacode/core/agents/__init__.py +12 -0
  10. tunacode/core/agents/main.py +89 -63
  11. tunacode/core/agents/orchestrator.py +99 -0
  12. tunacode/core/agents/planner_schema.py +9 -0
  13. tunacode/core/agents/readonly.py +51 -0
  14. tunacode/core/background/__init__.py +0 -0
  15. tunacode/core/background/manager.py +36 -0
  16. tunacode/core/llm/__init__.py +0 -0
  17. tunacode/core/llm/planner.py +63 -0
  18. tunacode/core/setup/config_setup.py +79 -44
  19. tunacode/core/setup/coordinator.py +20 -13
  20. tunacode/core/setup/git_safety_setup.py +35 -49
  21. tunacode/core/state.py +2 -9
  22. tunacode/exceptions.py +0 -2
  23. tunacode/prompts/system.txt +179 -69
  24. tunacode/tools/__init__.py +10 -1
  25. tunacode/tools/base.py +1 -1
  26. tunacode/tools/bash.py +5 -5
  27. tunacode/tools/grep.py +210 -250
  28. tunacode/tools/read_file.py +2 -8
  29. tunacode/tools/run_command.py +4 -11
  30. tunacode/tools/update_file.py +2 -6
  31. tunacode/ui/completers.py +32 -31
  32. tunacode/ui/console.py +3 -3
  33. tunacode/ui/input.py +8 -5
  34. tunacode/ui/keybindings.py +1 -3
  35. tunacode/ui/lexers.py +16 -16
  36. tunacode/ui/output.py +2 -2
  37. tunacode/ui/panels.py +8 -8
  38. tunacode/ui/prompt_manager.py +19 -7
  39. tunacode/utils/import_cache.py +11 -0
  40. tunacode/utils/user_configuration.py +24 -2
  41. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/METADATA +68 -11
  42. tunacode_cli-0.0.19.dist-info/RECORD +75 -0
  43. tunacode_cli-0.0.17.dist-info/RECORD +0 -67
  44. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/WHEEL +0 -0
  45. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/entry_points.txt +0 -0
  46. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/licenses/LICENSE +0 -0
  47. {tunacode_cli-0.0.17.dist-info → tunacode_cli-0.0.19.dist-info}/top_level.txt +0 -0
tunacode/cli/commands.py CHANGED
@@ -6,7 +6,6 @@ from enum import Enum
6
6
  from typing import Any, Dict, List, Optional, Type
7
7
 
8
8
  from .. import utils
9
- from ..configuration.models import ModelRegistry
10
9
  from ..exceptions import ValidationError
11
10
  from ..types import CommandArgs, CommandContext, CommandResult, ProcessRequestCallback
12
11
  from ..ui import console as ui
@@ -116,9 +115,9 @@ class YoloCommand(SimpleCommand):
116
115
  state = context.state_manager.session
117
116
  state.yolo = not state.yolo
118
117
  if state.yolo:
119
- await ui.success("Ooh shit, its YOLO time!\n")
118
+ await ui.success("All tools are now active ⚡ Please proceed with caution.\n")
120
119
  else:
121
- await ui.info("Pfft, boring...\n")
120
+ await ui.info("Tool confirmations re-enabled for safety.\n")
122
121
 
123
122
 
124
123
  class DumpCommand(SimpleCommand):
@@ -168,6 +167,39 @@ class ThoughtsCommand(SimpleCommand):
168
167
  await ui.success(f"Thought display {status}")
169
168
 
170
169
 
170
+ class ArchitectCommand(SimpleCommand):
171
+ """Toggle architect mode for task planning and orchestration."""
172
+
173
+ def __init__(self):
174
+ super().__init__(
175
+ CommandSpec(
176
+ name="architect",
177
+ aliases=["/architect"],
178
+ description="Toggle architect mode (task planning & orchestration)",
179
+ category=CommandCategory.DEBUG,
180
+ )
181
+ )
182
+
183
+ async def execute(self, args: List[str], context: CommandContext) -> None:
184
+ state = context.state_manager.session
185
+ if args:
186
+ arg = args[0].lower()
187
+ if arg in {"on", "1", "true"}:
188
+ state.architect_mode = True
189
+ elif arg in {"off", "0", "false"}:
190
+ state.architect_mode = False
191
+ else:
192
+ await ui.error("Usage: /architect [on|off]")
193
+ return
194
+ else:
195
+ state.architect_mode = not getattr(state, 'architect_mode', False)
196
+ status = "ON" if state.architect_mode else "OFF"
197
+ if state.architect_mode:
198
+ await ui.success(f"Architect mode {status} - Requests will be planned before execution")
199
+ else:
200
+ await ui.success(f"Architect mode {status} - Using direct execution")
201
+
202
+
171
203
  class IterationsCommand(SimpleCommand):
172
204
  """Configure maximum agent iterations for ReAct reasoning."""
173
205
 
@@ -189,12 +221,12 @@ class IterationsCommand(SimpleCommand):
189
221
  if new_limit < 1 or new_limit > 50:
190
222
  await ui.error("Iterations must be between 1 and 50")
191
223
  return
192
-
224
+
193
225
  # Update the user config
194
226
  if "settings" not in state.user_config:
195
227
  state.user_config["settings"] = {}
196
228
  state.user_config["settings"]["max_iterations"] = new_limit
197
-
229
+
198
230
  await ui.success(f"Maximum iterations set to {new_limit}")
199
231
  await ui.muted("Higher values allow more complex reasoning but may be slower")
200
232
  except ValueError:
@@ -221,8 +253,9 @@ class ClearCommand(SimpleCommand):
221
253
  async def execute(self, args: List[str], context: CommandContext) -> None:
222
254
  # Patch any orphaned tool calls before clearing
223
255
  from tunacode.core.agents.main import patch_tool_messages
256
+
224
257
  patch_tool_messages("Conversation cleared", context.state_manager)
225
-
258
+
226
259
  await ui.clear()
227
260
  context.state_manager.session.messages = []
228
261
  await ui.success("Message history cleared")
@@ -243,17 +276,17 @@ class FixCommand(SimpleCommand):
243
276
 
244
277
  async def execute(self, args: List[str], context: CommandContext) -> None:
245
278
  from tunacode.core.agents.main import patch_tool_messages
246
-
279
+
247
280
  # Count current messages
248
281
  before_count = len(context.state_manager.session.messages)
249
-
282
+
250
283
  # Patch orphaned tool calls
251
284
  patch_tool_messages("Tool call resolved by /fix command", context.state_manager)
252
-
285
+
253
286
  # Count after patching
254
287
  after_count = len(context.state_manager.session.messages)
255
288
  patched_count = after_count - before_count
256
-
289
+
257
290
  if patched_count > 0:
258
291
  await ui.success(f"Fixed {patched_count} orphaned tool call(s)")
259
292
  await ui.muted("You can now continue the conversation normally")
@@ -269,36 +302,37 @@ class ParseToolsCommand(SimpleCommand):
269
302
  CommandSpec(
270
303
  name="parsetools",
271
304
  aliases=["/parsetools"],
272
- description="Parse JSON tool calls from last response when structured calling fails",
305
+ description=(
306
+ "Parse JSON tool calls from last response when structured calling fails"
307
+ ),
273
308
  category=CommandCategory.DEBUG,
274
309
  )
275
310
  )
276
311
 
277
312
  async def execute(self, args: List[str], context: CommandContext) -> None:
278
313
  from tunacode.core.agents.main import extract_and_execute_tool_calls
279
-
314
+
280
315
  # Find the last model response in messages
281
316
  messages = context.state_manager.session.messages
282
317
  if not messages:
283
318
  await ui.error("No message history found")
284
319
  return
285
-
320
+
286
321
  # Look for the most recent response with text content
287
322
  found_content = False
288
323
  for msg in reversed(messages):
289
- if hasattr(msg, 'parts'):
324
+ if hasattr(msg, "parts"):
290
325
  for part in msg.parts:
291
- if hasattr(part, 'content') and isinstance(part.content, str):
326
+ if hasattr(part, "content") and isinstance(part.content, str):
292
327
  # Create tool callback
293
328
  from tunacode.cli.repl import _tool_handler
329
+
294
330
  def tool_callback_with_state(part, node):
295
331
  return _tool_handler(part, node, context.state_manager)
296
-
332
+
297
333
  try:
298
334
  await extract_and_execute_tool_calls(
299
- part.content,
300
- tool_callback_with_state,
301
- context.state_manager
335
+ part.content, tool_callback_with_state, context.state_manager
302
336
  )
303
337
  await ui.success("JSON tool parsing completed")
304
338
  found_content = True
@@ -306,7 +340,7 @@ class ParseToolsCommand(SimpleCommand):
306
340
  except Exception as e:
307
341
  await ui.error(f"Failed to parse tools: {str(e)}")
308
342
  return
309
-
343
+
310
344
  if not found_content:
311
345
  await ui.error("No parseable content found in recent messages")
312
346
 
@@ -326,7 +360,7 @@ class RefreshConfigCommand(SimpleCommand):
326
360
 
327
361
  async def execute(self, args: List[str], context: CommandContext) -> None:
328
362
  from tunacode.configuration.defaults import DEFAULT_USER_CONFIG
329
-
363
+
330
364
  # Update current session config with latest defaults
331
365
  for key, value in DEFAULT_USER_CONFIG.items():
332
366
  if key not in context.state_manager.session.user_config:
@@ -336,9 +370,11 @@ class RefreshConfigCommand(SimpleCommand):
336
370
  for subkey, subvalue in value.items():
337
371
  if subkey not in context.state_manager.session.user_config[key]:
338
372
  context.state_manager.session.user_config[key][subkey] = subvalue
339
-
373
+
340
374
  # Show updated max_iterations
341
- max_iterations = context.state_manager.session.user_config.get("settings", {}).get("max_iterations", 20)
375
+ max_iterations = context.state_manager.session.user_config.get("settings", {}).get(
376
+ "max_iterations", 20
377
+ )
342
378
  await ui.success(f"Configuration refreshed - max iterations: {max_iterations}")
343
379
 
344
380
 
@@ -507,23 +543,20 @@ class UpdateCommand(SimpleCommand):
507
543
  )
508
544
 
509
545
  async def execute(self, args: List[str], context: CommandContext) -> None:
546
+ import shutil
510
547
  import subprocess
511
548
  import sys
512
- import shutil
513
549
 
514
550
  await ui.info("Checking for TunaCode updates...")
515
551
 
516
552
  # Detect installation method
517
553
  installation_method = None
518
-
554
+
519
555
  # Check if installed via pipx
520
556
  if shutil.which("pipx"):
521
557
  try:
522
558
  result = subprocess.run(
523
- ["pipx", "list"],
524
- capture_output=True,
525
- text=True,
526
- timeout=10
559
+ ["pipx", "list"], capture_output=True, text=True, timeout=10
527
560
  )
528
561
  if "tunacode" in result.stdout.lower():
529
562
  installation_method = "pipx"
@@ -534,10 +567,10 @@ class UpdateCommand(SimpleCommand):
534
567
  if not installation_method:
535
568
  try:
536
569
  result = subprocess.run(
537
- [sys.executable, "-m", "pip", "show", "tunacode-cli"],
538
- capture_output=True,
539
- text=True,
540
- timeout=10
570
+ [sys.executable, "-m", "pip", "show", "tunacode-cli"],
571
+ capture_output=True,
572
+ text=True,
573
+ timeout=10,
541
574
  )
542
575
  if result.returncode == 0:
543
576
  installation_method = "pip"
@@ -556,10 +589,7 @@ class UpdateCommand(SimpleCommand):
556
589
  if installation_method == "pipx":
557
590
  await ui.info("Updating via pipx...")
558
591
  result = subprocess.run(
559
- ["pipx", "upgrade", "tunacode"],
560
- capture_output=True,
561
- text=True,
562
- timeout=60
592
+ ["pipx", "upgrade", "tunacode"], capture_output=True, text=True, timeout=60
563
593
  )
564
594
  else: # pip
565
595
  await ui.info("Updating via pip...")
@@ -567,16 +597,16 @@ class UpdateCommand(SimpleCommand):
567
597
  [sys.executable, "-m", "pip", "install", "--upgrade", "tunacode-cli"],
568
598
  capture_output=True,
569
599
  text=True,
570
- timeout=60
600
+ timeout=60,
571
601
  )
572
602
 
573
603
  if result.returncode == 0:
574
604
  await ui.success("TunaCode updated successfully!")
575
605
  await ui.muted("Restart TunaCode to use the new version")
576
-
606
+
577
607
  # Show update output if available
578
608
  if result.stdout.strip():
579
- output_lines = result.stdout.strip().split('\n')
609
+ output_lines = result.stdout.strip().split("\n")
580
610
  for line in output_lines[-5:]: # Show last 5 lines
581
611
  if line.strip():
582
612
  await ui.muted(f" {line}")
@@ -723,6 +753,7 @@ class CommandRegistry:
723
753
  YoloCommand,
724
754
  DumpCommand,
725
755
  ThoughtsCommand,
756
+ ArchitectCommand,
726
757
  IterationsCommand,
727
758
  ClearCommand,
728
759
  FixCommand,
@@ -798,8 +829,9 @@ class CommandRegistry:
798
829
  return await command.execute(args, context)
799
830
  else:
800
831
  # Ambiguous - show possibilities
832
+ matches_str = ", ".join(sorted(set(matches)))
801
833
  raise ValidationError(
802
- f"Ambiguous command '{command_name}'. Did you mean: {', '.join(sorted(set(matches)))}?"
834
+ f"Ambiguous command '{command_name}'. Did you mean: {matches_str}?"
803
835
  )
804
836
 
805
837
  def find_matching_commands(self, partial_command: str) -> List[str]:
tunacode/cli/main.py CHANGED
@@ -24,36 +24,39 @@ state_manager = StateManager()
24
24
  def main(
25
25
  version: bool = typer.Option(False, "--version", "-v", help="Show version and exit."),
26
26
  run_setup: bool = typer.Option(False, "--setup", help="Run setup process."),
27
- baseurl: str = typer.Option(None, "--baseurl", help="API base URL (e.g., https://openrouter.ai/api/v1)"),
27
+ baseurl: str = typer.Option(
28
+ None, "--baseurl", help="API base URL (e.g., https://openrouter.ai/api/v1)"
29
+ ),
28
30
  model: str = typer.Option(None, "--model", help="Default model to use (e.g., openai/gpt-4)"),
29
31
  key: str = typer.Option(None, "--key", help="API key for the provider"),
30
32
  ):
31
33
  """🚀 Start TunaCode - Your AI-powered development assistant"""
32
-
33
- if version:
34
- asyncio.run(ui.version())
35
- return
36
-
37
- asyncio.run(ui.banner())
38
-
39
- has_update, latest_version = check_for_updates()
40
- if has_update:
41
- asyncio.run(ui.show_update_message(latest_version))
42
-
43
- # Pass CLI args to setup
44
- cli_config = {}
45
- if baseurl or model or key:
46
- cli_config = {
47
- "baseurl": baseurl,
48
- "model": model,
49
- "key": key
50
- }
51
-
52
- try:
53
- asyncio.run(setup(run_setup, state_manager, cli_config))
54
- asyncio.run(repl(state_manager))
55
- except Exception as e:
56
- asyncio.run(ui.error(str(e)))
34
+
35
+ async def async_main():
36
+ if version:
37
+ await ui.version()
38
+ return
39
+
40
+ await ui.banner()
41
+
42
+ # Start update check in background
43
+ update_task = asyncio.to_thread(check_for_updates)
44
+
45
+ cli_config = {}
46
+ if baseurl or model or key:
47
+ cli_config = {"baseurl": baseurl, "model": model, "key": key}
48
+
49
+ try:
50
+ await setup(run_setup, state_manager, cli_config)
51
+ await repl(state_manager)
52
+ except Exception as e:
53
+ await ui.error(str(e))
54
+
55
+ has_update, latest_version = await update_task
56
+ if has_update:
57
+ await ui.update_available(latest_version)
58
+
59
+ asyncio.run(async_main())
57
60
 
58
61
 
59
62
  if __name__ == "__main__":
tunacode/cli/repl.py CHANGED
@@ -6,6 +6,8 @@ Handles user input, command processing, and agent interaction in an interactive
6
6
  """
7
7
 
8
8
  import json
9
+ import os
10
+ import subprocess
9
11
  from asyncio.exceptions import CancelledError
10
12
 
11
13
  from prompt_toolkit.application import run_in_terminal
@@ -15,6 +17,7 @@ from pydantic_ai.exceptions import UnexpectedModelBehavior
15
17
  from tunacode.configuration.settings import ApplicationSettings
16
18
  from tunacode.core.agents import main as agent
17
19
  from tunacode.core.agents.main import patch_tool_messages
20
+ from tunacode.core.agents.orchestrator import OrchestratorAgent
18
21
  from tunacode.core.tool_handler import ToolHandler
19
22
  from tunacode.exceptions import AgentError, UserAbortError, ValidationError
20
23
  from tunacode.ui import console as ui
@@ -158,41 +161,69 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
158
161
  True, state_manager.session.spinner, state_manager
159
162
  )
160
163
  try:
161
- # Expand @file references before sending to the agent
162
- try:
163
- from tunacode.utils.text_utils import expand_file_refs
164
-
165
- text = expand_file_refs(text)
166
- except ValueError as e:
167
- await ui.error(str(e))
168
- return
169
-
170
164
  # Patch any orphaned tool calls from previous requests before proceeding
171
165
  patch_tool_messages("Tool execution was interrupted", state_manager)
172
-
166
+
167
+ # Track message start for thoughts display
168
+ start_idx = len(state_manager.session.messages)
169
+
173
170
  # Create a partial function that includes state_manager
174
171
  def tool_callback_with_state(part, node):
175
172
  return _tool_handler(part, node, state_manager)
176
173
 
177
- start_idx = len(state_manager.session.messages)
178
- res = await agent.process_request(
179
- state_manager.session.current_model,
180
- text,
181
- state_manager,
182
- tool_callback=tool_callback_with_state,
183
- )
184
- if output:
185
- if state_manager.session.show_thoughts:
186
- new_msgs = state_manager.session.messages[start_idx:]
187
- for msg in new_msgs:
188
- if isinstance(msg, dict) and "thought" in msg:
189
- await ui.muted(f"THOUGHT: {msg['thought']}")
190
- # Check if result exists and has output
191
- if hasattr(res, 'result') and res.result is not None and hasattr(res.result, 'output'):
192
- await ui.agent(res.result.output)
193
- else:
194
- # Fallback: show that the request was processed
195
- await ui.muted("Request completed")
174
+ # Check if architect mode is enabled
175
+ if getattr(state_manager.session, 'architect_mode', False):
176
+ # Expand @file references before sending to the orchestrator
177
+ try:
178
+ from tunacode.utils.text_utils import expand_file_refs
179
+
180
+ text = expand_file_refs(text)
181
+ except ValueError as e:
182
+ await ui.error(str(e))
183
+ return
184
+ # Use orchestrator for planning and execution
185
+ orchestrator = OrchestratorAgent(state_manager)
186
+ results = await orchestrator.run(text, state_manager.session.current_model)
187
+
188
+ if output:
189
+ # Process results from all sub-agents
190
+ for res in results:
191
+ # Check if result exists and has output
192
+ if hasattr(res, "result") and res.result is not None and hasattr(res.result, "output"):
193
+ await ui.agent(res.result.output)
194
+
195
+ if not results:
196
+ # Fallback: show that the request was processed
197
+ await ui.muted("Request completed")
198
+ else:
199
+ # Expand @file references before sending to the agent
200
+ try:
201
+ from tunacode.utils.text_utils import expand_file_refs
202
+
203
+ text = expand_file_refs(text)
204
+ except ValueError as e:
205
+ await ui.error(str(e))
206
+ return
207
+
208
+ # Use normal agent processing
209
+ res = await agent.process_request(
210
+ state_manager.session.current_model,
211
+ text,
212
+ state_manager,
213
+ tool_callback=tool_callback_with_state,
214
+ )
215
+ if output:
216
+ if state_manager.session.show_thoughts:
217
+ new_msgs = state_manager.session.messages[start_idx:]
218
+ for msg in new_msgs:
219
+ if isinstance(msg, dict) and "thought" in msg:
220
+ await ui.muted(f"THOUGHT: {msg['thought']}")
221
+ # Check if result exists and has output
222
+ if hasattr(res, "result") and res.result is not None and hasattr(res.result, "output"):
223
+ await ui.agent(res.result.output)
224
+ else:
225
+ # Fallback: show that the request was processed
226
+ await ui.muted("Request completed")
196
227
  except CancelledError:
197
228
  await ui.muted("Request cancelled")
198
229
  except UserAbortError:
@@ -204,29 +235,28 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
204
235
  except Exception as e:
205
236
  # Check if this might be a tool calling failure that we can recover from
206
237
  error_str = str(e).lower()
207
- if any(keyword in error_str for keyword in ['tool', 'function', 'call', 'schema']):
238
+ if any(keyword in error_str for keyword in ["tool", "function", "call", "schema"]):
208
239
  # Try to extract and execute tool calls from the last response
209
240
  if state_manager.session.messages:
210
241
  last_msg = state_manager.session.messages[-1]
211
- if hasattr(last_msg, 'parts'):
242
+ if hasattr(last_msg, "parts"):
212
243
  for part in last_msg.parts:
213
- if hasattr(part, 'content') and isinstance(part.content, str):
244
+ if hasattr(part, "content") and isinstance(part.content, str):
214
245
  from tunacode.core.agents.main import extract_and_execute_tool_calls
246
+
215
247
  try:
216
248
  # Create a partial function that includes state_manager
217
249
  def tool_callback_with_state(part, node):
218
250
  return _tool_handler(part, node, state_manager)
219
-
251
+
220
252
  await extract_and_execute_tool_calls(
221
- part.content,
222
- tool_callback_with_state,
223
- state_manager
253
+ part.content, tool_callback_with_state, state_manager
224
254
  )
225
255
  await ui.warning("🔧 Recovered using JSON tool parsing")
226
256
  return # Successfully recovered
227
257
  except Exception:
228
258
  pass # Fallback failed, continue with normal error handling
229
-
259
+
230
260
  # Wrap unexpected exceptions in AgentError for better tracking
231
261
  agent_error = AgentError(f"Agent processing failed: {str(e)}")
232
262
  agent_error.__cause__ = e # Preserve the original exception chain
@@ -271,6 +301,30 @@ async def repl(state_manager: StateManager):
271
301
  break
272
302
  continue
273
303
 
304
+ if line.startswith("!"):
305
+ command = line[1:].strip()
306
+
307
+ # Show tool-style header for bash commands
308
+ cmd_display = command if command else "Interactive shell"
309
+ await ui.panel("Tool(bash)", f"Command: {cmd_display}", border_style="yellow")
310
+
311
+ def run_shell():
312
+ try:
313
+ if command:
314
+ result = subprocess.run(command, shell=True, capture_output=False)
315
+ if result.returncode != 0:
316
+ # Use print directly since we're in a terminal context
317
+ print(f"\nCommand exited with code {result.returncode}")
318
+ else:
319
+ shell = os.environ.get("SHELL", "bash")
320
+ subprocess.run(shell)
321
+ except Exception as e:
322
+ print(f"\nShell command failed: {str(e)}")
323
+
324
+ await run_in_terminal(run_shell)
325
+ await ui.line()
326
+ continue
327
+
274
328
  # Check if another task is already running
275
329
  if state_manager.session.current_task and not state_manager.session.current_task.done():
276
330
  await ui.muted("Agent is busy, press Ctrl+C to interrupt.")