tunacode-cli 0.0.54__py3-none-any.whl → 0.0.56__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 (35) hide show
  1. tunacode/cli/commands/__init__.py +2 -0
  2. tunacode/cli/commands/implementations/plan.py +50 -0
  3. tunacode/cli/commands/registry.py +7 -1
  4. tunacode/cli/repl.py +358 -8
  5. tunacode/cli/repl_components/output_display.py +18 -1
  6. tunacode/cli/repl_components/tool_executor.py +15 -4
  7. tunacode/constants.py +4 -2
  8. tunacode/core/agents/agent_components/__init__.py +20 -0
  9. tunacode/core/agents/agent_components/agent_config.py +134 -7
  10. tunacode/core/agents/agent_components/agent_helpers.py +219 -0
  11. tunacode/core/agents/agent_components/node_processor.py +82 -115
  12. tunacode/core/agents/agent_components/truncation_checker.py +81 -0
  13. tunacode/core/agents/main.py +86 -312
  14. tunacode/core/state.py +51 -3
  15. tunacode/core/tool_handler.py +20 -0
  16. tunacode/prompts/system.md +5 -4
  17. tunacode/tools/exit_plan_mode.py +191 -0
  18. tunacode/tools/grep.py +12 -1
  19. tunacode/tools/present_plan.py +208 -0
  20. tunacode/types.py +57 -0
  21. tunacode/ui/console.py +2 -0
  22. tunacode/ui/input.py +13 -2
  23. tunacode/ui/keybindings.py +26 -38
  24. tunacode/ui/output.py +39 -4
  25. tunacode/ui/panels.py +79 -2
  26. tunacode/ui/prompt_manager.py +19 -2
  27. tunacode/ui/tool_descriptions.py +115 -0
  28. tunacode/ui/tool_ui.py +3 -2
  29. tunacode/utils/message_utils.py +14 -4
  30. {tunacode_cli-0.0.54.dist-info → tunacode_cli-0.0.56.dist-info}/METADATA +4 -3
  31. {tunacode_cli-0.0.54.dist-info → tunacode_cli-0.0.56.dist-info}/RECORD +35 -29
  32. {tunacode_cli-0.0.54.dist-info → tunacode_cli-0.0.56.dist-info}/WHEEL +0 -0
  33. {tunacode_cli-0.0.54.dist-info → tunacode_cli-0.0.56.dist-info}/entry_points.txt +0 -0
  34. {tunacode_cli-0.0.54.dist-info → tunacode_cli-0.0.56.dist-info}/licenses/LICENSE +0 -0
  35. {tunacode_cli-0.0.54.dist-info → tunacode_cli-0.0.56.dist-info}/top_level.txt +0 -0
tunacode/ui/input.py CHANGED
@@ -76,19 +76,28 @@ async def multiline_input(
76
76
  ) -> str:
77
77
  """Get multiline input from the user with @file completion and highlighting."""
78
78
  kb = create_key_bindings(state_manager)
79
+
80
+ # Clear any residual terminal output
81
+ import sys
82
+ sys.stdout.flush()
83
+
84
+ # Full placeholder with all keyboard shortcuts
79
85
  placeholder = formatted_text(
80
86
  (
81
87
  "<darkgrey>"
82
88
  "<bold>Enter</bold> to submit • "
83
89
  "<bold>Esc + Enter</bold> for new line • "
84
90
  "<bold>Esc twice</bold> to cancel • "
91
+ "<bold>Shift + Tab</bold> toggle plan mode • "
85
92
  "<bold>/help</bold> for commands"
86
93
  "</darkgrey>"
87
94
  )
88
95
  )
