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/ui/panels.py CHANGED
@@ -2,15 +2,14 @@
2
2
 
3
3
  import asyncio
4
4
  import time
5
- from typing import Any, Optional, Union
5
+ from typing import TYPE_CHECKING, Any, Mapping, Optional, Union
6
6
 
7
- from rich.box import ROUNDED
8
- from rich.live import Live
9
- from rich.markdown import Markdown
10
- from rich.padding import Padding
11
- from rich.panel import Panel
12
- from rich.pretty import Pretty
13
- from rich.table import Table
7
+ if TYPE_CHECKING:
8
+ from rich.markdown import Markdown
9
+ from rich.padding import Padding
10
+ from rich.pretty import Pretty
11
+ from rich.table import Table
12
+ from rich.text import Text
14
13
 
15
14
  from tunacode.configuration.models import ModelRegistry
16
15
  from tunacode.constants import (
@@ -45,11 +44,44 @@ from .output import print
45
44
 
46
45
  colors = DotDict(UI_COLORS)
47
46
 
47
+ RenderableContent = Union["Text", "Markdown"]
48
+
49
+ _rich_components: Optional[Mapping[str, Any]] = None
50
+
51
+
52
+ def get_rich_components() -> Mapping[str, Any]:
53
+ """Get Rich components lazily with caching."""
54
+
55
+ global _rich_components
56
+ if _rich_components is not None:
57
+ return _rich_components
58
+
59
+ from rich.box import ROUNDED
60
+ from rich.live import Live
61
+ from rich.markdown import Markdown
62
+ from rich.padding import Padding
63
+ from rich.panel import Panel
64
+ from rich.pretty import Pretty
65
+ from rich.table import Table
66
+ from rich.text import Text
67
+
68
+ _rich_components = {
69
+ "ROUNDED": ROUNDED,
70
+ "Live": Live,
71
+ "Markdown": Markdown,
72
+ "Padding": Padding,
73
+ "Panel": Panel,
74
+ "Pretty": Pretty,
75
+ "Table": Table,
76
+ "Text": Text,
77
+ }
78
+ return _rich_components
79
+
48
80
 
49
81
  @create_sync_wrapper
50
82
  async def panel(
51
83
  title: str,
52
- text: Union[str, Markdown, Pretty, Table],
84
+ text: Union[str, "Markdown", "Pretty", "Table"],
53
85
  top: int = DEFAULT_PANEL_PADDING["top"],
54
86
  right: int = DEFAULT_PANEL_PADDING["right"],
55
87
  bottom: int = DEFAULT_PANEL_PADDING["bottom"],
@@ -58,26 +90,35 @@ async def panel(
58
90
  **kwargs: Any,
59
91
  ) -> None:
60
92
  """Display a rich panel with modern styling."""
93
+ rich = get_rich_components()
61
94
  border_style = border_style or kwargs.get("style") or colors.border
62
95
 
63
- panel_obj = Panel(
64
- Padding(text, (0, 1, 0, 1)),
96
+ panel_obj = rich["Panel"](
97
+ rich["Padding"](text, (0, 1, 0, 1)),
65
98
  title=f"[bold]{title}[/bold]",
66
99
  title_align="left",
67
100
  border_style=border_style,
68
101
  padding=(0, 1),
69
- box=ROUNDED, # Use ROUNDED box style
102
+ box=rich["ROUNDED"], # Use ROUNDED box style
70
103
  )
71
104
 
72
- final_padding = Padding(panel_obj, (top, right, bottom, left))
105
+ final_padding = rich["Padding"](panel_obj, (top, right, bottom, left))
73
106
 
74
107
  await print(final_padding, **kwargs)
75
108
 
76
109
 
77
110
  async def agent(text: str, bottom: int = 1) -> None:
78
111
  """Display an agent panel with modern styling."""
112
+ rich = get_rich_components()
79
113
  title = f"[bold {colors.primary}]●[/bold {colors.primary}] {APP_NAME}"
80
- await panel(title, Markdown(text), bottom=bottom, border_style=colors.primary)
114
+ await panel(title, rich["Markdown"](text), bottom=bottom, border_style=colors.primary)
115
+
116
+
117
+ async def batch(text: str, bottom: int = 1) -> None:
118
+ """Display a parallel batch execution panel with green styling."""
119
+ rich = get_rich_components()
120
+ title = "[bold green]Parallel Execution[/bold green]"
121
+ await panel(title, rich["Markdown"](text), bottom=bottom, border_style="green")
81
122
 
82
123
 
83
124
  class StreamingAgentPanel:
@@ -86,13 +127,13 @@ class StreamingAgentPanel:
86
127
  bottom: int
87
128
  title: str
88
129
  content: str
89
- live: Optional[Live]
130
+ live: Optional[Any]
90
131
  _last_update_time: float
91
132
  _dots_task: Optional[asyncio.Task]
92
133
  _dots_count: int
93
134
  _show_dots: bool
94
135
 
95
- def __init__(self, bottom: int = 1):
136
+ def __init__(self, bottom: int = 1, debug: bool = False):
96
137
  self.bottom = bottom
97
138
  self.title = f"[bold {colors.primary}]●[/bold {colors.primary}] {APP_NAME}"
98
139
  self.content = ""
@@ -100,18 +141,43 @@ class StreamingAgentPanel:
100
141
  self._last_update_time = 0.0
101
142
  self._dots_task = None
102
143
  self._dots_count = 0
103
- self._show_dots = False
104
-
105
- def _create_panel(self) -> Padding:
144
+ self._show_dots = True # Start with dots enabled for "Thinking..."
145
+ # Debug/diagnostic instrumentation (printed after stop to avoid Live interference)
146
+ self._debug_enabled = debug
147
+ self._debug_events: list[str] = []
148
+ self._update_count: int = 0
149
+ self._first_update_done: bool = False
150
+ self._dots_tick_count: int = 0
151
+ self._max_logged_dots: int = 10
152
+
153
+ def _log_debug(self, label: str, **data: Any) -> None:
154
+ if not self._debug_enabled:
155
+ return
156
+ try:
157
+ ts = time.perf_counter_ns()
158
+ except Exception:
159
+ ts = 0
160
+ payload = ", ".join(f"{k}={repr(v)}" for k, v in data.items()) if data else ""
161
+ line = f"[ui] {label} ts_ns={ts}{(' ' + payload) if payload else ''}"
162
+ self._debug_events.append(line)
163
+
164
+ def _create_panel(self) -> "Padding":
106
165
  """Create a Rich panel with current content."""
107
- # Use the UI_THINKING_MESSAGE constant instead of hardcoded text
108
- from rich.text import Text
109
-
110
166
  from tunacode.constants import UI_THINKING_MESSAGE
111
167
 
168
+ rich = get_rich_components()
169
+
112
170
  # Show "Thinking..." only when no content has arrived yet
113
171
  if not self.content:
114
- content_renderable: Union[Text, Markdown] = Text.from_markup(UI_THINKING_MESSAGE)
172
+ # Apply dots animation to "Thinking..." message too
173
+ thinking_msg = UI_THINKING_MESSAGE
174
+ if self._show_dots:
175
+ # Remove the existing ... from the message and add animated dots
176
+ base_msg = thinking_msg.replace("...", "")
177
+ dots_patterns = ["", ".", "..", "..."]
178
+ dots = dots_patterns[self._dots_count % len(dots_patterns)]
179
+ thinking_msg = base_msg + dots
180
+ content_renderable: RenderableContent = rich["Text"].from_markup(thinking_msg)
115
181
  else:
116
182
  # Once we have content, show it with optional dots animation
117
183
  display_content = self.content
@@ -121,16 +187,16 @@ class StreamingAgentPanel:
121
187
  dots_patterns = ["", ".", "..", "..."]
122
188
  dots = dots_patterns[self._dots_count % len(dots_patterns)]
123
189
  display_content = self.content.rstrip() + dots
124
- content_renderable = Markdown(display_content)
125
- panel_obj = Panel(
126
- Padding(content_renderable, (0, 1, 0, 1)),
190
+ content_renderable = rich["Markdown"](display_content)
191
+ panel_obj = rich["Panel"](
192
+ rich["Padding"](content_renderable, (0, 1, 0, 1)),
127
193
  title=f"[bold]{self.title}[/bold]",
128
194
  title_align="left",
129
195
  border_style=colors.primary,
130
196
  padding=(0, 1),
131
- box=ROUNDED,
197
+ box=rich["ROUNDED"],
132
198
  )
133
- return Padding(
199
+ return rich["Padding"](
134
200
  panel_obj,
135
201
  (
136
202
  DEFAULT_PANEL_PADDING["top"],
@@ -143,25 +209,44 @@ class StreamingAgentPanel:
143
209
  async def _animate_dots(self):
144
210
  """Animate dots after a pause in streaming."""
145
211
  while True:
146
- await asyncio.sleep(0.5)
212
+ await asyncio.sleep(0.2) # Faster animation cycle
147
213
  current_time = time.time()
148
- # Only show dots after 1 second of no updates
149
- if current_time - self._last_update_time > 1.0:
214
+ # Use shorter delay for initial "Thinking..." phase
215
+ delay_threshold = 0.3 if not self.content else 1.0
216
+ # Show dots after the delay threshold
217
+ if current_time - self._last_update_time > delay_threshold:
150
218
  self._show_dots = True
151
219
  self._dots_count += 1
220
+ # Log only a few initial ticks to avoid noise
221
+ if (
222
+ self._debug_enabled
223
+ and not self.content
224
+ and self._dots_tick_count < self._max_logged_dots
225
+ ):
226
+ self._dots_tick_count += 1
227
+ self._log_debug("dots_tick", n=self._dots_count)
152
228
  if self.live:
153
229
  self.live.update(self._create_panel())
154
230
  else:
155
- self._show_dots = False
156
- self._dots_count = 0
231
+ # Only reset if we have content (keep dots for initial "Thinking...")
232
+ if self.content:
233
+ self._show_dots = False
234
+ self._dots_count = 0
157
235
 
158
236
  async def start(self):
159
237
  """Start the live streaming display."""
160
- from .output import console
238
+ from .output import get_console
239
+
240
+ rich = get_rich_components()
161
241
 
162
- self.live = Live(self._create_panel(), console=console, refresh_per_second=4)
242
+ self.live = rich["Live"](self._create_panel(), console=get_console(), refresh_per_second=4)
163
243
  self.live.start()
164
- self._last_update_time = time.time()
244
+ self._log_debug("start")
245
+ # For "Thinking...", set time in past to trigger dots immediately
246
+ if not self.content:
247
+ self._last_update_time = time.time() - 0.4 # Triggers dots on first cycle
248
+ else:
249
+ self._last_update_time = time.time()
165
250
  # Start the dots animation task
166
251
  self._dots_task = asyncio.create_task(self._animate_dots())
167
252
 
@@ -170,18 +255,81 @@ class StreamingAgentPanel:
170
255
  # Defensive: some providers may yield None chunks intermittently
171
256
  if content_chunk is None:
172
257
  content_chunk = ""
258
+
259
+ # Filter out system prompts and tool definitions from streaming
260
+ # Use more precise filtering to avoid false positives
261
+ content_str = str(content_chunk).strip()
262
+ if content_str and any(
263
+ content_str.startswith(phrase) or phrase in content_str
264
+ for phrase in [
265
+ "namespace functions {",
266
+ "namespace multi_tool_use {",
267
+ ]
268
+ ):
269
+ return
270
+
271
+ # Special handling for the training data phrase - filter only if it's a complete
272
+ # system message
273
+ if "You are trained on data up to" in content_str and len(content_str) > 50:
274
+ # Only filter if this looks like a complete system message, not user content
275
+ if (
276
+ content_str.startswith("You are trained on data up to")
277
+ or "The current date is" in content_str
278
+ ):
279
+ return
280
+
173
281
  # Ensure type safety for concatenation
174
- self.content = (self.content or "") + str(content_chunk)
282
+ incoming = str(content_chunk)
283
+ # First-chunk diagnostics
284
+ is_first_chunk = (not self.content) and bool(incoming)
285
+ if is_first_chunk:
286
+ self._log_debug(
287
+ "first_chunk_received",
288
+ chunk_repr=incoming[:5],
289
+ chunk_len=len(incoming),
290
+ )
291
+ self.content = (self.content or "") + incoming
175
292
 
176
293
  # Reset the update timer when we get new content
177
294
  self._last_update_time = time.time()
178
- self._show_dots = False # Hide dots immediately when new content arrives
295
+ # Hide dots immediately when new content arrives
296
+ if self._show_dots:
297
+ self._log_debug("disable_dots_called")
298
+ self._show_dots = False
179
299
 
180
300
  if self.live:
301
+ # Log timing around the first two live.update() calls
302
+ self._update_count += 1
303
+ if self._update_count <= 2:
304
+ self._log_debug("live_update.start", update_index=self._update_count)
181
305
  self.live.update(self._create_panel())
306
+ if self._update_count <= 2:
307
+ self._log_debug("live_update.end", update_index=self._update_count)
182
308
 
183
309
  async def set_content(self, content: str):
184
310
  """Set the complete content (overwrites previous)."""
311
+ # Filter out plan mode system prompts and tool definitions
312
+ # Use more precise filtering to avoid false positives
313
+ content_str = str(content).strip()
314
+ if content_str and any(
315
+ content_str.startswith(phrase) or phrase in content_str
316
+ for phrase in [
317
+ "namespace functions {",
318
+ "namespace multi_tool_use {",
319
+ ]
320
+ ):
321
+ return
322
+
323
+ # Special handling for the training data phrase - filter only if it's a complete
324
+ # system message
325
+ if "You are trained on data up to" in content_str and len(content_str) > 50:
326
+ # Only filter if this looks like a complete system message, not user content
327
+ if (
328
+ content_str.startswith("You are trained on data up to")
329
+ or "The current date is" in content_str
330
+ ):
331
+ return
332
+
185
333
  self.content = content
186
334
  if self.live:
187
335
  self.live.update(self._create_panel())
@@ -195,6 +343,8 @@ class StreamingAgentPanel:
195
343
  await self._dots_task
196
344
  except asyncio.CancelledError:
197
345
  pass
346
+ finally:
347
+ self._log_debug("dots_task_cancelled")
198
348
 
199
349
  if self.live:
200
350
  # Get the console before stopping the live display
@@ -218,6 +368,23 @@ class StreamingAgentPanel:
218
368
 
219
369
  self.live = None
220
370
 
371
+ # Emit debug diagnostics after Live has been stopped (to avoid interference)
372
+ if self._debug_enabled:
373
+ from .output import print as ui_print
374
+
375
+ # Summarize UI buffer state
376
+ ui_prefix = "[debug]"
377
+ ui_buffer_first5 = repr((self.content or "")[:5])
378
+ total_len = len(self.content or "")
379
+ lines = [
380
+ f"{ui_prefix} ui_buffer_first5={ui_buffer_first5} total_len={total_len}",
381
+ ]
382
+ # Include recorded event lines
383
+ lines.extend(self._debug_events)
384
+ # Flush lines to console
385
+ for line in lines:
386
+ await ui_print(line)
387
+
221
388
 
222
389
  async def agent_streaming(content_stream, bottom: int = 1):
223
390
  """Display an agent panel with streaming content updates.
@@ -245,14 +412,15 @@ async def error(text: str) -> None:
245
412
 
246
413
  async def dump_messages(messages_list=None, state_manager: StateManager = None) -> None:
247
414
  """Display message history panel."""
415
+ rich = get_rich_components()
248
416
  if messages_list is None and state_manager:
249
417
  # Get messages from state manager
250
- messages = Pretty(state_manager.session.messages)
418
+ messages = rich["Pretty"](state_manager.session.messages)
251
419
  elif messages_list is not None:
252
- messages = Pretty(messages_list)
420
+ messages = rich["Pretty"](messages_list)
253
421
  else:
254
422
  # No messages available
255
- messages = Pretty([])
423
+ messages = rich["Pretty"]([])
256
424
  await panel(PANEL_MESSAGE_HISTORY, messages, style=colors.muted)
257
425
 
258
426
 
@@ -268,7 +436,8 @@ async def models(state_manager: StateManager = None) -> None:
268
436
 
269
437
  async def help(command_registry=None) -> None:
270
438
  """Display the available commands organized by category."""
271
- table = Table(show_header=False, box=None, padding=(0, 2, 0, 0))
439
+ rich = get_rich_components()
440
+ table = rich["Table"](show_header=False, box=None, padding=(0, 2, 0, 0))
272
441
  table.add_column("Command", style=f"bold {colors.primary}", justify="right", min_width=18)
273
442
  table.add_column("Description", style=colors.muted)
274
443
 
@@ -328,7 +497,7 @@ async def help(command_registry=None) -> None:
328
497
 
329
498
  @create_sync_wrapper
330
499
  async def tool_confirm(
331
- title: str, content: Union[str, Markdown], filepath: Optional[str] = None
500
+ title: str, content: Union[str, "Markdown"], filepath: Optional[str] = None
332
501
  ) -> None:
333
502
  """Display a tool confirmation panel."""
334
503
  bottom_padding = 0 if filepath else 1
@@ -0,0 +1,91 @@
1
+ """Shared heuristics for prioritizing and skipping project paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable, List, Sequence
6
+
7
+ # CLAUDE_ANCHOR[key=0b3f320e] Cross-ecosystem skip directory defaults for FileReferenceCompleter
8
+ DEFAULT_SKIP_DIRECTORY_NAMES: Sequence[str] = (
9
+ ".git",
10
+ ".hg",
11
+ ".svn",
12
+ ".idea",
13
+ ".vscode",
14
+ ".venv",
15
+ "venv",
16
+ ".uv_cache",
17
+ "node_modules",
18
+ "dist",
19
+ "build",
20
+ "out",
21
+ "target",
22
+ # CLAUDE_ANCHOR[key=6fd59413] Skip list includes __pycache__ to avoid noisy
23
+ # Python build artifacts
24
+ "vendor",
25
+ "__pycache__",
26
+ ".mypy_cache",
27
+ ".pytest_cache",
28
+ ".ruff_cache",
29
+ ".tox",
30
+ "coverage",
31
+ ".cache",
32
+ # CLAUDE_ANCHOR[key=2bcebd52] Skip heuristic checks every path component to prune
33
+ # nested junk directories
34
+ )
35
+
36
+ DEFAULT_PRIORITY_PREFIXES: Sequence[str] = (
37
+ "src",
38
+ "app",
39
+ "lib",
40
+ "cmd",
41
+ "pkg",
42
+ "internal",
43
+ "include",
44
+ "components",
45
+ "tests",
46
+ "test",
47
+ "spec",
48
+ "examples",
49
+ "docs",
50
+ "documentation",
51
+ )
52
+
53
+
54
+ def prioritize_roots(roots: Iterable[str]) -> List[str]:
55
+ """Order project roots so high-signal directories surface first."""
56
+
57
+ ordered: List[str] = []
58
+ seen = set()
59
+
60
+ for root in roots:
61
+ if root == "." and root not in seen:
62
+ ordered.append(root)
63
+ seen.add(root)
64
+
65
+ for prefix in DEFAULT_PRIORITY_PREFIXES:
66
+ for root in roots:
67
+ if root in seen:
68
+ continue
69
+ normalized = root.replace("\\", "/")
70
+ if normalized == prefix or normalized.startswith(f"{prefix}/"):
71
+ ordered.append(root)
72
+ seen.add(root)
73
+
74
+ for root in roots:
75
+ if root in seen:
76
+ continue
77
+ ordered.append(root)
78
+ seen.add(root)
79
+
80
+ return ordered
81
+
82
+
83
+ def should_skip_directory(path: str) -> bool:
84
+ """Return True when any component of the path matches skip heuristics."""
85
+
86
+ if not path or path == ".":
87
+ return False
88
+
89
+ normalized = path.replace("\\", "/")
90
+ components = normalized.split("/")
91
+ return any(component in DEFAULT_SKIP_DIRECTORY_NAMES for component in components)
@@ -102,13 +102,17 @@ class PromptManager:
102
102
 
103
103
  # Create a custom prompt that changes based on input
104
104
  def get_prompt():
105
+ # Start with the base prompt
106
+ base_prompt = prompt
107
+
105
108
  # Check if current buffer starts with "!"
106
109
  if hasattr(session.app, "current_buffer") and session.app.current_buffer:
107
110
  text = session.app.current_buffer.text
108
111
  if text.startswith("!"):
109
112
  # Use bright yellow background with black text for high visibility
110
113
  return HTML('<style bg="#ffcc00" fg="black"><b> ◆ BASH MODE ◆ </b></style> ')
111
- return HTML(prompt) if isinstance(prompt, str) else prompt
114
+
115
+ return HTML(base_prompt) if isinstance(base_prompt, str) else base_prompt
112
116
 
113
117
  try:
114
118
  # Get user input with dynamic prompt
tunacode/ui/tool_ui.py CHANGED
@@ -25,6 +25,11 @@ if TYPE_CHECKING:
25
25
  class ToolUI:
26
26
  """Handles tool confirmation UI presentation."""
27
27
 
28
+ REJECTION_FEEDBACK_SESSION = "tool_rejection_feedback"
29
+ REJECTION_GUIDANCE_PROMPT = (
30
+ " Describe what the agent should do instead (leave blank to skip): "
31
+ )
32
+
28
33
  def __init__(self):
29
34
  self.colors = DotDict(UI_COLORS)
30
35
 
@@ -76,8 +81,9 @@ class ToolUI:
76
81
 
77
82
  # Show file content on write_file
78
83
  elif tool_name == TOOL_WRITE_FILE:
79
- markdown_obj = self._create_code_block(args["filepath"], args["content"])
80
- return str(markdown_obj)
84
+ lang = ext_to_lang(args["filepath"])
85
+ code_block = f"```{lang}\n{args['content']}\n```"
86
+ return code_block
81
87
 
82
88
  # Default to showing key and value on new line
83
89
  content = ""
@@ -132,10 +138,14 @@ class ToolUI:
132
138
 
133
139
  if resp == "2":
134
140
  return ToolConfirmationResponse(approved=True, skip_future=True)
135
- elif resp == "3":
136
- return ToolConfirmationResponse(approved=False, abort=True)
137
- else:
138
- return ToolConfirmationResponse(approved=True)
141
+ if resp == "3":
142
+ instructions = await self._prompt_rejection_feedback(state_manager)
143
+ return ToolConfirmationResponse(
144
+ approved=False,
145
+ abort=True,
146
+ instructions=instructions,
147
+ )
148
+ return ToolConfirmationResponse(approved=True)
139
149
 
140
150
  def show_sync_confirmation(self, request: ToolConfirmationRequest) -> ToolConfirmationResponse:
141
151
  """
@@ -188,10 +198,23 @@ class ToolUI:
188
198
 
189
199
  if resp == "2":
190
200
  return ToolConfirmationResponse(approved=True, skip_future=True)
191
- elif resp == "3":
192
- return ToolConfirmationResponse(approved=False, abort=True)
193
- else:
194
- return ToolConfirmationResponse(approved=True)
201
+ if resp == "3":
202
+ instructions = self._prompt_rejection_feedback_sync()
203
+ return ToolConfirmationResponse(approved=False, abort=True, instructions=instructions)
204
+ return ToolConfirmationResponse(approved=True)
205
+
206
+ async def _prompt_rejection_feedback(self, state_manager: Optional["StateManager"]) -> str:
207
+ guidance = await ui.input(
208
+ session_key=self.REJECTION_FEEDBACK_SESSION,
209
+ pretext=self.REJECTION_GUIDANCE_PROMPT,
210
+ state_manager=state_manager,
211
+ )
212
+ return guidance.strip() if guidance else ""
213
+
214
+ def _prompt_rejection_feedback_sync(self) -> str:
215
+ guidance = input(self.REJECTION_GUIDANCE_PROMPT).strip()
216
+ ui.console.print()
217
+ return guidance
195
218
 
196
219
  async def log_mcp(self, title: str, args: ToolArgs) -> None:
197
220
  """
@@ -0,0 +1,93 @@
1
+ """
2
+ Module: tunacode.utils.api_key_validation
3
+
4
+ Utilities for validating API keys are configured for the selected model.
5
+ """
6
+
7
+ from typing import Optional, Tuple
8
+
9
+ from tunacode.types import UserConfig
10
+
11
+
12
+ def get_required_api_key_for_model(model: str) -> Tuple[Optional[str], str]:
13
+ """
14
+ Determine which API key is required for a given model.
15
+
16
+ Args:
17
+ model: Model identifier in format "provider:model-name"
18
+
19
+ Returns:
20
+ Tuple of (api_key_name, provider_name) or (None, "unknown") if no specific key required
21
+ """
22
+ if not model or ":" not in model:
23
+ return None, "unknown"
24
+
25
+ provider = model.split(":")[0].lower()
26
+
27
+ # Map providers to their required API keys
28
+ provider_key_map = {
29
+ "openrouter": ("OPENROUTER_API_KEY", "OpenRouter"),
30
+ "openai": ("OPENAI_API_KEY", "OpenAI"),
31
+ "anthropic": ("ANTHROPIC_API_KEY", "Anthropic"),
32
+ "google": ("GEMINI_API_KEY", "Google"),
33
+ "google-gla": ("GEMINI_API_KEY", "Google"),
34
+ "gemini": ("GEMINI_API_KEY", "Google"),
35
+ }
36
+
37
+ return provider_key_map.get(provider, (None, provider))
38
+
39
+
40
+ def validate_api_key_for_model(model: str, user_config: UserConfig) -> Tuple[bool, Optional[str]]:
41
+ """
42
+ Check if the required API key exists for the given model.
43
+
44
+ Args:
45
+ model: Model identifier in format "provider:model-name"
46
+ user_config: User configuration containing env variables
47
+
48
+ Returns:
49
+ Tuple of (is_valid, error_message)
50
+ """
51
+ api_key_name, provider_name = get_required_api_key_for_model(model)
52
+
53
+ if not api_key_name:
54
+ # No specific API key required (might be custom endpoint)
55
+ return True, None
56
+
57
+ env_config = user_config.get("env", {})
58
+ api_key = env_config.get(api_key_name, "").strip()
59
+
60
+ if not api_key:
61
+ return False, (
62
+ f"No API key found for {provider_name}.\n"
63
+ f"Please run 'tunacode --setup' to configure your API key."
64
+ )
65
+
66
+ return True, None
67
+
68
+
69
+ def get_configured_providers(user_config: UserConfig) -> list[str]:
70
+ """
71
+ Get list of providers that have API keys configured.
72
+
73
+ Args:
74
+ user_config: User configuration containing env variables
75
+
76
+ Returns:
77
+ List of provider names that have API keys set
78
+ """
79
+ env_config = user_config.get("env", {})
80
+ configured = []
81
+
82
+ provider_map = {
83
+ "OPENROUTER_API_KEY": "openrouter",
84
+ "OPENAI_API_KEY": "openai",
85
+ "ANTHROPIC_API_KEY": "anthropic",
86
+ "GEMINI_API_KEY": "google",
87
+ }
88
+
89
+ for key_name, provider in provider_map.items():
90
+ if env_config.get(key_name, "").strip():
91
+ configured.append(provider)
92
+
93
+ return configured