tunacode-cli 0.0.70__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 (90) hide show
  1. tunacode/cli/commands/__init__.py +0 -2
  2. tunacode/cli/commands/implementations/__init__.py +0 -3
  3. tunacode/cli/commands/implementations/debug.py +2 -2
  4. tunacode/cli/commands/implementations/development.py +10 -8
  5. tunacode/cli/commands/implementations/model.py +357 -29
  6. tunacode/cli/commands/implementations/system.py +3 -2
  7. tunacode/cli/commands/implementations/template.py +0 -2
  8. tunacode/cli/commands/registry.py +8 -7
  9. tunacode/cli/commands/slash/loader.py +2 -1
  10. tunacode/cli/commands/slash/validator.py +2 -1
  11. tunacode/cli/main.py +19 -1
  12. tunacode/cli/repl.py +90 -229
  13. tunacode/cli/repl_components/command_parser.py +2 -1
  14. tunacode/cli/repl_components/error_recovery.py +8 -5
  15. tunacode/cli/repl_components/output_display.py +1 -10
  16. tunacode/cli/repl_components/tool_executor.py +1 -13
  17. tunacode/configuration/defaults.py +2 -2
  18. tunacode/configuration/key_descriptions.py +284 -0
  19. tunacode/configuration/settings.py +0 -1
  20. tunacode/constants.py +6 -42
  21. tunacode/core/agents/__init__.py +43 -2
  22. tunacode/core/agents/agent_components/__init__.py +7 -0
  23. tunacode/core/agents/agent_components/agent_config.py +162 -158
  24. tunacode/core/agents/agent_components/agent_helpers.py +31 -2
  25. tunacode/core/agents/agent_components/node_processor.py +180 -146
  26. tunacode/core/agents/agent_components/response_state.py +123 -6
  27. tunacode/core/agents/agent_components/state_transition.py +116 -0
  28. tunacode/core/agents/agent_components/streaming.py +296 -0
  29. tunacode/core/agents/agent_components/task_completion.py +19 -6
  30. tunacode/core/agents/agent_components/tool_buffer.py +21 -1
  31. tunacode/core/agents/agent_components/tool_executor.py +10 -0
  32. tunacode/core/agents/main.py +522 -370
  33. tunacode/core/agents/main_legact.py +538 -0
  34. tunacode/core/agents/prompts.py +66 -0
  35. tunacode/core/agents/utils.py +29 -122
  36. tunacode/core/setup/__init__.py +0 -2
  37. tunacode/core/setup/config_setup.py +88 -227
  38. tunacode/core/setup/config_wizard.py +230 -0
  39. tunacode/core/setup/coordinator.py +2 -1
  40. tunacode/core/state.py +16 -64
  41. tunacode/core/token_usage/usage_tracker.py +3 -1
  42. tunacode/core/tool_authorization.py +352 -0
  43. tunacode/core/tool_handler.py +67 -60
  44. tunacode/prompts/system.xml +751 -0
  45. tunacode/services/mcp.py +97 -1
  46. tunacode/setup.py +0 -23
  47. tunacode/tools/base.py +54 -1
  48. tunacode/tools/bash.py +14 -0
  49. tunacode/tools/glob.py +4 -2
  50. tunacode/tools/grep.py +7 -17
  51. tunacode/tools/prompts/glob_prompt.xml +1 -1
  52. tunacode/tools/prompts/grep_prompt.xml +1 -0
  53. tunacode/tools/prompts/list_dir_prompt.xml +1 -1
  54. tunacode/tools/prompts/react_prompt.xml +23 -0
  55. tunacode/tools/prompts/read_file_prompt.xml +1 -1
  56. tunacode/tools/react.py +153 -0
  57. tunacode/tools/run_command.py +15 -0
  58. tunacode/types.py +14 -79
  59. tunacode/ui/completers.py +434 -50
  60. tunacode/ui/config_dashboard.py +585 -0
  61. tunacode/ui/console.py +63 -11
  62. tunacode/ui/input.py +8 -3
  63. tunacode/ui/keybindings.py +0 -18
  64. tunacode/ui/model_selector.py +395 -0
  65. tunacode/ui/output.py +40 -19
  66. tunacode/ui/panels.py +173 -49
  67. tunacode/ui/path_heuristics.py +91 -0
  68. tunacode/ui/prompt_manager.py +1 -20
  69. tunacode/ui/tool_ui.py +30 -8
  70. tunacode/utils/api_key_validation.py +93 -0
  71. tunacode/utils/config_comparator.py +340 -0
  72. tunacode/utils/models_registry.py +593 -0
  73. tunacode/utils/text_utils.py +18 -1
  74. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/METADATA +80 -12
  75. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/RECORD +78 -74
  76. tunacode/cli/commands/implementations/plan.py +0 -50
  77. tunacode/cli/commands/implementations/todo.py +0 -217
  78. tunacode/context.py +0 -71
  79. tunacode/core/setup/git_safety_setup.py +0 -186
  80. tunacode/prompts/system.md +0 -359
  81. tunacode/prompts/system.md.bak +0 -487
  82. tunacode/tools/exit_plan_mode.py +0 -273
  83. tunacode/tools/present_plan.py +0 -288
  84. tunacode/tools/prompts/exit_plan_mode_prompt.xml +0 -25
  85. tunacode/tools/prompts/present_plan_prompt.xml +0 -20
  86. tunacode/tools/prompts/todo_prompt.xml +0 -96
  87. tunacode/tools/todo.py +0 -456
  88. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +0 -0
  89. {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
  90. {tunacode_cli-0.0.70.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 = ""
@@ -101,14 +142,31 @@ class StreamingAgentPanel:
101
142
  self._dots_task = None
102
143
  self._dots_count = 0
103
144
  self._show_dots = True # Start with dots enabled for "Thinking..."
104
-
105
- def _create_panel(self) -> Padding:
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
172
  # Apply dots animation to "Thinking..." message too
@@ -119,7 +177,7 @@ class StreamingAgentPanel:
119
177
  dots_patterns = ["", ".", "..", "..."]
120
178
  dots = dots_patterns[self._dots_count % len(dots_patterns)]
121
179
  thinking_msg = base_msg + dots
122
- content_renderable: Union[Text, Markdown] = Text.from_markup(thinking_msg)
180
+ content_renderable: RenderableContent = rich["Text"].from_markup(thinking_msg)
123
181
  else:
124
182
  # Once we have content, show it with optional dots animation
125
183
  display_content = self.content
@@ -129,16 +187,16 @@ class StreamingAgentPanel:
129
187
  dots_patterns = ["", ".", "..", "..."]
130
188
  dots = dots_patterns[self._dots_count % len(dots_patterns)]
131
189
  display_content = self.content.rstrip() + dots
132
- content_renderable = Markdown(display_content)
133
- panel_obj = Panel(
134
- 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)),
135
193
  title=f"[bold]{self.title}[/bold]",
136
194
  title_align="left",
137
195
  border_style=colors.primary,
138
196
  padding=(0, 1),
139
- box=ROUNDED,
197
+ box=rich["ROUNDED"],
140
198
  )