89
- return await input(
96
+
97
+ # Display input area (Plan Mode indicator is handled dynamically in prompt manager)
98
+ result = await input(
90
99
  "multiline",
91
- pretext="> ", # Default prompt
100
+ pretext="> ",
92
101
  key_bindings=kb,
93
102
  multiline=True,
94
103
  placeholder=placeholder,
@@ -96,3 +105,5 @@ async def multiline_input(
96
105
  lexer=FileReferenceLexer(),
97
106
  state_manager=state_manager,
98
107
  )
108
+
109
+ return result
@@ -1,9 +1,7 @@
1
1
  """Key binding handlers for TunaCode UI."""
2
2
 
3
3
  import logging
4
- import time
5
4
 
6
- from prompt_toolkit.application import run_in_terminal
7
5
  from prompt_toolkit.key_binding import KeyBindings
8
6
 
9
7
  from ..core.state import StateManager
@@ -32,38 +30,12 @@ def create_key_bindings(state_manager: StateManager = None) -> KeyBindings:
32
30
 
33
31
  @kb.add("escape")
34
32
  def _escape(event):
35
- """Handle ESC key with double-press logic: first press warns, second cancels."""
36
- if not state_manager:
37
- logger.debug("Escape key pressed without state manager")
38
- return
33
+ """Handle ESC key - trigger Ctrl+C behavior."""
34
+ logger.debug("ESC key pressed - simulating Ctrl+C")
39
35
 
40
- current_time = time.time()
41
- session = state_manager.session
42
-
43
- # Reset counter if too much time has passed (3 seconds timeout)
44
- if session.last_esc_time and (current_time - session.last_esc_time) > 3.0:
45
- session.esc_press_count = 0
46
-
47
- session.esc_press_count = (session.esc_press_count or 0) + 1
48
- session.last_esc_time = current_time
49
-
50
- logger.debug(f"ESC key pressed: count={session.esc_press_count}, time={current_time}")
51
-
52
- if session.esc_press_count == 1:
53
- # First ESC press - show warning message
54
- from ..ui.output import warning
55
-
56
- run_in_terminal(lambda: warning("Hit ESC again within 3 seconds to cancel operation"))
57
- logger.debug("First ESC press - showing warning")
58
- else:
59
- # Second ESC press - cancel operation
60
- session.esc_press_count = 0 # Reset counter
61
- logger.debug("Second ESC press - initiating cancellation")
62
-
63
- # Mark the session as being cancelled to prevent new operations
64
- session.operation_cancelled = True
65
-
66
- current_task = session.current_task
36
+ # Cancel any active task if present
37
+ if state_manager and hasattr(state_manager.session, "current_task"):
38
+ current_task = state_manager.session.current_task
67
39
  if current_task and not current_task.done():
68
40
  logger.debug(f"Cancelling current task: {current_task}")
69
41
  try:
@@ -71,12 +43,28 @@ def create_key_bindings(state_manager: StateManager = None) -> KeyBindings:
71
43
  logger.debug("Task cancellation initiated successfully")
72
44
  except Exception as e:
73
45
  logger.debug(f"Failed to cancel task: {e}")
46
+
47
+ # Trigger the same behavior as Ctrl+C by sending the signal
48
+ import os
49
+ import signal
50
+ os.kill(os.getpid(), signal.SIGINT)
51
+
52
+ @kb.add("s-tab") # shift+tab
53
+ def _toggle_plan_mode(event):
54
+ """Toggle between Plan Mode and normal mode."""
55
+ if state_manager:
56
+ # Toggle the state
57
+ if state_manager.is_plan_mode():
58
+ state_manager.exit_plan_mode()
59
+ logger.debug("Toggled to normal mode via Shift+Tab")
74
60
  else:
75
- logger.debug(f"No active task to cancel: current_task={current_task}")
61
+ state_manager.enter_plan_mode()
62
+ logger.debug("Toggled to Plan Mode via Shift+Tab")
63
+
64
+ # Clear the current buffer and refresh the display
65
+ event.current_buffer.reset()
76
66
 
77
- # Force exit the current input by raising KeyboardInterrupt
78
- # This will be caught by the prompt manager and converted to UserAbortError
79
- logger.debug("Raising KeyboardInterrupt to abort current operation")
80
- raise KeyboardInterrupt()
67
+ # Force a refresh of the application without exiting
68
+ event.app.invalidate()
81
69
 
82
70
  return kb
tunacode/ui/output.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Output and display functions for TunaCode UI."""
2
2
 
3
+ from typing import Optional
4
+
3
5
  from prompt_toolkit.application import run_in_terminal
4
6
  from rich.console import Console
5
7
  from rich.padding import Padding
@@ -109,20 +111,39 @@ async def show_update_message(latest_version: str) -> None:
109
111
  await update_available(latest_version)
110
112
 
111
113
 
112
- async def spinner(show: bool = True, spinner_obj=None, state_manager: StateManager = None):
113
- """Manage a spinner display."""
114
+ async def spinner(
115
+ show: bool = True,
116
+ spinner_obj=None,
117
+ state_manager: StateManager = None,
118
+ message: Optional[str] = None,
119
+ ):
120
+ """Manage a spinner display with dynamic message support.
121
+
122
+ Args:
123
+ show: Whether to show (True) or hide (False) the spinner
124
+ spinner_obj: Existing spinner object to reuse
125
+ state_manager: State manager instance for storing spinner
126
+ message: Optional custom message to display (uses UI_THINKING_MESSAGE if None)
127
+
128
+ Returns:
129
+ The spinner object for further manipulation
130
+ """
114
131
  icon = SPINNER_TYPE
115
- message = UI_THINKING_MESSAGE
132
+ display_message = message or UI_THINKING_MESSAGE
116
133
 
117
134
  # Get spinner from state manager if available
118
135
  if spinner_obj is None and state_manager:
119
136
  spinner_obj = state_manager.session.spinner
120
137
 
121
138
  if not spinner_obj:
122
- spinner_obj = await run_in_terminal(lambda: console.status(message, spinner=icon))
139
+ spinner_obj = await run_in_terminal(lambda: console.status(display_message, spinner=icon))
123
140
  # Store it back in state manager if available
124
141
  if state_manager:
125
142
  state_manager.session.spinner = spinner_obj
143
+ else:
144
+ # Update existing spinner message if provided
145
+ if message and hasattr(spinner_obj, "update"):
146
+ spinner_obj.update(display_message)
126
147
 
127
148
  if show:
128
149
  spinner_obj.start()
@@ -132,6 +153,20 @@ async def spinner(show: bool = True, spinner_obj=None, state_manager: StateManag
132
153
  return spinner_obj
133
154
 
134
155
 
156
+ async def update_spinner_message(message: str, state_manager: StateManager = None):
157
+ """Update the spinner message if a spinner is active.
158
+
159
+ Args:
160
+ message: New message to display
161
+ state_manager: State manager instance containing spinner
162
+ """
163
+ if state_manager and state_manager.session.spinner:
164
+ spinner_obj = state_manager.session.spinner
165
+ if hasattr(spinner_obj, "update"):
166
+ # Rich's Status object supports update method
167
+ await run_in_terminal(lambda: spinner_obj.update(message))
168
+
169
+
135
170
  def get_context_window_display(total_tokens: int, max_tokens: int) -> str:
136
171
  """
137
172
  Create a color-coded display for the context window status.
tunacode/ui/panels.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """Panel display functions for TunaCode UI."""
2
2
 
3
+ import asyncio
4
+ import time
3
5
  from typing import Any, Optional, Union
4
6
 
5
7
  from rich.box import ROUNDED
@@ -81,11 +83,24 @@ async def agent(text: str, bottom: int = 1) -> None:
81
83
  class StreamingAgentPanel:
82
84
  """Streaming agent panel using Rich.Live for progressive display."""
83
85
 
86
+ bottom: int
87
+ title: str
88
+ content: str
89
+ live: Optional[Live]
90
+ _last_update_time: float
91
+ _dots_task: Optional[asyncio.Task]
92
+ _dots_count: int
93
+ _show_dots: bool
94
+
84
95
  def __init__(self, bottom: int = 1):
85
96
  self.bottom = bottom
86
97
  self.title = f"[bold {colors.primary}]●[/bold {colors.primary}] {APP_NAME}"
87
98
  self.content = ""
88
99
  self.live = None
100
+ self._last_update_time = 0.0
101
+ self._dots_task = None
102
+ self._dots_count = 0
103
+ self._show_dots = False
89
104
 
90
105
  def _create_panel(self) -> Padding:
91
106
  """Create a Rich panel with current content."""
@@ -94,11 +109,19 @@ class StreamingAgentPanel:
94
109
 
95
110
  from tunacode.constants import UI_THINKING_MESSAGE
96
111
 
97
- # Handle the default thinking message with Rich markup
112
+ # Show "Thinking..." only when no content has arrived yet
98
113
  if not self.content:
99
114
  content_renderable: Union[Text, Markdown] = Text.from_markup(UI_THINKING_MESSAGE)
100
115
  else:
101
- content_renderable = Markdown(self.content)
116
+ # Once we have content, show it with optional dots animation
117
+ display_content = self.content
118
+ # Add animated dots if we're waiting for more content
119
+ if self._show_dots:
120
+ # Cycle through: "", ".", "..", "..."
121
+ dots_patterns = ["", ".", "..", "..."]
122
+ dots = dots_patterns[self._dots_count % len(dots_patterns)]
123
+ display_content = self.content.rstrip() + dots
124
+ content_renderable = Markdown(display_content)
102
125
  panel_obj = Panel(
103
126
  Padding(content_renderable, (0, 1, 0, 1)),
104
127
  title=f"[bold]{self.title}[/bold]",
@@ -117,31 +140,85 @@ class StreamingAgentPanel:
117
140
  ),
118
141
  )
119
142
 
143
+ async def _animate_dots(self):
144
+ """Animate dots after a pause in streaming."""
145
+ while True:
146
+ await asyncio.sleep(0.5)
147
+ current_time = time.time()
148
+ # Only show dots after 1 second of no updates
149
+ if current_time - self._last_update_time > 1.0:
150
+ self._show_dots = True
151
+ self._dots_count += 1
152
+ if self.live:
153
+ self.live.update(self._create_panel())
154
+ else:
155
+ self._show_dots = False
156
+ self._dots_count = 0
157
+
120
158
  async def start(self):
121
159
  """Start the live streaming display."""
122
160
  from .output import console
123
161
 
124
162
  self.live = Live(self._create_panel(), console=console, refresh_per_second=4)
125
163
  self.live.start()
164
+ self._last_update_time = time.time()
165
+ # Start the dots animation task
166
+ self._dots_task = asyncio.create_task(self._animate_dots())
126
167
 
127
168
  async def update(self, content_chunk: str):
128
169
  """Update the streaming display with new content."""
129
170
  # Defensive: some providers may yield None chunks intermittently
130
171
  if content_chunk is None:
131
172
  content_chunk = ""
173
+
174
+ # Filter out plan mode system prompts and tool definitions from streaming
175
+ if any(phrase in str(content_chunk) for phrase in [
176
+ "🔧 PLAN MODE",
177
+ "TOOL EXECUTION ONLY",
178
+ "planning assistant that ONLY communicates",
179
+ "namespace functions {",
180
+ "namespace multi_tool_use {",
181
+ "You are trained on data up to"
182
+ ]):
183
+ return
184
+
132
185
  # Ensure type safety for concatenation
133
186
  self.content = (self.content or "") + str(content_chunk)
187
+
188
+ # Reset the update timer when we get new content
189
+ self._last_update_time = time.time()
190
+ self._show_dots = False # Hide dots immediately when new content arrives
191
+
134
192
  if self.live:
135
193
  self.live.update(self._create_panel())
136
194
 
137
195
  async def set_content(self, content: str):
138
196
  """Set the complete content (overwrites previous)."""
197
+ # Filter out plan mode system prompts and tool definitions
198
+ if any(phrase in str(content) for phrase in [
199
+ "🔧 PLAN MODE",
200
+ "TOOL EXECUTION ONLY",
201
+ "planning assistant that ONLY communicates",
202
+ "namespace functions {",
203
+ "namespace multi_tool_use {",
204
+ "You are trained on data up to"
205
+ ]):
206
+ return
207
+
139
208
  self.content = content
140
209
  if self.live:
141
210
  self.live.update(self._create_panel())
142
211
 
143
212
  async def stop(self):
144
213
  """Stop the live streaming display."""
214
+ # Cancel the dots animation task
215
+ if self._dots_task:
216
+ self._dots_task.cancel()
217
+ try:
218
+ await self._dots_task
219
+ except asyncio.CancelledError:
220
+ pass
221
+
145
222
  if self.live:
146
223
  # Get the console before stopping the live display
147
224
  from .output import console
@@ -100,15 +100,32 @@ class PromptManager:
100
100
  """
101
101
  session = self.get_session(session_key, config)
102
102
 
103
- # Create a custom prompt that changes based on input
103
+ # Create a custom prompt that changes based on input and plan mode
104
104
  def get_prompt():
105
+ # Start with the base prompt
106
+ base_prompt = prompt
107
+
108
+ # Add Plan Mode indicator if active
109
+ if (self.state_manager and
110
+ self.state_manager.is_plan_mode() and
111
+ "PLAN MODE ON" not in base_prompt):
112
+ base_prompt = '<style fg="#40E0D0"><bold>⏸ PLAN MODE ON</bold></style>\n' + base_prompt
113
+ elif (self.state_manager and
114
+ not self.state_manager.is_plan_mode() and
115
+ ("⏸" in base_prompt or "PLAN MODE ON" in base_prompt)):
116
+ # Remove plan mode indicator if no longer in plan mode
117
+ lines = base_prompt.split("\n")
118
+ if len(lines) > 1 and ("⏸" in lines[0] or "PLAN MODE ON" in lines[0]):
119
+ base_prompt = "\n".join(lines[1:])
120
+
105
121
  # Check if current buffer starts with "!"
106
122
  if hasattr(session.app, "current_buffer") and session.app.current_buffer:
107
123
  text = session.app.current_buffer.text
108
124
  if text.startswith("!"):
109
125
  # Use bright yellow background with black text for high visibility
110
126
  return HTML('<style bg="#ffcc00" fg="black"><b> ◆ BASH MODE ◆ </b></style> ')
111
- return HTML(prompt) if isinstance(prompt, str) else prompt
127
+
128
+ return HTML(base_prompt) if isinstance(base_prompt, str) else base_prompt
112
129
 
113
130
  try:
114
131
  # Get user input with dynamic prompt
@@ -0,0 +1,115 @@
1
+ """Tool description mappings for user-friendly spinner messages."""
2
+
3
+ from typing import Dict, Optional
4
+
5
+
6
+ def get_tool_description(tool_name: str, args: Optional[Dict] = None) -> str:
7
+ """
8
+ Get a human-readable description for a tool execution.
9
+
10
+ Args:
11
+ tool_name: Name of the tool being executed
12
+ args: Optional tool arguments for more specific descriptions
13
+
14
+ Returns:
15
+ User-friendly description of the tool operation
16
+ """
17
+ # Base descriptions for each tool
18
+ base_descriptions = {
19
+ # File operations
20
+ "read_file": "Reading file",
21
+ "write_file": "Writing file",
22
+ "update_file": "Updating file",
23
+ "create_file": "Creating file",
24
+ "delete_file": "Deleting file",
25
+ # Directory operations
26
+ "list_dir": "Listing directory",
27
+ "create_dir": "Creating directory",
28
+ "delete_dir": "Deleting directory",
29
+ # Search operations
30
+ "grep": "Searching files",
31
+ "glob": "Finding files",
32
+ "find_files": "Searching for files",
33
+ # Code operations
34
+ "run_command": "Executing command",
35
+ "bash": "Running shell command",
36
+ "python": "Executing Python code",
37
+ # Analysis operations
38
+ "analyze_code": "Analyzing code",
39
+ "lint": "Running linter",
40
+ "format_code": "Formatting code",
41
+ # Version control
42
+ "git_status": "Checking git status",
43
+ "git_diff": "Getting git diff",
44
+ "git_commit": "Creating git commit",
45
+ # Testing
46
+ "run_tests": "Running tests",
47
+ "test": "Executing tests",
48
+ # Documentation
49
+ "generate_docs": "Generating documentation",
50
+ "update_docs": "Updating documentation",
51
+ # Default
52
+ "unknown": "Processing",
53
+ }
54
+
55
+ # Get base description
56
+ base_desc = base_descriptions.get(tool_name, f"Executing {tool_name}")
57
+
58
+ # Add specific details from args if available
59
+ if args:
60
+ if tool_name == "read_file" and "file_path" in args:
61
+ return f"{base_desc}: {args['file_path']}"
62
+ elif tool_name == "write_file" and "file_path" in args:
63
+ return f"{base_desc}: {args['file_path']}"
64
+ elif tool_name == "update_file" and "file_path" in args:
65
+ return f"{base_desc}: {args['file_path']}"
66
+ elif tool_name == "list_dir" and "directory" in args:
67
+ return f"{base_desc}: {args['directory']}"
68
+ elif tool_name == "grep" and "pattern" in args:
69
+ pattern = args["pattern"]
70
+ # Truncate long patterns
71
+ if len(pattern) > 30:
72
+ pattern = pattern[:27] + "..."
73
+ return f"{base_desc} for: {pattern}"
74
+ elif tool_name == "glob" and "pattern" in args:
75
+ return f"{base_desc}: {args['pattern']}"
76
+ elif tool_name == "run_command" and "command" in args:
77
+ cmd = args["command"]
78
+ # Truncate long commands
79
+ if len(cmd) > 40:
80
+ cmd = cmd[:37] + "..."
81
+ return f"{base_desc}: {cmd}"
82
+ elif tool_name == "bash" and "command" in args:
83
+ cmd = args["command"]
84
+ if len(cmd) > 40:
85
+ cmd = cmd[:37] + "..."
86
+ return f"{base_desc}: {cmd}"
87
+
88
+ return base_desc
89
+
90
+
91
+ def get_batch_description(tool_count: int, tool_names: Optional[list] = None) -> str:
92
+ """
93
+ Get a description for batch tool execution.
94
+
95
+ Args:
96
+ tool_count: Number of tools being executed
97
+ tool_names: Optional list of tool names for more detail
98
+
99
+ Returns:
100
+ Description of the batch operation
101
+ """
102
+ if tool_count == 1:
103
+ return "Executing 1 tool"
104
+
105
+ if tool_names and len(set(tool_names)) == 1:
106
+ # All tools are the same type
107
+ tool_type = tool_names[0]
108
+ if tool_type == "read_file":
109
+ return f"Reading {tool_count} files in parallel"
110
+ elif tool_type == "grep":
111
+ return f"Searching {tool_count} patterns in parallel"
112
+ elif tool_type == "list_dir":
113
+ return f"Listing {tool_count} directories in parallel"
114
+
115
+ return f"Executing {tool_count} tools in parallel"
tunacode/ui/tool_ui.py CHANGED
@@ -76,8 +76,9 @@ class ToolUI:
76
76
 
77
77
  # Show file content on write_file
78
78
  elif tool_name == TOOL_WRITE_FILE:
79
- markdown_obj = self._create_code_block(args["filepath"], args["content"])
80
- return str(markdown_obj)
79
+ lang = ext_to_lang(args["filepath"])
80
+ code_block = f"```{lang}\n{args['content']}\n```"
81
+ return code_block
81
82
 
82
83
  # Default to showing key and value on new line
83
84
  content = ""
@@ -9,11 +9,21 @@ def get_message_content(message: Any) -> str:
9
9
  return message
10
10
  if isinstance(message, dict):
11
11
  if "content" in message:
12
- return message["content"]
12
+ content = message["content"]
13
+ # Handle nested content structures
14
+ if isinstance(content, list):
15
+ return " ".join(get_message_content(item) for item in content)
16
+ return str(content)
13
17
  if "thought" in message:
14
- return message["thought"]
18
+ return str(message["thought"])
15
19
  if hasattr(message, "content"):
16
- return message.content
20
+ content = message.content
21
+ if isinstance(content, list):
22
+ return " ".join(get_message_content(item) for item in content)
23
+ return str(content)
17
24
  if hasattr(message, "parts"):
18
- return " ".join(get_message_content(part) for part in message.parts)
25
+ parts = message.parts
26
+ if isinstance(parts, list):
27
+ return " ".join(get_message_content(part) for part in parts)
28
+ return str(parts)
19
29
  return ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tunacode-cli
3
- Version: 0.0.54
3
+ Version: 0.0.56
4
4
  Summary: Your agentic CLI developer.
5
5
  Author-email: larock22 <noreply@github.com>
6
6
  License: MIT
@@ -39,6 +39,7 @@ Requires-Dist: vulture>=2.7; extra == "dev"
39
39
  Requires-Dist: unimport>=1.0.0; extra == "dev"
40
40
  Requires-Dist: autoflake>=2.0.0; extra == "dev"
41
41
  Requires-Dist: dead>=1.5.0; extra == "dev"
42
+ Requires-Dist: hatch>=1.6.0; extra == "dev"
42
43
  Dynamic: license-file
43
44
 
44
45
  # TunaCode CLI
@@ -106,7 +107,7 @@ tunacode --model "anthropic:claude-3.5-sonnet" --key "sk-ant-your-anthropic-key"
106
107
  tunacode --model "openrouter:openai/gpt-4o" --key "sk-or-your-openrouter-key"
107
108
  ```
108
109
 
109
- Your config is saved to `~/.config/tunacode.json` (edit directly with `nvim ~/.config/tunacode.json`)
110
+ Your config is saved to `~/.config/tunacode.json`. This file stores your API keys, model preferences, and runtime settings like `max_iterations` (default: 40) and `context_window_size`. You can edit it directly with `nvim ~/.config/tunacode.json` or see [the complete configuration example](documentation/configuration/config-file-example.md) for all available options.
110
111
 
111
112
  ### Recommended Models
112
113
 
@@ -243,7 +244,7 @@ tunacode --model "anthropic:claude-3.5-sonnet" --key "sk-ant-your-anthropic-key"
243
244
  tunacode --model "openrouter:openai/gpt-4o" --key "sk-or-your-openrouter-key"
244
245
  ```
245
246
 
246
- Your config is saved to `~/.config/tunacode.json` (edit directly with `nvim ~/.config/tunacode.json`)
247
+ Your config is saved to `~/.config/tunacode.json`. This file stores your API keys, model preferences, and runtime settings like `max_iterations` (default: 40) and `context_window_size`. You can edit it directly with `nvim ~/.config/tunacode.json` or see [the complete configuration example](documentation/configuration/config-file-example.md) for all available options.
247
248
 
248
249
  ### Recommended Models
249
250