tunacode-cli 0.0.55__py3-none-any.whl → 0.0.78.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

Files changed (114) hide show
  1. tunacode/cli/commands/__init__.py +2 -2
  2. tunacode/cli/commands/implementations/__init__.py +2 -3
  3. tunacode/cli/commands/implementations/command_reload.py +48 -0
  4. tunacode/cli/commands/implementations/debug.py +2 -2
  5. tunacode/cli/commands/implementations/development.py +10 -8
  6. tunacode/cli/commands/implementations/model.py +357 -29
  7. tunacode/cli/commands/implementations/quickstart.py +43 -0
  8. tunacode/cli/commands/implementations/system.py +96 -3
  9. tunacode/cli/commands/implementations/template.py +0 -2
  10. tunacode/cli/commands/registry.py +139 -5
  11. tunacode/cli/commands/slash/__init__.py +32 -0
  12. tunacode/cli/commands/slash/command.py +157 -0
  13. tunacode/cli/commands/slash/loader.py +135 -0
  14. tunacode/cli/commands/slash/processor.py +294 -0
  15. tunacode/cli/commands/slash/types.py +93 -0
  16. tunacode/cli/commands/slash/validator.py +400 -0
  17. tunacode/cli/main.py +23 -2
  18. tunacode/cli/repl.py +217 -190
  19. tunacode/cli/repl_components/command_parser.py +38 -4
  20. tunacode/cli/repl_components/error_recovery.py +85 -4
  21. tunacode/cli/repl_components/output_display.py +12 -1
  22. tunacode/cli/repl_components/tool_executor.py +1 -1
  23. tunacode/configuration/defaults.py +12 -3
  24. tunacode/configuration/key_descriptions.py +284 -0
  25. tunacode/configuration/settings.py +0 -1
  26. tunacode/constants.py +12 -40
  27. tunacode/core/agents/__init__.py +43 -2
  28. tunacode/core/agents/agent_components/__init__.py +7 -0
  29. tunacode/core/agents/agent_components/agent_config.py +249 -55
  30. tunacode/core/agents/agent_components/agent_helpers.py +43 -13
  31. tunacode/core/agents/agent_components/node_processor.py +179 -139
  32. tunacode/core/agents/agent_components/response_state.py +123 -6
  33. tunacode/core/agents/agent_components/state_transition.py +116 -0
  34. tunacode/core/agents/agent_components/streaming.py +296 -0
  35. tunacode/core/agents/agent_components/task_completion.py +19 -6
  36. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  37. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  38. tunacode/core/agents/main.py +522 -370
  39. tunacode/core/agents/main_legact.py +538 -0
  40. tunacode/core/agents/prompts.py +66 -0
  41. tunacode/core/agents/utils.py +29 -121
  42. tunacode/core/code_index.py +83 -29
  43. tunacode/core/setup/__init__.py +0 -2
  44. tunacode/core/setup/config_setup.py +110 -20
  45. tunacode/core/setup/config_wizard.py +230 -0
  46. tunacode/core/setup/coordinator.py +14 -5
  47. tunacode/core/state.py +16 -20
  48. tunacode/core/token_usage/usage_tracker.py +5 -3
  49. tunacode/core/tool_authorization.py +352 -0
  50. tunacode/core/tool_handler.py +67 -40
  51. tunacode/exceptions.py +119 -5
  52. tunacode/prompts/system.xml +751 -0
  53. tunacode/services/mcp.py +125 -7
  54. tunacode/setup.py +5 -25
  55. tunacode/tools/base.py +163 -0
  56. tunacode/tools/bash.py +110 -1
  57. tunacode/tools/glob.py +332 -34
  58. tunacode/tools/grep.py +179 -82
  59. tunacode/tools/grep_components/result_formatter.py +98 -4
  60. tunacode/tools/list_dir.py +132 -2
  61. tunacode/tools/prompts/bash_prompt.xml +72 -0
  62. tunacode/tools/prompts/glob_prompt.xml +45 -0
  63. tunacode/tools/prompts/grep_prompt.xml +98 -0
  64. tunacode/tools/prompts/list_dir_prompt.xml +31 -0
  65. tunacode/tools/prompts/react_prompt.xml +23 -0
  66. tunacode/tools/prompts/read_file_prompt.xml +54 -0
  67. tunacode/tools/prompts/run_command_prompt.xml +64 -0
  68. tunacode/tools/prompts/update_file_prompt.xml +53 -0
  69. tunacode/tools/prompts/write_file_prompt.xml +37 -0
  70. tunacode/tools/react.py +153 -0
  71. tunacode/tools/read_file.py +91 -0
  72. tunacode/tools/run_command.py +114 -0
  73. tunacode/tools/schema_assembler.py +167 -0
  74. tunacode/tools/update_file.py +94 -0
  75. tunacode/tools/write_file.py +86 -0
  76. tunacode/tools/xml_helper.py +83 -0
  77. tunacode/tutorial/__init__.py +9 -0
  78. tunacode/tutorial/content.py +98 -0
  79. tunacode/tutorial/manager.py +182 -0
  80. tunacode/tutorial/steps.py +124 -0
  81. tunacode/types.py +20 -27
  82. tunacode/ui/completers.py +434 -50
  83. tunacode/ui/config_dashboard.py +585 -0
  84. tunacode/ui/console.py +63 -11
  85. tunacode/ui/input.py +20 -3
  86. tunacode/ui/keybindings.py +7 -4
  87. tunacode/ui/model_selector.py +395 -0
  88. tunacode/ui/output.py +40 -19
  89. tunacode/ui/panels.py +212 -43
  90. tunacode/ui/path_heuristics.py +91 -0
  91. tunacode/ui/prompt_manager.py +5 -1
  92. tunacode/ui/tool_ui.py +33 -10
  93. tunacode/utils/api_key_validation.py +93 -0
  94. tunacode/utils/config_comparator.py +340 -0
  95. tunacode/utils/json_utils.py +206 -0
  96. tunacode/utils/message_utils.py +14 -4
  97. tunacode/utils/models_registry.py +593 -0
  98. tunacode/utils/ripgrep.py +332 -9
  99. tunacode/utils/text_utils.py +18 -1
  100. tunacode/utils/user_configuration.py +45 -0
  101. tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
  102. tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
  103. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
  104. tunacode/cli/commands/implementations/todo.py +0 -217
  105. tunacode/context.py +0 -71
  106. tunacode/core/setup/git_safety_setup.py +0 -182
  107. tunacode/prompts/system.md +0 -731
  108. tunacode/tools/read_file_async_poc.py +0 -196
  109. tunacode/tools/todo.py +0 -349
  110. tunacode_cli-0.0.55.dist-info/METADATA +0 -322
  111. tunacode_cli-0.0.55.dist-info/RECORD +0 -126
  112. tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
  113. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  114. {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/licenses/LICENSE +0 -0
tunacode/cli/repl.py CHANGED
@@ -1,16 +1,6 @@
1
- """
2
- Module: tunacode.cli.repl
3
-
4
- Interactive REPL (Read-Eval-Print Loop) implementation for TunaCode.
5
- Handles user input, command processing, and agent interaction in an interactive shell.
6
-
7
- CLAUDE_ANCHOR[repl-module]: Core REPL loop and user interaction handling
8
- """
9
-
10
- # ============================================================================
11
- # IMPORTS AND DEPENDENCIES
12
- # ============================================================================
1
+ """Interactive REPL implementation for TunaCode."""
13
2
 
3
+ import asyncio
14
4
  import logging
15
5
  import os
16
6
  import subprocess
@@ -21,20 +11,20 @@ from prompt_toolkit.application import run_in_terminal
21
11
  from prompt_toolkit.application.current import get_app
22
12
  from pydantic_ai.exceptions import UnexpectedModelBehavior
23
13
 
14
+ from tunacode.configuration.models import ModelRegistry
24
15
  from tunacode.constants import DEFAULT_CONTEXT_WINDOW
25
- from tunacode.core.agents import main as agent
26
- from tunacode.core.agents.main import patch_tool_messages
27
- from tunacode.exceptions import AgentError, UserAbortError, ValidationError
16
+ from tunacode.core import agents as agent
17
+ from tunacode.core.agents import patch_tool_messages
18
+ from tunacode.core.token_usage.api_response_parser import ApiResponseParser
19
+ from tunacode.core.token_usage.cost_calculator import CostCalculator
20
+ from tunacode.core.token_usage.usage_tracker import UsageTracker
21
+ from tunacode.exceptions import UserAbortError, ValidationError
28
22
  from tunacode.ui import console as ui
29
23
  from tunacode.ui.output import get_context_window_display
30
24
  from tunacode.utils.security import CommandSecurityError, safe_subprocess_run
31
25
 
32
26
  from ..types import CommandContext, CommandResult, StateManager
33
27
  from .commands import CommandRegistry
34
-
35
- # ============================================================================
36
- # MODULE-LEVEL CONSTANTS AND CONFIGURATION
37
- # ============================================================================
38
28
  from .repl_components import attempt_tool_recovery, display_agent_output, tool_handler
39
29
  from .repl_components.output_display import MSG_REQUEST_COMPLETED
40
30
 
@@ -50,74 +40,81 @@ DEFAULT_SHELL = "bash"
50
40
  # Configure logging
51
41
  logger = logging.getLogger(__name__)
52
42
 
53
- # The _parse_args function has been moved to repl_components.command_parser
54
- # The _tool_handler function has been moved to repl_components.tool_executor
55
-
56
-
57
- # ============================================================================
58
- # COMMAND SYSTEM
59
- # ============================================================================
60
43
 
61
44
  _command_registry = CommandRegistry()
62
45
  _command_registry.register_all_default_commands()
63
46
 
64
47
 
65
48
  async def _handle_command(command: str, state_manager: StateManager) -> CommandResult:
66
- """
67
- Handles a command string using the command registry.
68
-
69
- Args:
70
- command: The command string entered by the user.
71
- state_manager: The state manager instance.
72
-
73
- Returns:
74
- Command result (varies by command).
75
- """
76
- context = CommandContext(state_manager=state_manager, process_request=process_request)
77
-
49
+ """Handles a command string using the command registry."""
50
+ context = CommandContext(state_manager=state_manager, process_request=execute_repl_request)
78
51
  try:
79
- _command_registry.set_process_request_callback(process_request)
80
-
52
+ _command_registry.set_process_request_callback(execute_repl_request)
81
53
  return await _command_registry.execute(command, context)
82
54
  except ValidationError as e:
83
55
  await ui.error(str(e))
84
56
  return None
85
57
 
86
58
 
87
- # The _attempt_tool_recovery function has been moved to repl_components.error_recovery
59
+ def _extract_feedback_from_last_message(state_manager: StateManager) -> str | None:
60
+ """Extract user guidance feedback from recent messages in session.messages.
61
+
62
+ When option 3 is selected with feedback, a message is added with format:
63
+ "Tool '...' execution cancelled before running.\nUser guidance:\n{guidance}\n..."
88
64
 
65
+ Note: patch_tool_messages() adds "Operation aborted by user." AFTER the feedback,
66
+ so we check the last few messages, not just the last one.
89
67
 
90
- # The _display_agent_output function has been moved to repl_components.output_display
68
+ Args:
69
+ state_manager: State manager containing session messages
91
70
 
71
+ Returns:
72
+ The guidance text if found, None otherwise
73
+ """
74
+ if not state_manager.session.messages:
75
+ return None
92
76
 
93
- # ============================================================================
94
- # MAIN AGENT REQUEST PROCESSING
95
- # ============================================================================
77
+ # Check last 3 messages since patch_tool_messages() adds a message after feedback
78
+ messages_to_check = state_manager.session.messages[-3:]
96
79
 
80
+ for msg in reversed(messages_to_check):
81
+ # Extract content from message parts
82
+ if not hasattr(msg, "parts"):
83
+ continue
97
84
 
98
- async def process_request(text: str, state_manager: StateManager, output: bool = True):
99
- """Process input using the agent, handling cancellation safely.
85
+ for part in msg.parts:
86
+ if hasattr(part, "content") and isinstance(part.content, str):
87
+ content = part.content
100
88
 
101
- CLAUDE_ANCHOR[process-request-repl]: REPL's main request processor with error handling
102
- """
89
+ # Look for "User guidance:" pattern
90
+ if "User guidance:" in content:
91
+ lines = content.split("\n")
92
+ for i, line in enumerate(lines):
93
+ if "User guidance:" in line and i + 1 < len(lines):
94
+ guidance = lines[i + 1].strip()
95
+ # Only return non-empty guidance
96
+ cancelled_msg = "User cancelled without additional instructions."
97
+ if guidance and guidance != cancelled_msg:
98
+ return guidance
99
+
100
+ return None
101
+
102
+
103
+ async def execute_repl_request(text: str, state_manager: StateManager, output: bool = True):
104
+ """Process input using the agent, handling cancellation safely."""
103
105
  import uuid
104
106
 
105
- # Generate a unique ID for this request for correlated logging
106
- request_id = str(uuid.uuid4())
107
- logger.debug(
108
- "Processing new request", extra={"request_id": request_id, "input_text": text[:100]}
109
- )
110
- state_manager.session.request_id = request_id
107
+ from tunacode.utils.text_utils import expand_file_refs
108
+
109
+ state_manager.session.request_id = str(uuid.uuid4())
111
110
 
112
- # Check for cancellation before starting (only if explicitly set to True)
113
- operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
114
- if operation_cancelled is True:
115
- logger.debug("Operation cancelled before processing started")
111
+ if getattr(state_manager.session, "operation_cancelled", False) is True:
116
112
  raise CancelledError("Operation was cancelled")
117
113
 
118
114
  state_manager.session.spinner = await ui.spinner(
119
115
  True, state_manager.session.spinner, state_manager
120
116
  )
117
+
121
118
  try:
122
119
  patch_tool_messages(MSG_TOOL_INTERRUPTED, state_manager)
123
120
 
@@ -128,147 +125,191 @@ async def process_request(text: str, state_manager: StateManager, output: bool =
128
125
 
129
126
  start_idx = len(state_manager.session.messages)
130
127
 
131
- def tool_callback_with_state(part, _node):
128
+ def tool_callback_with_state(part, _):
132
129
  return tool_handler(part, state_manager)
133
130
 
134
131
  try:
135
- from tunacode.utils.text_utils import expand_file_refs
136
-
137
132
  text, referenced_files = expand_file_refs(text)
138
- for file_path in referenced_files:
139
- state_manager.session.files_in_context.add(file_path)
133
+ state_manager.session.files_in_context.update(referenced_files)
140
134
  except ValueError as e:
141
135
  await ui.error(str(e))
142
136
  return
143
137
 
144
- # Check for cancellation before proceeding with agent call (only if explicitly set to True)
145
- operation_cancelled = getattr(state_manager.session, "operation_cancelled", False)
146
- if operation_cancelled is True:
147
- logger.debug("Operation cancelled before agent processing")
138
+ if getattr(state_manager.session, "operation_cancelled", False) is True:
148
139
  raise CancelledError("Operation was cancelled")
149
140
 
150
141
  enable_streaming = state_manager.session.user_config.get("settings", {}).get(
151
142
  "enable_streaming", True
152
143
  )
153
144
 
145
+ # Create UsageTracker to ensure session cost tracking
146
+ model_registry = ModelRegistry()
147
+ parser = ApiResponseParser()
148
+ calculator = CostCalculator(model_registry)
149
+ usage_tracker = UsageTracker(parser, calculator, state_manager)
150
+
154
151
  if enable_streaming:
155
152
  await ui.spinner(False, state_manager.session.spinner, state_manager)
156
-
157
153
  state_manager.session.is_streaming_active = True
158
-
159
- streaming_panel = ui.StreamingAgentPanel()
154
+ streaming_panel = ui.StreamingAgentPanel(
155
+ debug=bool(state_manager.session.show_thoughts)
156
+ )
160
157
  await streaming_panel.start()
161
-
162
158
  state_manager.session.streaming_panel = streaming_panel
163
159
 
164
160
  try:
165
-
166
- async def streaming_callback(content: str):
167
- await streaming_panel.update(content)
168
-
169
161
  res = await agent.process_request(
170
162
  text,
171
163
  state_manager.session.current_model,
172
164
  state_manager,
173
165
  tool_callback=tool_callback_with_state,
174
- streaming_callback=streaming_callback,
166
+ streaming_callback=lambda content: streaming_panel.update(content),
167
+ usage_tracker=usage_tracker,
175
168
  )
176
169
  finally:
177
170
  await streaming_panel.stop()
178
171
  state_manager.session.streaming_panel = None
179
172
  state_manager.session.is_streaming_active = False
173
+ # Emit source-side streaming diagnostics if thoughts are enabled
174
+ if state_manager.session.show_thoughts:
175
+ try:
176
+ raw = getattr(state_manager.session, "_debug_raw_stream_accum", "") or ""
177
+ events = getattr(state_manager.session, "_debug_events", []) or []
178
+ raw_first5 = repr(raw[:5])
179
+ await ui.muted(
180
+ f"[debug] raw_stream_first5={raw_first5} total_len={len(raw)}"
181
+ )
182
+ for line in events:
183
+ await ui.muted(line)
184
+ except Exception:
185
+ # Don't let diagnostics break normal flow
186
+ pass
180
187
  else:
181
- # Use normal agent processing
182
188
  res = await agent.process_request(
183
189
  text,
184
190
  state_manager.session.current_model,
185
191
  state_manager,
186
192
  tool_callback=tool_callback_with_state,
193
+ usage_tracker=usage_tracker,
187
194
  )
188
195
 
189
196
  if output:
190
197
  if state_manager.session.show_thoughts:
191
- new_msgs = state_manager.session.messages[start_idx:]
192
- for msg in new_msgs:
198
+ for msg in state_manager.session.messages[start_idx:]:
193
199
  if isinstance(msg, dict) and "thought" in msg:
194
200
  await ui.muted(f"THOUGHT: {msg['thought']}")
195
-
196
- # Only display result if not streaming (streaming already showed content)
197
- if enable_streaming:
198
- pass # Guard: streaming already showed content
199
- elif (
200
- not hasattr(res, "result")
201
- or res.result is None
202
- or not hasattr(res.result, "output")
203
- ):
204
- # Fallback: show that the request was processed
205
- await ui.muted(MSG_REQUEST_COMPLETED)
206
- else:
207
- # Use the dedicated function for displaying agent output
208
- await display_agent_output(res, enable_streaming)
209
-
210
- # Always show files in context after agent response
201
+ if not enable_streaming:
202
+ if (
203
+ not hasattr(res, "result")
204
+ or res.result is None
205
+ or not hasattr(res.result, "output")
206
+ ):
207
+ await ui.muted(MSG_REQUEST_COMPLETED)
208
+ else:
209
+ await display_agent_output(res, enable_streaming, state_manager)
211
210
  if state_manager.session.files_in_context:
212
211
  filenames = [Path(f).name for f in sorted(state_manager.session.files_in_context)]
213
212
  await ui.muted(f"Files in context: {', '.join(filenames)}")
214
213
 
215
- # --- ERROR HANDLING ---
216
214
  except CancelledError:
217
215
  await ui.muted(MSG_REQUEST_CANCELLED)
218
216
  except UserAbortError:
219
- await ui.muted(MSG_OPERATION_ABORTED)
217
+ # CLAUDE_ANCHOR[7b2c1d4e]: Guided aborts inject user instructions; skip legacy banner.
218
+ # Check if there's feedback to process immediately
219
+ feedback = _extract_feedback_from_last_message(state_manager)
220
+ if feedback:
221
+ # Process the feedback as a new request immediately
222
+ # Stop spinner first to clean up state before recursive call
223
+ await ui.spinner(False, state_manager.session.spinner, state_manager)
224
+ # Clear current_task so recursive call can set its own
225
+ state_manager.session.current_task = None
226
+ try:
227
+ await execute_repl_request(feedback, state_manager, output=output)
228
+ except Exception:
229
+ # If recursive call fails, don't let it bubble up - just continue
230
+ pass
231
+ # Return early to skip the finally block's cleanup (already done above)
232
+ return
233
+ # No feedback, just abort normally
234
+ pass
220
235
  except UnexpectedModelBehavior as e:
221
- error_message = str(e)
222
- await ui.muted(error_message)
223
- patch_tool_messages(error_message, state_manager)
236
+ await ui.muted(str(e))
237
+ patch_tool_messages(str(e), state_manager)
224
238
  except Exception as e:
225
- # Try tool recovery for tool-related errors
226
- if await attempt_tool_recovery(e, state_manager):
227
- return # Successfully recovered
228
-
229
- agent_error = AgentError(f"Agent processing failed: {str(e)}")
230
- agent_error.__cause__ = e # Preserve the original exception chain
231
- await ui.error(str(e))
239
+ if not await attempt_tool_recovery(e, state_manager):
240
+ await ui.error(str(e))
232
241
  finally:
233
242
  await ui.spinner(False, state_manager.session.spinner, state_manager)
234
243
  state_manager.session.current_task = None
235
- # Reset cancellation flag when task completes (if attribute exists)
236
244
  if hasattr(state_manager.session, "operation_cancelled"):
237
245
  state_manager.session.operation_cancelled = False
238
-
239
246
  if "multiline" in state_manager.session.input_sessions:
240
247
  await run_in_terminal(
241
248
  lambda: state_manager.session.input_sessions["multiline"].app.invalidate()
242
249
  )
243
250
 
244
251
 
245
- # ============================================================================
246
- # MAIN REPL LOOP
247
- # ============================================================================
252
+ # Backwards compatibility: exported name expected by external integrations/tests
253
+ process_request = execute_repl_request
254
+
255
+
256
+ async def warm_code_index():
257
+ """Pre-warm the code index in background for faster directory operations."""
258
+ try:
259
+ from tunacode.core.code_index import CodeIndex
260
+
261
+ # Build index in thread to avoid blocking
262
+ index = await asyncio.to_thread(lambda: CodeIndex.get_instance())
263
+ await asyncio.to_thread(index.build_index)
264
+
265
+ logger.debug(f"Code index pre-warmed with {len(index._all_files)} files")
266
+ except Exception as e:
267
+ logger.debug(f"Failed to pre-warm code index: {e}")
248
268
 
249
269
 
250
270
  async def repl(state_manager: StateManager):
251
271
  """Main REPL loop that handles user interaction and input processing."""
272
+ import time
273
+
274
+ # Start pre-warming code index in background (non-blocking)
275
+ asyncio.create_task(warm_code_index())
276
+
252
277
  action = None
253
278
  abort_pressed = False
254
279
  last_abort_time = 0.0
255
280
 
256
- model_name = state_manager.session.current_model
257
281
  max_tokens = (
258
282
  state_manager.session.user_config.get("context_window_size") or DEFAULT_CONTEXT_WINDOW
259
283
  )
260
284
  state_manager.session.max_tokens = max_tokens
261
-
262
285
  state_manager.session.update_token_count()
263
- context_display = get_context_window_display(state_manager.session.total_tokens, max_tokens)
264
286
 
265
- # Only show startup info if thoughts are enabled or on first run
266
- if state_manager.session.show_thoughts or not hasattr(state_manager.session, "_startup_shown"):
267
- await ui.muted(f"• Model: {model_name} • {context_display}")
287
+ async def show_context():
288
+ context = get_context_window_display(state_manager.session.total_tokens, max_tokens)
289
+
290
+ # Get session cost for display
291
+ session_cost = 0.0
292
+ if state_manager.session.session_total_usage:
293
+ session_cost = float(state_manager.session.session_total_usage.get("cost", 0.0) or 0.0)
294
+
295
+ # Subtle, unified styling - mostly muted with minimal accent on cost
296
+ await ui.muted(f"• Model: {state_manager.session.current_model} • {context}")
297
+ if session_cost > 0:
298
+ await ui.print(
299
+ f"[dim]• Session Cost:[/dim] [dim #00d7ff]${session_cost:.4f}[/dim #00d7ff]"
300
+ )
301
+
302
+ # Always show context
303
+ await show_context()
304
+
305
+ # Show startup message only once
306
+ if not hasattr(state_manager.session, "_startup_shown"):
268
307
  await ui.success("Ready to assist")
269
- await ui.line()
270
308
  state_manager.session._startup_shown = True
271
309
 
310
+ # Offer tutorial to first-time users
311
+ await _offer_tutorial_if_appropriate(state_manager)
312
+
272
313
  instance = agent.get_or_create_agent(state_manager.session.current_model, state_manager)
273
314
 
274
315
  async with instance.run_mcp_servers():
@@ -276,17 +317,11 @@ async def repl(state_manager: StateManager):
276
317
  try:
277
318
  line = await ui.multiline_input(state_manager, _command_registry)
278
319
  except UserAbortError:
279
- import time
280
-
281
320
  current_time = time.time()
282
-
283
- # Reset if more than 3 seconds have passed
284
321
  if current_time - last_abort_time > 3.0:
285
322
  abort_pressed = False
286
-
287
323
  if abort_pressed:
288
324
  break
289
-
290
325
  abort_pressed = True
291
326
  last_abort_time = current_time
292
327
  await ui.warning(MSG_HIT_ABORT_KEY)
@@ -294,7 +329,6 @@ async def repl(state_manager: StateManager):
294
329
 
295
330
  if not line:
296
331
  continue
297
-
298
332
  abort_pressed = False
299
333
 
300
334
  if line.lower() in ["exit", "quit"]:
@@ -305,96 +339,89 @@ async def repl(state_manager: StateManager):
305
339
  if action == "restart":
306
340
  break
307
341
  elif isinstance(action, str) and action:
308
- # If the command returned a string (e.g., from template shortcut),
309
- # process it as a prompt
310
342
  line = action
311
- # Fall through to process as normal text
312
343
  else:
313
344
  continue
314
345
 
315
346
  if line.startswith("!"):
316
347
  command = line[1:].strip()
317
-
318
- cmd_display = command if command else "Interactive shell"
319
- await ui.panel("Tool(bash)", f"Command: {cmd_display}", border_style="yellow")
348
+ await ui.panel(
349
+ "Tool(bash)",
350
+ f"Command: {command or 'Interactive shell'}",
351
+ border_style="yellow",
352
+ )
320
353
 
321
354
  def run_shell():
322
355
  try:
323
356
  if command:
324
- try:
325
- result = safe_subprocess_run(
326
- command,
327
- shell=True,
328
- validate=True, # Still validate for basic safety
329
- capture_output=False,
330
- )
331
- if result.returncode != 0:
332
- ui.console.print(
333
- f"\nCommand exited with code {result.returncode}"
334
- )
335
- except CommandSecurityError as e:
336
- ui.console.print(f"\nSecurity validation failed: {str(e)}")
337
- ui.console.print(
338
- "If you need to run this command, please ensure it's safe."
339
- )
357
+ result = safe_subprocess_run(
358
+ command, shell=True, validate=True, capture_output=False
359
+ )
360
+ if result.returncode != 0:
361
+ ui.console.print(f"\nCommand exited with code {result.returncode}")
340
362
  else:
341
- shell = os.environ.get(SHELL_ENV_VAR, DEFAULT_SHELL)
342
- subprocess.run(shell) # Interactive shell is safe
363
+ subprocess.run(os.environ.get(SHELL_ENV_VAR, DEFAULT_SHELL))
364
+ except CommandSecurityError as e:
365
+ ui.console.print(f"\nSecurity validation failed: {str(e)}")
343
366
  except Exception as e:
344
367
  ui.console.print(f"\nShell command failed: {str(e)}")
345
368
 
346
369
  await run_in_terminal(run_shell)
347
- await ui.line()
348
370
  continue
349
371
 
350
- # --- AGENT REQUEST PROCESSING ---
351
372
  if state_manager.session.current_task and not state_manager.session.current_task.done():
352
373
  await ui.muted(MSG_AGENT_BUSY)
353
374
  continue
354
375
 
355
- # Reset cancellation flag for new operations (if attribute exists)
356
376
  if hasattr(state_manager.session, "operation_cancelled"):
357
377
  state_manager.session.operation_cancelled = False
358
378
 
359
379
  state_manager.session.current_task = get_app().create_background_task(
360
- process_request(line, state_manager)
380
+ execute_repl_request(line, state_manager)
361
381
  )
362
382
  await state_manager.session.current_task
363
383
 
364
384
  state_manager.session.update_token_count()
365
- context_display = get_context_window_display(
366
- state_manager.session.total_tokens, state_manager.session.max_tokens
367
- )
368
- # Only show model/context info if thoughts are enabled
369
- if state_manager.session.show_thoughts:
370
- await ui.muted(
371
- f"• Model: {state_manager.session.current_model} • {context_display}"
372
- )
373
-
374
- if action == "restart":
375
- await repl(state_manager)
376
- else:
377
- # Show session cost summary if available
378
- session_total = state_manager.session.session_total_usage
379
- if session_total:
380
- try:
381
- prompt = int(session_total.get("prompt_tokens", 0) or 0)
382
- completion = int(session_total.get("completion_tokens", 0) or 0)
383
- total_tokens = prompt + completion
384
- total_cost = float(session_total.get("cost", 0) or 0)
385
-
386
- # Only show summary if we have actual token usage
387
- if state_manager.session.show_thoughts and (total_tokens > 0 or total_cost > 0):
388
- summary = (
389
- f"\n[bold cyan]TunaCode Session Summary[/bold cyan]\n"
390
- f" - Total Tokens: {total_tokens:,}\n"
391
- f" - Prompt Tokens: {prompt:,}\n"
392
- f" - Completion Tokens: {completion:,}\n"
393
- f" - [bold green]Total Session Cost: ${total_cost:.4f}[/bold green]"
394
- )
395
- ui.console.print(summary)
396
- except (TypeError, ValueError) as e:
397
- # Skip displaying summary if values can't be converted to numbers
398
- logger.debug(f"Failed to display token usage summary: {e}")
385
+ await show_context()
399
386
 
400
- await ui.info(MSG_SESSION_ENDED)
387
+ if action == "restart":
388
+ await repl(state_manager)
389
+ else:
390
+ session_total = state_manager.session.session_total_usage
391
+ if session_total:
392
+ try:
393
+ total_tokens = int(session_total.get("prompt_tokens", 0) or 0) + int(
394
+ session_total.get("completion_tokens", 0) or 0
395
+ )
396
+ total_cost = float(session_total.get("cost", 0) or 0)
397
+ if total_tokens > 0 or total_cost > 0:
398
+ ui.console.print(
399
+ f"\n[bold cyan]TunaCode Session Summary[/bold cyan]\n"
400
+ f" - Total Tokens: {total_tokens:,}\n"
401
+ f" - Total Cost: ${total_cost:.4f}"
402
+ )
403
+ except (TypeError, ValueError):
404
+ pass
405
+ await ui.info(MSG_SESSION_ENDED)
406
+
407
+
408
+ async def _offer_tutorial_if_appropriate(state_manager: StateManager) -> None:
409
+ """Offer tutorial to first-time users if appropriate."""
410
+ try:
411
+ from tunacode.tutorial import TutorialManager
412
+
413
+ tutorial_manager = TutorialManager(state_manager)
414
+
415
+ # Check if we should offer tutorial
416
+ if await tutorial_manager.should_offer_tutorial():
417
+ # Offer tutorial to user
418
+ accepted = await tutorial_manager.offer_tutorial()
419
+ if accepted:
420
+ # Run tutorial
421
+ await tutorial_manager.run_tutorial()
422
+ except ImportError:
423
+ # Tutorial system not available, silently continue
424
+ pass
425
+ except Exception as e:
426
+ # Don't let tutorial errors crash the REPL
427
+ logger.warning(f"Tutorial offer failed: {e}")
@@ -5,14 +5,24 @@ Command parsing utilities for the REPL.
5
5
  """
6
6
 
7
7
  import json
8
+ import logging
8
9
 
10
+ from tunacode.constants import (
11
+ JSON_PARSE_BASE_DELAY,
12
+ JSON_PARSE_MAX_DELAY,
13
+ JSON_PARSE_MAX_RETRIES,
14
+ )
9
15
  from tunacode.exceptions import ValidationError
10
16
  from tunacode.types import ToolArgs
17
+ from tunacode.utils.json_utils import safe_json_parse
18
+ from tunacode.utils.retry import retry_json_parse
19
+
20
+ logger = logging.getLogger(__name__)
11
21
 
12
22
 
13
23
  def parse_args(args) -> ToolArgs:
14
24
  """
15
- Parse tool arguments from a JSON string or dictionary.
25
+ Parse tool arguments from a JSON string or dictionary with retry logic.
16
26
 
17
27
  Args:
18
28
  args (str or dict): A JSON-formatted string or a dictionary containing tool arguments.
@@ -21,12 +31,36 @@ def parse_args(args) -> ToolArgs:
21
31
  dict: The parsed arguments.
22
32
 
23
33
  Raises:
24
- ValueError: If 'args' is not a string or dictionary, or if the string is not valid JSON.
34
+ ValidationError: If 'args' is not a string or dictionary, or if the string
35
+ is not valid JSON.
25
36
  """
26
37
  if isinstance(args, str):
27
38
  try:
28
- return json.loads(args)
29
- except json.JSONDecodeError:
39
+ # First attempt: Use retry logic for transient failures
40
+ return retry_json_parse(
41
+ args,
42
+ max_retries=JSON_PARSE_MAX_RETRIES,
43
+ base_delay=JSON_PARSE_BASE_DELAY,
44
+ max_delay=JSON_PARSE_MAX_DELAY,
45
+ )
46
+ except json.JSONDecodeError as e:
47
+ # Check if this is an "Extra data" error (concatenated JSON objects)
48
+ if "Extra data" in str(e):
49
+ logger.warning(f"Detected concatenated JSON objects in args: {args[:200]}...")
50
+ try:
51
+ # Use the new safe JSON parser with concatenation support
52
+ result = safe_json_parse(args, allow_concatenated=True)
53
+ if isinstance(result, dict):
54
+ return result
55
+ elif isinstance(result, list) and result:
56
+ # Multiple objects - return first one
57
+ logger.warning("Multiple JSON objects detected, using first object only")
58
+ return result[0]
59
+ except Exception:
60
+ # If safe parsing also fails, fall through to original error
61
+ pass
62
+
63
+ # Original error - no recovery possible
30
64
  raise ValidationError(f"Invalid JSON: {args}")
31
65
  elif isinstance(args, dict):
32
66
  return args