141
- return Padding(
199
+ return rich["Padding"](
142
200
  panel_obj,
143
201
  (
144
202
  DEFAULT_PANEL_PADDING["top"],
@@ -159,6 +217,14 @@ class StreamingAgentPanel:
159
217
  if current_time - self._last_update_time > delay_threshold:
160
218
  self._show_dots = True
161
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)
162
228
  if self.live:
163
229
  self.live.update(self._create_panel())
164
230
  else:
@@ -169,10 +235,13 @@ class StreamingAgentPanel:
169
235
 
170
236
  async def start(self):
171
237
  """Start the live streaming display."""
172
- from .output import console
238
+ from .output import get_console
173
239
 
174
- self.live = Live(self._create_panel(), console=console, refresh_per_second=4)
240
+ rich = get_rich_components()
241
+
242
+ self.live = rich["Live"](self._create_panel(), console=get_console(), refresh_per_second=4)
175
243
  self.live.start()
244
+ self._log_debug("start")
176
245
  # For "Thinking...", set time in past to trigger dots immediately
177
246
  if not self.content:
178
247
  self._last_update_time = time.time() - 0.4 # Triggers dots on first cycle
@@ -187,46 +256,80 @@ class StreamingAgentPanel:
187
256
  if content_chunk is None:
188
257
  content_chunk = ""
189
258
 
190
- # Filter out plan mode system prompts and tool definitions from streaming
191
- if any(
192
- phrase in str(content_chunk)
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
193
264
  for phrase in [
194
- "🔧 PLAN MODE",
195
- "TOOL EXECUTION ONLY",
196
- "planning assistant that ONLY communicates",
197
265
  "namespace functions {",
198
266
  "namespace multi_tool_use {",
199
- "You are trained on data up to",
200
267
  ]
201
268
  ):
202
269
  return
203
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
+
204
281
  # Ensure type safety for concatenation
205
- 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
206
292
 
207
293
  # Reset the update timer when we get new content
208
294
  self._last_update_time = time.time()
209
- 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
210
299
 
211
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)
212
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)
213
308
 
214
309
  async def set_content(self, content: str):
215
310
  """Set the complete content (overwrites previous)."""
216
311
  # Filter out plan mode system prompts and tool definitions
217
- if any(
218
- phrase in str(content)
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
219
316
  for phrase in [
220
- "🔧 PLAN MODE",
221
- "TOOL EXECUTION ONLY",
222
- "planning assistant that ONLY communicates",
223
317
  "namespace functions {",
224
318
  "namespace multi_tool_use {",
225
- "You are trained on data up to",
226
319
  ]
227
320
  ):
