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
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Command system for TunaCode REPL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from tunacode.ui.app import TextualReplApp
|
|
10
|
+
|
|
11
|
+
from tunacode.ui.styles import STYLE_PRIMARY
|
|
12
|
+
|
|
13
|
+
# Update command constants
|
|
14
|
+
PACKAGE_NAME = "tunacode-cli"
|
|
15
|
+
UPDATE_INSTALL_TIMEOUT_SECONDS = 120
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _get_package_manager_command(package: str) -> tuple[list[str], str] | None:
|
|
19
|
+
"""Get package manager command and name.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Tuple of (command_list, manager_name) or None if no manager found.
|
|
23
|
+
"""
|
|
24
|
+
import shutil
|
|
25
|
+
|
|
26
|
+
uv_path = shutil.which("uv")
|
|
27
|
+
if uv_path:
|
|
28
|
+
return ([uv_path, "pip", "install", "--upgrade", package], "uv")
|
|
29
|
+
|
|
30
|
+
pip_path = shutil.which("pip")
|
|
31
|
+
if pip_path:
|
|
32
|
+
return ([pip_path, "install", "--upgrade", package], "pip")
|
|
33
|
+
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Command(ABC):
|
|
38
|
+
"""Base class for REPL commands."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
usage: str = ""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
46
|
+
"""Execute the command."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HelpCommand(Command):
|
|
51
|
+
name = "help"
|
|
52
|
+
description = "Show available commands"
|
|
53
|
+
|
|
54
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
55
|
+
from rich.table import Table
|
|
56
|
+
|
|
57
|
+
table = Table(title="Commands", show_header=True)
|
|
58
|
+
table.add_column("Command", style=STYLE_PRIMARY)
|
|
59
|
+
table.add_column("Description")
|
|
60
|
+
|
|
61
|
+
for name, cmd in COMMANDS.items():
|
|
62
|
+
table.add_row(f"/{name}", cmd.description)
|
|
63
|
+
|
|
64
|
+
table.add_row("!<cmd>", "Run shell command")
|
|
65
|
+
table.add_row("exit", "Exit TunaCode")
|
|
66
|
+
|
|
67
|
+
app.rich_log.write(table)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ClearCommand(Command):
|
|
71
|
+
name = "clear"
|
|
72
|
+
description = "Clear conversation history"
|
|
73
|
+
|
|
74
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
75
|
+
app.rich_log.clear()
|
|
76
|
+
app.state_manager.session.messages = []
|
|
77
|
+
app.state_manager.session.total_tokens = 0
|
|
78
|
+
app._update_resource_bar()
|
|
79
|
+
app.notify("Cleared conversation history")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class YoloCommand(Command):
|
|
83
|
+
name = "yolo"
|
|
84
|
+
description = "Toggle auto-confirm for tool executions"
|
|
85
|
+
|
|
86
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
87
|
+
app.state_manager.session.yolo = not app.state_manager.session.yolo
|
|
88
|
+
status = "ON" if app.state_manager.session.yolo else "OFF"
|
|
89
|
+
app.notify(f"YOLO mode: {status}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ModelCommand(Command):
|
|
93
|
+
name = "model"
|
|
94
|
+
description = "Open model picker or switch directly"
|
|
95
|
+
usage = "/model [provider:model-name]"
|
|
96
|
+
|
|
97
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
98
|
+
from tunacode.configuration.models import get_model_context_window, load_models_registry
|
|
99
|
+
from tunacode.utils.config.user_configuration import save_config
|
|
100
|
+
|
|
101
|
+
if args:
|
|
102
|
+
load_models_registry()
|
|
103
|
+
model_name = args.strip()
|
|
104
|
+
app.state_manager.session.current_model = model_name
|
|
105
|
+
app.state_manager.session.user_config["default_model"] = model_name
|
|
106
|
+
app.state_manager.session.max_tokens = get_model_context_window(model_name)
|
|
107
|
+
save_config(app.state_manager)
|
|
108
|
+
app._update_resource_bar()
|
|
109
|
+
app.notify(f"Model: {model_name}")
|
|
110
|
+
else:
|
|
111
|
+
from tunacode.ui.screens.model_picker import (
|
|
112
|
+
ModelPickerScreen,
|
|
113
|
+
ProviderPickerScreen,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
current_model = app.state_manager.session.current_model
|
|
117
|
+
|
|
118
|
+
def on_model_selected(full_model: str | None) -> None:
|
|
119
|
+
if full_model is not None:
|
|
120
|
+
app.state_manager.session.current_model = full_model
|
|
121
|
+
app.state_manager.session.user_config["default_model"] = full_model
|
|
122
|
+
app.state_manager.session.max_tokens = get_model_context_window(full_model)
|
|
123
|
+
save_config(app.state_manager)
|
|
124
|
+
app._update_resource_bar()
|
|
125
|
+
app.notify(f"Model: {full_model}")
|
|
126
|
+
|
|
127
|
+
def on_provider_selected(provider_id: str | None) -> None:
|
|
128
|
+
if provider_id is not None:
|
|
129
|
+
app.push_screen(
|
|
130
|
+
ModelPickerScreen(provider_id, current_model),
|
|
131
|
+
on_model_selected,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
app.push_screen(
|
|
135
|
+
ProviderPickerScreen(current_model),
|
|
136
|
+
on_provider_selected,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class BranchCommand(Command):
|
|
141
|
+
name = "branch"
|
|
142
|
+
description = "Create and switch to a new git branch"
|
|
143
|
+
usage = "/branch <name>"
|
|
144
|
+
|
|
145
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
146
|
+
import subprocess
|
|
147
|
+
|
|
148
|
+
if not args:
|
|
149
|
+
app.notify("Usage: /branch <name>", severity="warning")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
branch_name = args.strip()
|
|
153
|
+
try:
|
|
154
|
+
result = subprocess.run(
|
|
155
|
+
["git", "checkout", "-b", branch_name],
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
timeout=5,
|
|
159
|
+
)
|
|
160
|
+
if result.returncode == 0:
|
|
161
|
+
app.notify(f"Created branch: {branch_name}")
|
|
162
|
+
app.status_bar._refresh_location()
|
|
163
|
+
else:
|
|
164
|
+
app.rich_log.write(f"Error: {result.stderr.strip()}")
|
|
165
|
+
except Exception as e:
|
|
166
|
+
app.rich_log.write(f"Error: {e}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class PlanCommand(Command):
|
|
170
|
+
name = "plan"
|
|
171
|
+
description = "Toggle read-only planning mode"
|
|
172
|
+
|
|
173
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
174
|
+
app.notify("Plan mode not yet implemented", severity="warning")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ThemeCommand(Command):
|
|
178
|
+
name = "theme"
|
|
179
|
+
description = "Open theme picker or switch directly"
|
|
180
|
+
usage = "/theme [name]"
|
|
181
|
+
|
|
182
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
183
|
+
from tunacode.utils.config.user_configuration import save_config
|
|
184
|
+
|
|
185
|
+
if args:
|
|
186
|
+
theme_name = args.strip()
|
|
187
|
+
if theme_name not in app.available_themes:
|
|
188
|
+
app.notify(f"Unknown theme: {theme_name}", severity="error")
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
app.theme = theme_name
|
|
192
|
+
app.state_manager.session.user_config.setdefault("settings", {})["theme"] = theme_name
|
|
193
|
+
save_config(app.state_manager)
|
|
194
|
+
app.notify(f"Theme: {theme_name}")
|
|
195
|
+
else:
|
|
196
|
+
from tunacode.ui.screens.theme_picker import ThemePickerScreen
|
|
197
|
+
|
|
198
|
+
def on_dismiss(selected: str | None) -> None:
|
|
199
|
+
if selected is not None:
|
|
200
|
+
config = app.state_manager.session.user_config
|
|
201
|
+
config.setdefault("settings", {})["theme"] = selected
|
|
202
|
+
save_config(app.state_manager)
|
|
203
|
+
app.notify(f"Theme: {selected}")
|
|
204
|
+
|
|
205
|
+
app.push_screen(
|
|
206
|
+
ThemePickerScreen(app.available_themes, app.theme),
|
|
207
|
+
on_dismiss,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ResumeCommand(Command):
|
|
212
|
+
name = "resume"
|
|
213
|
+
description = "Resume a previous session"
|
|
214
|
+
usage = "/resume [load <id>|delete <id>]"
|
|
215
|
+
|
|
216
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
217
|
+
from tunacode.ui.screens import SessionPickerScreen
|
|
218
|
+
from tunacode.utils.system.paths import delete_session_file
|
|
219
|
+
|
|
220
|
+
parts = args.split(maxsplit=1) if args else []
|
|
221
|
+
subcommand = parts[0].lower() if parts else ""
|
|
222
|
+
|
|
223
|
+
# No args or "list" -> open picker
|
|
224
|
+
if subcommand in ("", "list"):
|
|
225
|
+
sessions = app.state_manager.list_sessions()
|
|
226
|
+
if not sessions:
|
|
227
|
+
app.notify("No saved sessions found")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
current_session_id = app.state_manager.session.session_id
|
|
231
|
+
|
|
232
|
+
def on_session_selected(session_id: str | None) -> None:
|
|
233
|
+
if not session_id:
|
|
234
|
+
return
|
|
235
|
+
self._load_session(app, session_id, sessions)
|
|
236
|
+
|
|
237
|
+
app.push_screen(
|
|
238
|
+
SessionPickerScreen(sessions, current_session_id),
|
|
239
|
+
on_session_selected,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
elif subcommand == "load":
|
|
243
|
+
if len(parts) < 2:
|
|
244
|
+
app.notify("Usage: /resume load <session-id>", severity="warning")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
session_id_prefix = parts[1].strip()
|
|
248
|
+
sessions = app.state_manager.list_sessions()
|
|
249
|
+
|
|
250
|
+
matching = [s for s in sessions if s["session_id"].startswith(session_id_prefix)]
|
|
251
|
+
if not matching:
|
|
252
|
+
app.notify(f"No session found matching: {session_id_prefix}", severity="error")
|
|
253
|
+
return
|
|
254
|
+
if len(matching) > 1:
|
|
255
|
+
app.notify("Multiple sessions match, be more specific", severity="warning")
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
self._load_session(app, matching[0]["session_id"], sessions)
|
|
259
|
+
|
|
260
|
+
elif subcommand == "delete":
|
|
261
|
+
if len(parts) < 2:
|
|
262
|
+
app.notify("Usage: /resume delete <session-id>", severity="warning")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
session_id_prefix = parts[1].strip()
|
|
266
|
+
sessions = app.state_manager.list_sessions()
|
|
267
|
+
|
|
268
|
+
matching = [s for s in sessions if s["session_id"].startswith(session_id_prefix)]
|
|
269
|
+
if not matching:
|
|
270
|
+
app.notify(f"No session found matching: {session_id_prefix}", severity="error")
|
|
271
|
+
return
|
|
272
|
+
if len(matching) > 1:
|
|
273
|
+
app.notify("Multiple sessions match, be more specific", severity="warning")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
target_session = matching[0]
|
|
277
|
+
if target_session["session_id"] == app.state_manager.session.session_id:
|
|
278
|
+
app.notify("Cannot delete current session", severity="error")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
project_id = app.state_manager.session.project_id
|
|
282
|
+
if delete_session_file(project_id, target_session["session_id"]):
|
|
283
|
+
app.notify(f"Deleted session {target_session['session_id'][:8]}")
|
|
284
|
+
else:
|
|
285
|
+
app.notify("Failed to delete session", severity="error")
|
|
286
|
+
|
|
287
|
+
else:
|
|
288
|
+
app.notify(f"Unknown subcommand: {subcommand}", severity="warning")
|
|
289
|
+
|
|
290
|
+
def _load_session(self, app: TextualReplApp, session_id: str, sessions: list[dict]) -> None:
|
|
291
|
+
"""Load a session by ID."""
|
|
292
|
+
from rich.text import Text
|
|
293
|
+
|
|
294
|
+
target = next((s for s in sessions if s["session_id"] == session_id), None)
|
|
295
|
+
if not target:
|
|
296
|
+
app.notify("Session not found", severity="error")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
app.state_manager.save_session()
|
|
300
|
+
|
|
301
|
+
if app.state_manager.load_session(session_id):
|
|
302
|
+
app.rich_log.clear()
|
|
303
|
+
app._replay_session_messages()
|
|
304
|
+
app._update_resource_bar()
|
|
305
|
+
|
|
306
|
+
loaded_msg = Text()
|
|
307
|
+
loaded_msg.append(
|
|
308
|
+
f"Loaded session {session_id[:8]} ({target['message_count']} messages)\n",
|
|
309
|
+
style="green",
|
|
310
|
+
)
|
|
311
|
+
app.rich_log.write(loaded_msg)
|
|
312
|
+
app.notify("Session loaded")
|
|
313
|
+
else:
|
|
314
|
+
app.notify("Failed to load session", severity="error")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class UpdateCommand(Command):
|
|
318
|
+
name = "update"
|
|
319
|
+
description = "Check for or install updates"
|
|
320
|
+
usage = "/update [check|install]"
|
|
321
|
+
|
|
322
|
+
async def execute(self, app: TextualReplApp, args: str) -> None:
|
|
323
|
+
import asyncio
|
|
324
|
+
import subprocess
|
|
325
|
+
|
|
326
|
+
from tunacode.constants import APP_VERSION
|
|
327
|
+
from tunacode.utils.system.paths import check_for_updates
|
|
328
|
+
|
|
329
|
+
parts = args.split(maxsplit=1) if args else []
|
|
330
|
+
subcommand = parts[0].lower() if parts else "check"
|
|
331
|
+
|
|
332
|
+
if subcommand == "check":
|
|
333
|
+
app.notify("Checking for updates...")
|
|
334
|
+
has_update, latest_version = await asyncio.to_thread(check_for_updates)
|
|
335
|
+
|
|
336
|
+
if has_update:
|
|
337
|
+
app.rich_log.write(f"Current version: {APP_VERSION}")
|
|
338
|
+
app.rich_log.write(f"Latest version: {latest_version}")
|
|
339
|
+
app.notify(f"Update available: {latest_version}")
|
|
340
|
+
app.rich_log.write("Run /update install to upgrade")
|
|
341
|
+
else:
|
|
342
|
+
app.notify(f"Already on latest version ({APP_VERSION})")
|
|
343
|
+
|
|
344
|
+
elif subcommand == "install":
|
|
345
|
+
from tunacode.ui.screens.update_confirm import UpdateConfirmScreen
|
|
346
|
+
|
|
347
|
+
app.notify("Checking for updates...")
|
|
348
|
+
has_update, latest_version = await asyncio.to_thread(check_for_updates)
|
|
349
|
+
|
|
350
|
+
if not has_update:
|
|
351
|
+
app.notify(f"Already on latest version ({APP_VERSION})")
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
confirmed = await app.push_screen_wait(UpdateConfirmScreen(APP_VERSION, latest_version))
|
|
355
|
+
|
|
356
|
+
if not confirmed:
|
|
357
|
+
app.notify("Update cancelled")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
pkg_cmd_result = _get_package_manager_command(PACKAGE_NAME)
|
|
361
|
+
if not pkg_cmd_result:
|
|
362
|
+
app.notify("No package manager found (uv or pip)", severity="error")
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
cmd, pkg_mgr = pkg_cmd_result
|
|
366
|
+
app.notify(f"Installing with {pkg_mgr}...")
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
result = await asyncio.to_thread(
|
|
370
|
+
subprocess.run,
|
|
371
|
+
cmd,
|
|
372
|
+
capture_output=True,
|
|
373
|
+
text=True,
|
|
374
|
+
timeout=UPDATE_INSTALL_TIMEOUT_SECONDS,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if result.returncode == 0:
|
|
378
|
+
app.notify(f"Updated to {latest_version}!")
|
|
379
|
+
app.rich_log.write("Restart tunacode to use the new version")
|
|
380
|
+
else:
|
|
381
|
+
app.notify("Update failed", severity="error")
|
|
382
|
+
if result.stderr:
|
|
383
|
+
app.rich_log.write(result.stderr.strip())
|
|
384
|
+
except Exception as e:
|
|
385
|
+
app.rich_log.write(f"Error: {e}")
|
|
386
|
+
|
|
387
|
+
else:
|
|
388
|
+
app.notify(f"Unknown subcommand: {subcommand}", severity="warning")
|
|
389
|
+
app.notify("Usage: /update [check|install]")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
COMMANDS: dict[str, Command] = {
|
|
393
|
+
"help": HelpCommand(),
|
|
394
|
+
"clear": ClearCommand(),
|
|
395
|
+
"yolo": YoloCommand(),
|
|
396
|
+
"model": ModelCommand(),
|
|
397
|
+
"branch": BranchCommand(),
|
|
398
|
+
"plan": PlanCommand(),
|
|
399
|
+
"theme": ThemeCommand(),
|
|
400
|
+
"resume": ResumeCommand(),
|
|
401
|
+
"update": UpdateCommand(),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
async def handle_command(app: TextualReplApp, text: str) -> bool:
|
|
406
|
+
"""Handle a command if text starts with / or !.
|
|
407
|
+
|
|
408
|
+
Returns True if command was handled, False otherwise.
|
|
409
|
+
"""
|
|
410
|
+
if text.startswith("!"):
|
|
411
|
+
app.start_shell_command(text[1:])
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
if text.startswith("/"):
|
|
415
|
+
parts = text[1:].split(maxsplit=1)
|
|
416
|
+
cmd_name = parts[0].lower() if parts else ""
|
|
417
|
+
cmd_args = parts[1] if len(parts) > 1 else ""
|
|
418
|
+
|
|
419
|
+
if cmd_name in COMMANDS:
|
|
420
|
+
await COMMANDS[cmd_name].execute(app, cmd_args)
|
|
421
|
+
return True
|
|
422
|
+
else:
|
|
423
|
+
app.notify(f"Unknown command: /{cmd_name}", severity="warning")
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
if text.lower() == "exit":
|
|
427
|
+
app.exit()
|
|
428
|
+
return True
|
|
429
|
+
|
|
430
|
+
return False
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Native Textual components for TunaCode UI."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Output extraction for headless mode."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic_ai.messages import ModelResponse
|
|
6
|
+
|
|
7
|
+
from tunacode.utils.messaging import get_message_content
|
|
8
|
+
|
|
9
|
+
TEXT_ATTRIBUTES: tuple[str, ...] = ("output", "text", "content", "message")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize(value: str | None) -> str | None:
|
|
13
|
+
"""Strip whitespace and return None for empty strings."""
|
|
14
|
+
if value is None:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
stripped = value.strip()
|
|
18
|
+
return stripped if stripped else None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_from_attributes(obj: object) -> str | None:
|
|
22
|
+
"""Extract text from common result attributes."""
|
|
23
|
+
for attr in TEXT_ATTRIBUTES:
|
|
24
|
+
value = getattr(obj, attr, None)
|
|
25
|
+
if isinstance(value, str):
|
|
26
|
+
normalized = _normalize(value)
|
|
27
|
+
if normalized is not None:
|
|
28
|
+
return normalized
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extract_from_result(agent_run: object) -> str | None:
|
|
33
|
+
"""Extract text from agent_run.result."""
|
|
34
|
+
result = getattr(agent_run, "result", None)
|
|
35
|
+
if result is None:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if isinstance(result, str):
|
|
39
|
+
return _normalize(result)
|
|
40
|
+
|
|
41
|
+
return _extract_from_attributes(result)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extract_from_messages(messages: list[Any]) -> str | None:
|
|
45
|
+
"""Extract text from the latest ModelResponse in messages."""
|
|
46
|
+
for message in reversed(messages):
|
|
47
|
+
if not isinstance(message, ModelResponse):
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
content = get_message_content(message)
|
|
51
|
+
normalized = _normalize(content)
|
|
52
|
+
if normalized is not None:
|
|
53
|
+
return normalized
|
|
54
|
+
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve_output(agent_run: object, messages: list[Any]) -> str | None:
|
|
59
|
+
"""Resolve headless output from agent run or messages.
|
|
60
|
+
|
|
61
|
+
Priority:
|
|
62
|
+
1. agent_run.result (direct result)
|
|
63
|
+
2. Latest ModelResponse content (fallback)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Extracted text or None if no output found.
|
|
67
|
+
"""
|
|
68
|
+
result = _extract_from_result(agent_run)
|
|
69
|
+
if result is not None:
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
return _extract_from_messages(messages)
|