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.
- tunacode/cli/commands/__init__.py +2 -2
- tunacode/cli/commands/implementations/__init__.py +2 -3
- tunacode/cli/commands/implementations/command_reload.py +48 -0
- 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/quickstart.py +43 -0
- tunacode/cli/commands/implementations/system.py +96 -3
- tunacode/cli/commands/implementations/template.py +0 -2
- tunacode/cli/commands/registry.py +139 -5
- tunacode/cli/commands/slash/__init__.py +32 -0
- tunacode/cli/commands/slash/command.py +157 -0
- tunacode/cli/commands/slash/loader.py +135 -0
- tunacode/cli/commands/slash/processor.py +294 -0
- tunacode/cli/commands/slash/types.py +93 -0
- tunacode/cli/commands/slash/validator.py +400 -0
- tunacode/cli/main.py +23 -2
- tunacode/cli/repl.py +217 -190
- tunacode/cli/repl_components/command_parser.py +38 -4
- tunacode/cli/repl_components/error_recovery.py +85 -4
- tunacode/cli/repl_components/output_display.py +12 -1
- tunacode/cli/repl_components/tool_executor.py +1 -1
- tunacode/configuration/defaults.py +12 -3
- tunacode/configuration/key_descriptions.py +284 -0
- tunacode/configuration/settings.py +0 -1
- tunacode/constants.py +12 -40
- tunacode/core/agents/__init__.py +43 -2
- tunacode/core/agents/agent_components/__init__.py +7 -0
- tunacode/core/agents/agent_components/agent_config.py +249 -55
- tunacode/core/agents/agent_components/agent_helpers.py +43 -13
- tunacode/core/agents/agent_components/node_processor.py +179 -139
- 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 -121
- tunacode/core/code_index.py +83 -29
- tunacode/core/setup/__init__.py +0 -2
- tunacode/core/setup/config_setup.py +110 -20
- tunacode/core/setup/config_wizard.py +230 -0
- tunacode/core/setup/coordinator.py +14 -5
- tunacode/core/state.py +16 -20
- tunacode/core/token_usage/usage_tracker.py +5 -3
- tunacode/core/tool_authorization.py +352 -0
- tunacode/core/tool_handler.py +67 -40
- tunacode/exceptions.py +119 -5
- tunacode/prompts/system.xml +751 -0
- tunacode/services/mcp.py +125 -7
- tunacode/setup.py +5 -25
- tunacode/tools/base.py +163 -0
- tunacode/tools/bash.py +110 -1
- tunacode/tools/glob.py +332 -34
- tunacode/tools/grep.py +179 -82
- tunacode/tools/grep_components/result_formatter.py +98 -4
- tunacode/tools/list_dir.py +132 -2
- tunacode/tools/prompts/bash_prompt.xml +72 -0
- tunacode/tools/prompts/glob_prompt.xml +45 -0
- tunacode/tools/prompts/grep_prompt.xml +98 -0
- tunacode/tools/prompts/list_dir_prompt.xml +31 -0
- tunacode/tools/prompts/react_prompt.xml +23 -0
- tunacode/tools/prompts/read_file_prompt.xml +54 -0
- tunacode/tools/prompts/run_command_prompt.xml +64 -0
- tunacode/tools/prompts/update_file_prompt.xml +53 -0
- tunacode/tools/prompts/write_file_prompt.xml +37 -0
- tunacode/tools/react.py +153 -0
- tunacode/tools/read_file.py +91 -0
- tunacode/tools/run_command.py +114 -0
- tunacode/tools/schema_assembler.py +167 -0
- tunacode/tools/update_file.py +94 -0
- tunacode/tools/write_file.py +86 -0
- tunacode/tools/xml_helper.py +83 -0
- tunacode/tutorial/__init__.py +9 -0
- tunacode/tutorial/content.py +98 -0
- tunacode/tutorial/manager.py +182 -0
- tunacode/tutorial/steps.py +124 -0
- tunacode/types.py +20 -27
- tunacode/ui/completers.py +434 -50
- tunacode/ui/config_dashboard.py +585 -0
- tunacode/ui/console.py +63 -11
- tunacode/ui/input.py +20 -3
- tunacode/ui/keybindings.py +7 -4
- tunacode/ui/model_selector.py +395 -0
- tunacode/ui/output.py +40 -19
- tunacode/ui/panels.py +212 -43
- tunacode/ui/path_heuristics.py +91 -0
- tunacode/ui/prompt_manager.py +5 -1
- tunacode/ui/tool_ui.py +33 -10
- tunacode/utils/api_key_validation.py +93 -0
- tunacode/utils/config_comparator.py +340 -0
- tunacode/utils/json_utils.py +206 -0
- tunacode/utils/message_utils.py +14 -4
- tunacode/utils/models_registry.py +593 -0
- tunacode/utils/ripgrep.py +332 -9
- tunacode/utils/text_utils.py +18 -1
- tunacode/utils/user_configuration.py +45 -0
- tunacode_cli-0.0.78.6.dist-info/METADATA +260 -0
- tunacode_cli-0.0.78.6.dist-info/RECORD +158 -0
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/WHEEL +1 -2
- tunacode/cli/commands/implementations/todo.py +0 -217
- tunacode/context.py +0 -71
- tunacode/core/setup/git_safety_setup.py +0 -182
- tunacode/prompts/system.md +0 -731
- tunacode/tools/read_file_async_poc.py +0 -196
- tunacode/tools/todo.py +0 -349
- tunacode_cli-0.0.55.dist-info/METADATA +0 -322
- tunacode_cli-0.0.55.dist-info/RECORD +0 -126
- tunacode_cli-0.0.55.dist-info/top_level.txt +0 -1
- {tunacode_cli-0.0.55.dist-info → tunacode_cli-0.0.78.6.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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 = ""
|
|
@@ -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 =
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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.
|
|
212
|
+
await asyncio.sleep(0.2) # Faster animation cycle
|
|
147
213
|
current_time = time.time()
|
|
148
|
-
#
|
|
149
|
-
if
|
|
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
|
-
|
|
156
|
-
self.
|
|
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
|
|
238
|
+
from .output import get_console
|
|
239
|
+
|
|
240
|
+
rich = get_rich_components()
|
|
161
241
|
|
|
162
|
-
self.live = Live(self._create_panel(), console=
|
|
242
|
+
self.live = rich["Live"](self._create_panel(), console=get_console(), refresh_per_second=4)
|
|
163
243
|
self.live.start()
|
|
164
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
tunacode/ui/prompt_manager.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|