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.
- tunacode/cli/commands/__init__.py +0 -2
- tunacode/cli/commands/implementations/__init__.py +0 -3
- tunacode/cli/commands/implementations/debug.py +2 -2
- tunacode/cli/commands/implementations/development.py +10 -8
- tunacode/cli/commands/implementations/model.py +357 -29
- tunacode/cli/commands/implementations/system.py +3 -2
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +8 -7
- tunacode/cli/commands/slash/loader.py +2 -1
- tunacode/cli/commands/slash/validator.py +2 -1
- tunacode/cli/main.py +19 -1
- tunacode/cli/repl.py +90 -229
- tunacode/cli/repl_components/command_parser.py +2 -1
- tunacode/cli/repl_components/error_recovery.py +8 -5
- tunacode/cli/repl_components/output_display.py +1 -10
- tunacode/cli/repl_components/tool_executor.py +1 -13
- tunacode/configuration/defaults.py +2 -2
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +6 -42
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +162 -158
- tunacode/core/agents/agent_components/agent_helpers.py +31 -2
- tunacode/core/agents/agent_components/node_processor.py +180 -146
- tunacode/core/agents/agent_components/response_state.py +123 -6
- tunacode/core/agents/agent_components/state_transition.py +116 -0
- tunacode/core/agents/agent_components/streaming.py +296 -0
- tunacode/core/agents/agent_components/task_completion.py +19 -6
- tunacode/core/agents/agent_components/tool_buffer.py +21 -1
- tunacode/core/agents/agent_components/tool_executor.py +10 -0
- tunacode/core/agents/main.py +522 -370
- tunacode/core/agents/main_legact.py +538 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/utils.py +29 -122
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +88 -227
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +2 -1
- tunacode/core/state.py +16 -64
- tunacode/core/token_usage/usage_tracker.py +3 -1
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -60
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +97 -1
- tunacode/setup.py +0 -23
- tunacode/tools/base.py +54 -1
- tunacode/tools/bash.py +14 -0
- tunacode/tools/glob.py +4 -2
- tunacode/tools/grep.py +7 -17
- tunacode/tools/prompts/glob_prompt.xml +1 -1
- tunacode/tools/prompts/grep_prompt.xml +1 -0
- tunacode/tools/prompts/list_dir_prompt.xml +1 -1
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +1 -1
- tunacode/tools/react.py +153 -0
- tunacode/tools/run_command.py +15 -0
- tunacode/types.py +14 -79
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +8 -3
- tunacode/ui/keybindings.py +0 -18
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +173 -49
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +1 -20
- tunacode/ui/tool_ui.py +30 -8
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/text_utils.py +18 -1
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/METADATA +80 -12
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/RECORD +78 -74
- tunacode/cli/commands/implementations/plan.py +0 -50
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -186
- tunacode/prompts/system.md +0 -359
- tunacode/prompts/system.md.bak +0 -487
- tunacode/tools/exit_plan_mode.py +0 -273
- tunacode/tools/present_plan.py +0 -288
- tunacode/tools/prompts/exit_plan_mode_prompt.xml +0 -25
- tunacode/tools/prompts/present_plan_prompt.xml +0 -20
- tunacode/tools/prompts/todo_prompt.xml +0 -96
- tunacode/tools/todo.py +0 -456
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.70.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
8
|
-
from rich.
|
|
9
|
-
from rich.
|
|
10
|
-
from rich.
|
|
11
|
-
from rich.
|
|
12
|
-
from rich.
|
|
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[
|
|
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
|
-
|
|
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:
|
|
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
|
|
238
|
+
from .output import get_console
|
|
173
239
|
|
|
174
|
-
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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)
|
tunacode/ui/prompt_manager.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|