228
321
  return
229
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
+
230
333
  self.content = content
231
334
  if self.live:
232
335
  self.live.update(self._create_panel())
@@ -240,6 +343,8 @@ class StreamingAgentPanel:
240
343
  await self._dots_task
241
344
  except asyncio.CancelledError:
242
345
  pass
346
+ finally:
347
+ self._log_debug("dots_task_cancelled")
243
348
 
244
349
  if self.live:
245
350
  # Get the console before stopping the live display
@@ -263,6 +368,23 @@ class StreamingAgentPanel:
263
368
 
264
369
  self.live = None
265
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
+
266
388
 
267
389
  async def agent_streaming(content_stream, bottom: int = 1):
268
390
  """Display an agent panel with streaming content updates.
@@ -290,14 +412,15 @@ async def error(text: str) -> None:
290
412
 
291
413
  async def dump_messages(messages_list=None, state_manager: StateManager = None) -> None:
292
414
  """Display message history panel."""
415
+ rich = get_rich_components()
293
416
  if messages_list is None and state_manager:
294
417
  # Get messages from state manager
295
- messages = Pretty(state_manager.session.messages)
418
+ messages = rich["Pretty"](state_manager.session.messages)
296
419
  elif messages_list is not None:
297
- messages = Pretty(messages_list)
420
+ messages = rich["Pretty"](messages_list)
298
421
  else:
299
422
  # No messages available
300
- messages = Pretty([])
423
+ messages = rich["Pretty"]([])
301
424
  await panel(PANEL_MESSAGE_HISTORY, messages, style=colors.muted)
302
425
 
303
426
 
@@ -313,7 +436,8 @@ async def models(state_manager: StateManager = None) -> None:
313
436
 
314
437
  async def help(command_registry=None) -> None:
315
438
  """Display the available commands organized by category."""
316
- 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))
317
441
  table.add_column("Command", style=f"bold {colors.primary}", justify="right", min_width=18)
318
442
  table.add_column("Description", style=colors.muted)
319
443
 
@@ -373,7 +497,7 @@ async def help(command_registry=None) -> None:
373
497
 
374
498
  @create_sync_wrapper
375
499
  async def tool_confirm(
376
- title: str, content: Union[str, Markdown], filepath: Optional[str] = None
500
+ title: str, content: Union[str, "Markdown"], filepath: Optional[str] = None
377
501
  ) -> None:
378
502
  """Display a tool confirmation panel."""
379
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)
@@ -100,30 +100,11 @@ 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 and plan mode
103
+ # Create a custom prompt that changes based on input
104
104
  def get_prompt():
105
105
  # Start with the base prompt
106
106
  base_prompt = prompt
107
107
 
108
- # Add Plan Mode indicator if active
109
- if (
110
- self.state_manager
111
- and self.state_manager.is_plan_mode()
112
- and "PLAN MODE ON" not in base_prompt
113
- ):
114
- base_prompt = (
115
- '<style fg="#40E0D0"><bold>⏸ PLAN MODE ON</bold></style>\n' + base_prompt
116
- )
117
- elif (
118
- self.state_manager
119
- and not self.state_manager.is_plan_mode()
120
- and ("⏸" in base_prompt or "PLAN MODE ON" in base_prompt)
121
- ):
122
- # Remove plan mode indicator if no longer in plan mode
123
- lines = base_prompt.split("\n")
124
- if len(lines) > 1 and ("⏸" in lines[0] or "PLAN MODE ON" in lines[0]):
125
- base_prompt = "\n".join(lines[1:])
126
-
127
108
  # Check if current buffer starts with "!"
128
109
  if hasattr(session.app, "current_buffer") and session.app.current_buffer:
129
110
  text = session.app.current_buffer.text
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
 
@@ -133,10 +138,14 @@ class ToolUI:
133
138
 
134
139
  if resp == "2":
135
140
  return ToolConfirmationResponse(approved=True, skip_future=True)
136
- elif resp == "3":
137
- return ToolConfirmationResponse(approved=False, abort=True)
138
- else:
139
- 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)
140
149
 
141
150
  def show_sync_confirmation(self, request: ToolConfirmationRequest) -> ToolConfirmationResponse:
142
151
  """
@@ -189,10 +198,23 @@ class ToolUI:
189
198
 
190
199
  if resp == "2":
191
200
  return ToolConfirmationResponse(approved=True, skip_future=True)
192
- elif resp == "3":
193
- return ToolConfirmationResponse(approved=False, abort=True)
194
- else:
195
- 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
196
218
 
197
219
  async def log_mcp(self, title: str, args: ToolArgs) -> None:
198
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