tunacode-cli 0.1.21__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/__init__.py +0 -0
- tunacode/cli/textual_repl.tcss +283 -0
- tunacode/configuration/__init__.py +1 -0
- tunacode/configuration/defaults.py +45 -0
- tunacode/configuration/models.py +147 -0
- tunacode/configuration/models_registry.json +1 -0
- tunacode/configuration/pricing.py +74 -0
- tunacode/configuration/settings.py +35 -0
- tunacode/constants.py +227 -0
- tunacode/core/__init__.py +6 -0
- tunacode/core/agents/__init__.py +39 -0
- tunacode/core/agents/agent_components/__init__.py +48 -0
- tunacode/core/agents/agent_components/agent_config.py +441 -0
- tunacode/core/agents/agent_components/agent_helpers.py +290 -0
- tunacode/core/agents/agent_components/message_handler.py +99 -0
- tunacode/core/agents/agent_components/node_processor.py +477 -0
- tunacode/core/agents/agent_components/response_state.py +129 -0
- tunacode/core/agents/agent_components/result_wrapper.py +51 -0
- tunacode/core/agents/agent_components/state_transition.py +112 -0
- tunacode/core/agents/agent_components/streaming.py +271 -0
- tunacode/core/agents/agent_components/task_completion.py +40 -0
- tunacode/core/agents/agent_components/tool_buffer.py +44 -0
- tunacode/core/agents/agent_components/tool_executor.py +101 -0
- tunacode/core/agents/agent_components/truncation_checker.py +37 -0
- tunacode/core/agents/delegation_tools.py +109 -0
- tunacode/core/agents/main.py +545 -0
- tunacode/core/agents/prompts.py +66 -0
- tunacode/core/agents/research_agent.py +231 -0
- tunacode/core/compaction.py +218 -0
- tunacode/core/prompting/__init__.py +27 -0
- tunacode/core/prompting/loader.py +66 -0
- tunacode/core/prompting/prompting_engine.py +98 -0
- tunacode/core/prompting/sections.py +50 -0
- tunacode/core/prompting/templates.py +69 -0
- tunacode/core/state.py +409 -0
- tunacode/exceptions.py +313 -0
- tunacode/indexing/__init__.py +5 -0
- tunacode/indexing/code_index.py +432 -0
- tunacode/indexing/constants.py +86 -0
- tunacode/lsp/__init__.py +112 -0
- tunacode/lsp/client.py +351 -0
- tunacode/lsp/diagnostics.py +19 -0
- tunacode/lsp/servers.py +101 -0
- tunacode/prompts/default_prompt.md +952 -0
- tunacode/prompts/research/sections/agent_role.xml +5 -0
- tunacode/prompts/research/sections/constraints.xml +14 -0
- tunacode/prompts/research/sections/output_format.xml +57 -0
- tunacode/prompts/research/sections/tool_use.xml +23 -0
- tunacode/prompts/sections/advanced_patterns.xml +255 -0
- tunacode/prompts/sections/agent_role.xml +8 -0
- tunacode/prompts/sections/completion.xml +10 -0
- tunacode/prompts/sections/critical_rules.xml +37 -0
- tunacode/prompts/sections/examples.xml +220 -0
- tunacode/prompts/sections/output_style.xml +94 -0
- tunacode/prompts/sections/parallel_exec.xml +105 -0
- tunacode/prompts/sections/search_pattern.xml +100 -0
- tunacode/prompts/sections/system_info.xml +6 -0
- tunacode/prompts/sections/tool_use.xml +84 -0
- tunacode/prompts/sections/user_instructions.xml +3 -0
- tunacode/py.typed +0 -0
- tunacode/templates/__init__.py +5 -0
- tunacode/templates/loader.py +15 -0
- tunacode/tools/__init__.py +10 -0
- tunacode/tools/authorization/__init__.py +29 -0
- tunacode/tools/authorization/context.py +32 -0
- tunacode/tools/authorization/factory.py +20 -0
- tunacode/tools/authorization/handler.py +58 -0
- tunacode/tools/authorization/notifier.py +35 -0
- tunacode/tools/authorization/policy.py +19 -0
- tunacode/tools/authorization/requests.py +119 -0
- tunacode/tools/authorization/rules.py +72 -0
- tunacode/tools/bash.py +222 -0
- tunacode/tools/decorators.py +213 -0
- tunacode/tools/glob.py +353 -0
- tunacode/tools/grep.py +468 -0
- tunacode/tools/grep_components/__init__.py +9 -0
- tunacode/tools/grep_components/file_filter.py +93 -0
- tunacode/tools/grep_components/pattern_matcher.py +158 -0
- tunacode/tools/grep_components/result_formatter.py +87 -0
- tunacode/tools/grep_components/search_result.py +34 -0
- tunacode/tools/list_dir.py +205 -0
- tunacode/tools/prompts/bash_prompt.xml +10 -0
- tunacode/tools/prompts/glob_prompt.xml +7 -0
- tunacode/tools/prompts/grep_prompt.xml +10 -0
- tunacode/tools/prompts/list_dir_prompt.xml +7 -0
- tunacode/tools/prompts/read_file_prompt.xml +9 -0
- tunacode/tools/prompts/todoclear_prompt.xml +12 -0
- tunacode/tools/prompts/todoread_prompt.xml +16 -0
- tunacode/tools/prompts/todowrite_prompt.xml +28 -0
- tunacode/tools/prompts/update_file_prompt.xml +9 -0
- tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
- tunacode/tools/prompts/write_file_prompt.xml +7 -0
- tunacode/tools/react.py +111 -0
- tunacode/tools/read_file.py +68 -0
- tunacode/tools/todo.py +222 -0
- tunacode/tools/update_file.py +62 -0
- tunacode/tools/utils/__init__.py +1 -0
- tunacode/tools/utils/ripgrep.py +311 -0
- tunacode/tools/utils/text_match.py +352 -0
- tunacode/tools/web_fetch.py +245 -0
- tunacode/tools/write_file.py +34 -0
- tunacode/tools/xml_helper.py +34 -0
- tunacode/types/__init__.py +166 -0
- tunacode/types/base.py +94 -0
- tunacode/types/callbacks.py +53 -0
- tunacode/types/dataclasses.py +121 -0
- tunacode/types/pydantic_ai.py +31 -0
- tunacode/types/state.py +122 -0
- tunacode/ui/__init__.py +6 -0
- tunacode/ui/app.py +542 -0
- tunacode/ui/commands/__init__.py +430 -0
- tunacode/ui/components/__init__.py +1 -0
- tunacode/ui/headless/__init__.py +5 -0
- tunacode/ui/headless/output.py +72 -0
- tunacode/ui/main.py +252 -0
- tunacode/ui/renderers/__init__.py +41 -0
- tunacode/ui/renderers/errors.py +197 -0
- tunacode/ui/renderers/panels.py +550 -0
- tunacode/ui/renderers/search.py +314 -0
- tunacode/ui/renderers/tools/__init__.py +21 -0
- tunacode/ui/renderers/tools/bash.py +247 -0
- tunacode/ui/renderers/tools/diagnostics.py +186 -0
- tunacode/ui/renderers/tools/glob.py +226 -0
- tunacode/ui/renderers/tools/grep.py +228 -0
- tunacode/ui/renderers/tools/list_dir.py +198 -0
- tunacode/ui/renderers/tools/read_file.py +226 -0
- tunacode/ui/renderers/tools/research.py +294 -0
- tunacode/ui/renderers/tools/update_file.py +237 -0
- tunacode/ui/renderers/tools/web_fetch.py +182 -0
- tunacode/ui/repl_support.py +226 -0
- tunacode/ui/screens/__init__.py +16 -0
- tunacode/ui/screens/model_picker.py +303 -0
- tunacode/ui/screens/session_picker.py +181 -0
- tunacode/ui/screens/setup.py +218 -0
- tunacode/ui/screens/theme_picker.py +90 -0
- tunacode/ui/screens/update_confirm.py +69 -0
- tunacode/ui/shell_runner.py +129 -0
- tunacode/ui/styles/layout.tcss +98 -0
- tunacode/ui/styles/modals.tcss +38 -0
- tunacode/ui/styles/panels.tcss +81 -0
- tunacode/ui/styles/theme-nextstep.tcss +303 -0
- tunacode/ui/styles/widgets.tcss +33 -0
- tunacode/ui/styles.py +18 -0
- tunacode/ui/widgets/__init__.py +23 -0
- tunacode/ui/widgets/command_autocomplete.py +62 -0
- tunacode/ui/widgets/editor.py +402 -0
- tunacode/ui/widgets/file_autocomplete.py +47 -0
- tunacode/ui/widgets/messages.py +46 -0
- tunacode/ui/widgets/resource_bar.py +182 -0
- tunacode/ui/widgets/status_bar.py +98 -0
- tunacode/utils/__init__.py +0 -0
- tunacode/utils/config/__init__.py +13 -0
- tunacode/utils/config/user_configuration.py +91 -0
- tunacode/utils/messaging/__init__.py +10 -0
- tunacode/utils/messaging/message_utils.py +34 -0
- tunacode/utils/messaging/token_counter.py +77 -0
- tunacode/utils/parsing/__init__.py +13 -0
- tunacode/utils/parsing/command_parser.py +55 -0
- tunacode/utils/parsing/json_utils.py +188 -0
- tunacode/utils/parsing/retry.py +146 -0
- tunacode/utils/parsing/tool_parser.py +267 -0
- tunacode/utils/security/__init__.py +15 -0
- tunacode/utils/security/command.py +106 -0
- tunacode/utils/system/__init__.py +25 -0
- tunacode/utils/system/gitignore.py +155 -0
- tunacode/utils/system/paths.py +190 -0
- tunacode/utils/ui/__init__.py +9 -0
- tunacode/utils/ui/file_filter.py +135 -0
- tunacode/utils/ui/helpers.py +24 -0
- tunacode_cli-0.1.21.dist-info/METADATA +170 -0
- tunacode_cli-0.1.21.dist-info/RECORD +174 -0
- tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
- tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
- tunacode_cli-0.1.21.dist-info/licenses/LICENSE +21 -0
tunacode/ui/app.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Textual-based REPL shell - Application entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual import events
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.binding import Binding
|
|
17
|
+
from textual.containers import Container
|
|
18
|
+
from textual.widgets import LoadingIndicator, RichLog, Static
|
|
19
|
+
|
|
20
|
+
from tunacode.constants import (
|
|
21
|
+
RICHLOG_CLASS_PAUSED,
|
|
22
|
+
RICHLOG_CLASS_STREAMING,
|
|
23
|
+
TOOL_PANEL_WIDTH,
|
|
24
|
+
build_nextstep_theme,
|
|
25
|
+
build_tunacode_theme,
|
|
26
|
+
)
|
|
27
|
+
from tunacode.core.agents.main import process_request
|
|
28
|
+
from tunacode.indexing import CodeIndex
|
|
29
|
+
from tunacode.indexing.constants import QUICK_INDEX_THRESHOLD
|
|
30
|
+
from tunacode.types import (
|
|
31
|
+
ModelName,
|
|
32
|
+
StateManager,
|
|
33
|
+
ToolConfirmationRequest,
|
|
34
|
+
ToolConfirmationResponse,
|
|
35
|
+
)
|
|
36
|
+
from tunacode.ui.renderers.errors import render_exception
|
|
37
|
+
from tunacode.ui.renderers.panels import tool_panel_smart
|
|
38
|
+
from tunacode.ui.repl_support import (
|
|
39
|
+
PendingConfirmationState,
|
|
40
|
+
build_textual_tool_callback,
|
|
41
|
+
build_tool_progress_callback,
|
|
42
|
+
build_tool_result_callback,
|
|
43
|
+
build_tool_start_callback,
|
|
44
|
+
format_user_message,
|
|
45
|
+
)
|
|
46
|
+
from tunacode.ui.shell_runner import ShellRunner
|
|
47
|
+
from tunacode.ui.styles import (
|
|
48
|
+
STYLE_ERROR,
|
|
49
|
+
STYLE_HEADING,
|
|
50
|
+
STYLE_MUTED,
|
|
51
|
+
STYLE_PRIMARY,
|
|
52
|
+
STYLE_SUBHEADING,
|
|
53
|
+
STYLE_SUCCESS,
|
|
54
|
+
STYLE_WARNING,
|
|
55
|
+
)
|
|
56
|
+
from tunacode.ui.widgets import (
|
|
57
|
+
CommandAutoComplete,
|
|
58
|
+
Editor,
|
|
59
|
+
EditorSubmitRequested,
|
|
60
|
+
FileAutoComplete,
|
|
61
|
+
ResourceBar,
|
|
62
|
+
StatusBar,
|
|
63
|
+
ToolResultDisplay,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Throttle streaming display updates to reduce visual churn
|
|
67
|
+
STREAM_THROTTLE_MS: float = 100.0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TextualReplApp(App[None]):
|
|
71
|
+
TITLE = "TunaCode"
|
|
72
|
+
CSS_PATH = [
|
|
73
|
+
"styles/layout.tcss",
|
|
74
|
+
"styles/widgets.tcss",
|
|
75
|
+
"styles/modals.tcss",
|
|
76
|
+
"styles/panels.tcss",
|
|
77
|
+
"styles/theme-nextstep.tcss",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
BINDINGS = [
|
|
81
|
+
Binding("ctrl+p", "toggle_pause", "Pause/Resume Stream", show=False, priority=True),
|
|
82
|
+
Binding("escape", "cancel_stream", "Cancel", show=False, priority=True),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def __init__(self, *, state_manager: StateManager, show_setup: bool = False) -> None:
|
|
86
|
+
super().__init__()
|
|
87
|
+
self.state_manager: StateManager = state_manager
|
|
88
|
+
self._show_setup: bool = show_setup
|
|
89
|
+
self.request_queue: asyncio.Queue[str] = asyncio.Queue()
|
|
90
|
+
self.pending_confirmation: PendingConfirmationState | None = None
|
|
91
|
+
|
|
92
|
+
self._streaming_paused: bool = False
|
|
93
|
+
self._streaming_cancelled: bool = False
|
|
94
|
+
self._stream_buffer: list[str] = []
|
|
95
|
+
self.current_stream_text: str = ""
|
|
96
|
+
self._current_request_task: asyncio.Task | None = None
|
|
97
|
+
self._loading_indicator_shown: bool = False
|
|
98
|
+
self._last_display_update: float = 0.0
|
|
99
|
+
|
|
100
|
+
self.shell_runner = ShellRunner(self)
|
|
101
|
+
|
|
102
|
+
self.rich_log: RichLog
|
|
103
|
+
self.editor: Editor
|
|
104
|
+
self.resource_bar: ResourceBar
|
|
105
|
+
self.status_bar: StatusBar
|
|
106
|
+
self.streaming_output: Static
|
|
107
|
+
|
|
108
|
+
def compose(self) -> ComposeResult:
|
|
109
|
+
self.resource_bar = ResourceBar()
|
|
110
|
+
self.rich_log = RichLog(wrap=True, markup=True, highlight=True, auto_scroll=True)
|
|
111
|
+
self.streaming_output = Static("", id="streaming-output")
|
|
112
|
+
self.loading_indicator = LoadingIndicator()
|
|
113
|
+
self.editor = Editor()
|
|
114
|
+
self.status_bar = StatusBar()
|
|
115
|
+
|
|
116
|
+
yield self.resource_bar
|
|
117
|
+
with Container(id="viewport"):
|
|
118
|
+
yield self.rich_log
|
|
119
|
+
yield self.streaming_output
|
|
120
|
+
yield self.loading_indicator
|
|
121
|
+
yield self.editor
|
|
122
|
+
yield FileAutoComplete(self.editor)
|
|
123
|
+
yield CommandAutoComplete(self.editor)
|
|
124
|
+
yield self.status_bar
|
|
125
|
+
|
|
126
|
+
def on_mount(self) -> None:
|
|
127
|
+
tunacode_theme = build_tunacode_theme()
|
|
128
|
+
self.register_theme(tunacode_theme)
|
|
129
|
+
nextstep_theme = build_nextstep_theme()
|
|
130
|
+
self.register_theme(nextstep_theme)
|
|
131
|
+
|
|
132
|
+
user_config = self.state_manager.session.user_config
|
|
133
|
+
saved_theme = user_config.get("settings", {}).get("theme", "dracula")
|
|
134
|
+
self.theme = saved_theme if saved_theme in self.available_themes else "dracula"
|
|
135
|
+
|
|
136
|
+
# Initialize session persistence metadata
|
|
137
|
+
from tunacode.utils.system.paths import get_project_id
|
|
138
|
+
|
|
139
|
+
session = self.state_manager.session
|
|
140
|
+
session.project_id = get_project_id()
|
|
141
|
+
session.working_directory = os.getcwd()
|
|
142
|
+
if not session.created_at:
|
|
143
|
+
session.created_at = datetime.now(UTC).isoformat()
|
|
144
|
+
|
|
145
|
+
if self._show_setup:
|
|
146
|
+
from tunacode.ui.screens import SetupScreen
|
|
147
|
+
|
|
148
|
+
self.push_screen(SetupScreen(self.state_manager), self._on_setup_complete)
|
|
149
|
+
else:
|
|
150
|
+
self._start_repl()
|
|
151
|
+
|
|
152
|
+
async def on_unmount(self) -> None:
|
|
153
|
+
"""Save session before app exits."""
|
|
154
|
+
self.state_manager.save_session()
|
|
155
|
+
|
|
156
|
+
def watch_theme(self, old_theme: str, new_theme: str) -> None:
|
|
157
|
+
"""Toggle CSS class when theme changes for theme-specific styling."""
|
|
158
|
+
if old_theme:
|
|
159
|
+
self.remove_class(f"theme-{old_theme}")
|
|
160
|
+
if new_theme:
|
|
161
|
+
self.add_class(f"theme-{new_theme}")
|
|
162
|
+
|
|
163
|
+
def _on_setup_complete(self, completed: bool) -> None:
|
|
164
|
+
"""Called when setup screen is dismissed."""
|
|
165
|
+
if completed:
|
|
166
|
+
self._update_resource_bar()
|
|
167
|
+
self._start_repl()
|
|
168
|
+
|
|
169
|
+
def _start_repl(self) -> None:
|
|
170
|
+
"""Initialize REPL components after setup."""
|
|
171
|
+
self.set_focus(self.editor)
|
|
172
|
+
self.run_worker(self._request_worker, exclusive=False)
|
|
173
|
+
self.run_worker(self._startup_index_worker, exclusive=False)
|
|
174
|
+
self._update_resource_bar()
|
|
175
|
+
self._show_welcome()
|
|
176
|
+
|
|
177
|
+
async def _startup_index_worker(self) -> None:
|
|
178
|
+
"""Build startup index with dynamic sizing."""
|
|
179
|
+
import asyncio
|
|
180
|
+
|
|
181
|
+
def do_index() -> tuple[int, int | None, bool]:
|
|
182
|
+
"""Returns (indexed_count, total_or_none, is_partial)."""
|
|
183
|
+
index = CodeIndex.get_instance()
|
|
184
|
+
total = index.quick_count()
|
|
185
|
+
|
|
186
|
+
if total < QUICK_INDEX_THRESHOLD:
|
|
187
|
+
index.build_index()
|
|
188
|
+
return len(index._all_files), None, False
|
|
189
|
+
else:
|
|
190
|
+
count = index.build_priority_index()
|
|
191
|
+
return count, total, True
|
|
192
|
+
|
|
193
|
+
loop = asyncio.get_event_loop()
|
|
194
|
+
indexed, total, is_partial = await loop.run_in_executor(None, do_index)
|
|
195
|
+
|
|
196
|
+
if is_partial:
|
|
197
|
+
msg = Text()
|
|
198
|
+
msg.append(
|
|
199
|
+
f"Code cache: {indexed}/{total} files indexed, expanding...",
|
|
200
|
+
style=STYLE_MUTED,
|
|
201
|
+
)
|
|
202
|
+
self.rich_log.write(msg)
|
|
203
|
+
|
|
204
|
+
# Expand in background
|
|
205
|
+
def do_expand() -> int:
|
|
206
|
+
index = CodeIndex.get_instance()
|
|
207
|
+
index.expand_index()
|
|
208
|
+
return len(index._all_files)
|
|
209
|
+
|
|
210
|
+
final_count = await loop.run_in_executor(None, do_expand)
|
|
211
|
+
done_msg = Text()
|
|
212
|
+
done_msg.append(f"Code cache built: {final_count} files indexed ✓", style=STYLE_SUCCESS)
|
|
213
|
+
self.rich_log.write(done_msg)
|
|
214
|
+
else:
|
|
215
|
+
msg = Text()
|
|
216
|
+
msg.append(f"Code cache built: {indexed} files indexed ✓", style=STYLE_SUCCESS)
|
|
217
|
+
self.rich_log.write(msg)
|
|
218
|
+
|
|
219
|
+
def _show_welcome(self) -> None:
|
|
220
|
+
welcome = Text()
|
|
221
|
+
welcome.append("Welcome to TunaCode\n", style=STYLE_HEADING)
|
|
222
|
+
welcome.append("AI coding assistant for your terminal.\n\n", style=STYLE_MUTED)
|
|
223
|
+
welcome.append("Commands:\n", style=STYLE_PRIMARY)
|
|
224
|
+
welcome.append(" /help - Show all commands\n", style="")
|
|
225
|
+
welcome.append(" /clear - Clear conversation\n", style="")
|
|
226
|
+
welcome.append(" /yolo - Toggle auto-confirm\n", style="")
|
|
227
|
+
welcome.append(" /branch - Create git branch\n", style="")
|
|
228
|
+
welcome.append(" /plan - Toggle planning mode\n", style="")
|
|
229
|
+
welcome.append(" /model - Switch model\n", style="")
|
|
230
|
+
welcome.append(" /theme - Switch theme\n", style="")
|
|
231
|
+
welcome.append(" /resume - Load saved session\n", style="")
|
|
232
|
+
welcome.append(" !<cmd> - Run shell command\n", style="")
|
|
233
|
+
self.rich_log.write(welcome)
|
|
234
|
+
|
|
235
|
+
async def _request_worker(self) -> None:
|
|
236
|
+
while True:
|
|
237
|
+
request = await self.request_queue.get()
|
|
238
|
+
try:
|
|
239
|
+
await self._process_request(request)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
error_renderable = render_exception(e)
|
|
242
|
+
self.rich_log.write(error_renderable)
|
|
243
|
+
finally:
|
|
244
|
+
self.request_queue.task_done()
|
|
245
|
+
|
|
246
|
+
async def _process_request(self, message: str) -> None:
|
|
247
|
+
self.current_stream_text = ""
|
|
248
|
+
self._last_display_update = 0.0
|
|
249
|
+
self._streaming_cancelled = False
|
|
250
|
+
self.query_one("#viewport").add_class(RICHLOG_CLASS_STREAMING)
|
|
251
|
+
|
|
252
|
+
self._loading_indicator_shown = True
|
|
253
|
+
self.loading_indicator.add_class("active")
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
model_name = self.state_manager.session.current_model or "openai/gpt-4o"
|
|
257
|
+
|
|
258
|
+
# Set progress callback on session for subagent progress tracking
|
|
259
|
+
self.state_manager.session.tool_progress_callback = build_tool_progress_callback(self)
|
|
260
|
+
|
|
261
|
+
self._current_request_task = asyncio.create_task(
|
|
262
|
+
process_request(
|
|
263
|
+
message=message,
|
|
264
|
+
model=ModelName(model_name),
|
|
265
|
+
state_manager=self.state_manager,
|
|
266
|
+
tool_callback=build_textual_tool_callback(self, self.state_manager),
|
|
267
|
+
streaming_callback=self.streaming_callback,
|
|
268
|
+
tool_result_callback=build_tool_result_callback(self),
|
|
269
|
+
tool_start_callback=build_tool_start_callback(self),
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
await self._current_request_task
|
|
273
|
+
except asyncio.CancelledError:
|
|
274
|
+
from tunacode.core.agents.agent_components import patch_tool_messages
|
|
275
|
+
|
|
276
|
+
patch_tool_messages(
|
|
277
|
+
"Operation cancelled by user",
|
|
278
|
+
state_manager=self.state_manager,
|
|
279
|
+
)
|
|
280
|
+
self.notify("Cancelled")
|
|
281
|
+
except Exception as e:
|
|
282
|
+
from tunacode.core.agents.agent_components import patch_tool_messages
|
|
283
|
+
|
|
284
|
+
patch_tool_messages(
|
|
285
|
+
f"Request failed: {type(e).__name__}",
|
|
286
|
+
state_manager=self.state_manager,
|
|
287
|
+
)
|
|
288
|
+
error_renderable = render_exception(e)
|
|
289
|
+
self.rich_log.write(error_renderable)
|
|
290
|
+
finally:
|
|
291
|
+
self._current_request_task = None
|
|
292
|
+
self._loading_indicator_shown = False
|
|
293
|
+
self.loading_indicator.remove_class("active")
|
|
294
|
+
self.query_one("#viewport").remove_class(RICHLOG_CLASS_STREAMING)
|
|
295
|
+
self.query_one("#viewport").remove_class(RICHLOG_CLASS_PAUSED)
|
|
296
|
+
self.streaming_output.update("")
|
|
297
|
+
self.streaming_output.remove_class("active")
|
|
298
|
+
|
|
299
|
+
if self.current_stream_text and not self._streaming_cancelled:
|
|
300
|
+
self.rich_log.write("")
|
|
301
|
+
self.rich_log.write(Text("agent:", style="accent"))
|
|
302
|
+
self.rich_log.write(Markdown(self.current_stream_text))
|
|
303
|
+
|
|
304
|
+
self.current_stream_text = ""
|
|
305
|
+
self._streaming_cancelled = False
|
|
306
|
+
self._update_resource_bar()
|
|
307
|
+
|
|
308
|
+
# Auto-save session after processing
|
|
309
|
+
self.state_manager.save_session()
|
|
310
|
+
|
|
311
|
+
async def on_editor_submit_requested(self, message: EditorSubmitRequested) -> None:
|
|
312
|
+
from tunacode.ui.commands import handle_command
|
|
313
|
+
|
|
314
|
+
if await handle_command(self, message.text):
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
await self.request_queue.put(message.text)
|
|
318
|
+
|
|
319
|
+
from datetime import datetime
|
|
320
|
+
|
|
321
|
+
timestamp = datetime.now().strftime("%I:%M %p").lstrip("0")
|
|
322
|
+
|
|
323
|
+
self.rich_log.write("")
|
|
324
|
+
render_width = max(1, self.rich_log.size.width - 2)
|
|
325
|
+
|
|
326
|
+
user_block = format_user_message(message.text, STYLE_PRIMARY, width=render_width)
|
|
327
|
+
|
|
328
|
+
user_block.append(f"│ you {timestamp}", style=f"dim {STYLE_PRIMARY}")
|
|
329
|
+
self.rich_log.write(user_block)
|
|
330
|
+
|
|
331
|
+
async def request_tool_confirmation(
|
|
332
|
+
self, request: ToolConfirmationRequest
|
|
333
|
+
) -> ToolConfirmationResponse:
|
|
334
|
+
if self.pending_confirmation is not None and not self.pending_confirmation.future.done():
|
|
335
|
+
raise RuntimeError("Previous confirmation still pending")
|
|
336
|
+
|
|
337
|
+
future: asyncio.Future[ToolConfirmationResponse] = asyncio.Future()
|
|
338
|
+
self.pending_confirmation = PendingConfirmationState(future=future, request=request)
|
|
339
|
+
self._show_inline_confirmation(request)
|
|
340
|
+
return await future
|
|
341
|
+
|
|
342
|
+
def on_tool_result_display(self, message: ToolResultDisplay) -> None:
|
|
343
|
+
panel = tool_panel_smart(
|
|
344
|
+
name=message.tool_name,
|
|
345
|
+
status=message.status,
|
|
346
|
+
args=message.args,
|
|
347
|
+
result=message.result,
|
|
348
|
+
duration_ms=message.duration_ms,
|
|
349
|
+
)
|
|
350
|
+
self.rich_log.write(panel)
|
|
351
|
+
|
|
352
|
+
def _replay_session_messages(self) -> None:
|
|
353
|
+
"""Render loaded session messages to RichLog."""
|
|
354
|
+
from pydantic_ai.messages import ModelRequest, ModelResponse
|
|
355
|
+
|
|
356
|
+
from tunacode.utils.messaging.message_utils import get_message_content
|
|
357
|
+
|
|
358
|
+
for msg in self.state_manager.session.messages:
|
|
359
|
+
if isinstance(msg, dict) and "thought" in msg:
|
|
360
|
+
continue # Skip internal thoughts
|
|
361
|
+
|
|
362
|
+
content = get_message_content(msg)
|
|
363
|
+
if not content:
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
if isinstance(msg, ModelRequest):
|
|
367
|
+
user_block = Text()
|
|
368
|
+
user_block.append(f"| {content}\n", style=STYLE_PRIMARY)
|
|
369
|
+
user_block.append("| (restored)", style=f"dim {STYLE_PRIMARY}")
|
|
370
|
+
self.rich_log.write(user_block)
|
|
371
|
+
elif isinstance(msg, ModelResponse):
|
|
372
|
+
self.rich_log.write(Text("agent:", style="accent"))
|
|
373
|
+
self.rich_log.write(Markdown(content))
|
|
374
|
+
|
|
375
|
+
async def streaming_callback(self, chunk: str) -> None:
|
|
376
|
+
if self._streaming_paused:
|
|
377
|
+
self._stream_buffer.append(chunk)
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
# Always accumulate immediately
|
|
381
|
+
self.current_stream_text += chunk
|
|
382
|
+
|
|
383
|
+
# Throttle display updates to reduce visual churn
|
|
384
|
+
now = time.monotonic()
|
|
385
|
+
elapsed_ms = (now - self._last_display_update) * 1000
|
|
386
|
+
|
|
387
|
+
if elapsed_ms >= STREAM_THROTTLE_MS:
|
|
388
|
+
self._last_display_update = now
|
|
389
|
+
self.streaming_output.update(Markdown(self.current_stream_text))
|
|
390
|
+
self.streaming_output.add_class("active")
|
|
391
|
+
self.rich_log.scroll_end()
|
|
392
|
+
|
|
393
|
+
def action_toggle_pause(self) -> None:
|
|
394
|
+
if self._streaming_paused:
|
|
395
|
+
self.resume_streaming()
|
|
396
|
+
else:
|
|
397
|
+
self.pause_streaming()
|
|
398
|
+
|
|
399
|
+
def pause_streaming(self) -> None:
|
|
400
|
+
self._streaming_paused = True
|
|
401
|
+
self.query_one("#viewport").add_class(RICHLOG_CLASS_PAUSED)
|
|
402
|
+
self.notify("Streaming paused...")
|
|
403
|
+
|
|
404
|
+
def resume_streaming(self) -> None:
|
|
405
|
+
self._streaming_paused = False
|
|
406
|
+
self.query_one("#viewport").remove_class(RICHLOG_CLASS_PAUSED)
|
|
407
|
+
self.notify("Streaming resumed...")
|
|
408
|
+
|
|
409
|
+
if self._stream_buffer:
|
|
410
|
+
buffered_text = "".join(self._stream_buffer)
|
|
411
|
+
self.current_stream_text += buffered_text
|
|
412
|
+
self._stream_buffer.clear()
|
|
413
|
+
|
|
414
|
+
# Force immediate display update on resume
|
|
415
|
+
self._last_display_update = time.monotonic()
|
|
416
|
+
self.streaming_output.update(Markdown(self.current_stream_text))
|
|
417
|
+
|
|
418
|
+
def action_cancel_stream(self) -> None:
|
|
419
|
+
# If confirmation is pending, Escape rejects it
|
|
420
|
+
if self.pending_confirmation is not None and not self.pending_confirmation.future.done():
|
|
421
|
+
response = ToolConfirmationResponse(approved=False, skip_future=False, abort=True)
|
|
422
|
+
self.pending_confirmation.future.set_result(response)
|
|
423
|
+
self.pending_confirmation = None
|
|
424
|
+
self.rich_log.write(Text("Rejected", style=STYLE_ERROR))
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
# Otherwise, cancel the stream
|
|
428
|
+
if self._current_request_task is not None:
|
|
429
|
+
self._streaming_cancelled = True
|
|
430
|
+
self._stream_buffer.clear()
|
|
431
|
+
self.current_stream_text = ""
|
|
432
|
+
self._current_request_task.cancel()
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
shell_runner = getattr(self, "shell_runner", None)
|
|
436
|
+
if shell_runner is not None and shell_runner.is_running():
|
|
437
|
+
shell_runner.cancel()
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
if self.editor.value or self.editor.has_paste_buffer:
|
|
441
|
+
self.editor.clear_input()
|
|
442
|
+
return
|
|
443
|
+
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
def start_shell_command(self, raw_cmd: str) -> None:
|
|
447
|
+
self.shell_runner.start(raw_cmd)
|
|
448
|
+
|
|
449
|
+
def write_shell_output(self, renderable: Text) -> None:
|
|
450
|
+
self.rich_log.write(renderable)
|
|
451
|
+
|
|
452
|
+
def shell_status_running(self) -> None:
|
|
453
|
+
self.status_bar.update_running_action("shell")
|
|
454
|
+
|
|
455
|
+
def shell_status_last(self) -> None:
|
|
456
|
+
self.status_bar.update_last_action("shell")
|
|
457
|
+
|
|
458
|
+
def _update_resource_bar(self) -> None:
|
|
459
|
+
session = self.state_manager.session
|
|
460
|
+
usage = session.session_total_usage
|
|
461
|
+
|
|
462
|
+
# Use actual context window tokens, not cumulative API usage
|
|
463
|
+
context_tokens = session.total_tokens
|
|
464
|
+
|
|
465
|
+
self.resource_bar.update_stats(
|
|
466
|
+
model=session.current_model or "No model selected",
|
|
467
|
+
tokens=context_tokens,
|
|
468
|
+
max_tokens=session.max_tokens or 200000,
|
|
469
|
+
session_cost=usage.get("cost", 0.0),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
def _show_inline_confirmation(self, request: ToolConfirmationRequest) -> None:
|
|
473
|
+
"""Display inline confirmation prompt in RichLog."""
|
|
474
|
+
content_parts: list[Text | Syntax] = []
|
|
475
|
+
|
|
476
|
+
# Header
|
|
477
|
+
header = Text()
|
|
478
|
+
header.append(f"Confirm: {request.tool_name}\n", style=STYLE_SUBHEADING)
|
|
479
|
+
content_parts.append(header)
|
|
480
|
+
|
|
481
|
+
# Arguments
|
|
482
|
+
args_text = Text()
|
|
483
|
+
for key, value in request.args.items():
|
|
484
|
+
display_value = str(value)
|
|
485
|
+
if len(display_value) > 60:
|
|
486
|
+
display_value = display_value[:57] + "..."
|
|
487
|
+
args_text.append(f" {key}: ", style=STYLE_MUTED)
|
|
488
|
+
args_text.append(f"{display_value}\n")
|
|
489
|
+
content_parts.append(args_text)
|
|
490
|
+
|
|
491
|
+
# Diff Preview (if available)
|
|
492
|
+
if request.diff_content:
|
|
493
|
+
content_parts.append(Text("\nPreview changes:\n", style="bold"))
|
|
494
|
+
content_parts.append(
|
|
495
|
+
Syntax(request.diff_content, "diff", theme="monokai", word_wrap=True)
|
|
496
|
+
)
|
|
497
|
+
content_parts.append(Text("\n"))
|
|
498
|
+
|
|
499
|
+
# Footer Actions
|
|
500
|
+
actions = Text()
|
|
501
|
+
actions.append("\n")
|
|
502
|
+
actions.append("[1]", style=f"bold {STYLE_SUCCESS}")
|
|
503
|
+
actions.append(" Yes ")
|
|
504
|
+
actions.append("[2]", style=f"bold {STYLE_WARNING}")
|
|
505
|
+
actions.append(" Yes + Skip ")
|
|
506
|
+
actions.append("[3]", style=f"bold {STYLE_ERROR}")
|
|
507
|
+
actions.append(" No")
|
|
508
|
+
content_parts.append(actions)
|
|
509
|
+
|
|
510
|
+
# Use Group to stack components vertically
|
|
511
|
+
from rich.console import Group
|
|
512
|
+
|
|
513
|
+
panel = Panel(
|
|
514
|
+
Group(*content_parts),
|
|
515
|
+
border_style=STYLE_PRIMARY,
|
|
516
|
+
padding=(0, 1),
|
|
517
|
+
expand=True,
|
|
518
|
+
width=TOOL_PANEL_WIDTH,
|
|
519
|
+
)
|
|
520
|
+
self.rich_log.write(panel)
|
|
521
|
+
|
|
522
|
+
def on_key(self, event: events.Key) -> None:
|
|
523
|
+
"""Handle key events, intercepting confirmation keys when pending."""
|
|
524
|
+
if self.pending_confirmation is None or self.pending_confirmation.future.done():
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
response: ToolConfirmationResponse | None = None
|
|
528
|
+
|
|
529
|
+
if event.key == "1":
|
|
530
|
+
response = ToolConfirmationResponse(approved=True, skip_future=False, abort=False)
|
|
531
|
+
self.rich_log.write(Text("Approved", style=STYLE_SUCCESS))
|
|
532
|
+
elif event.key == "2":
|
|
533
|
+
response = ToolConfirmationResponse(approved=True, skip_future=True, abort=False)
|
|
534
|
+
self.rich_log.write(Text("Approved (skipping future)", style=STYLE_WARNING))
|
|
535
|
+
elif event.key == "3":
|
|
536
|
+
response = ToolConfirmationResponse(approved=False, skip_future=False, abort=True)
|
|
537
|
+
self.rich_log.write(Text("Rejected", style=STYLE_ERROR))
|
|
538
|
+
|
|
539
|
+
if response is not None:
|
|
540
|
+
self.pending_confirmation.future.set_result(response)
|
|
541
|
+
self.pending_confirmation = None
|
|
542
|
+
event.stop()
|