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.

Files changed (174) hide show
  1. tunacode/__init__.py +0 -0
  2. tunacode/cli/textual_repl.tcss +283 -0
  3. tunacode/configuration/__init__.py +1 -0
  4. tunacode/configuration/defaults.py +45 -0
  5. tunacode/configuration/models.py +147 -0
  6. tunacode/configuration/models_registry.json +1 -0
  7. tunacode/configuration/pricing.py +74 -0
  8. tunacode/configuration/settings.py +35 -0
  9. tunacode/constants.py +227 -0
  10. tunacode/core/__init__.py +6 -0
  11. tunacode/core/agents/__init__.py +39 -0
  12. tunacode/core/agents/agent_components/__init__.py +48 -0
  13. tunacode/core/agents/agent_components/agent_config.py +441 -0
  14. tunacode/core/agents/agent_components/agent_helpers.py +290 -0
  15. tunacode/core/agents/agent_components/message_handler.py +99 -0
  16. tunacode/core/agents/agent_components/node_processor.py +477 -0
  17. tunacode/core/agents/agent_components/response_state.py +129 -0
  18. tunacode/core/agents/agent_components/result_wrapper.py +51 -0
  19. tunacode/core/agents/agent_components/state_transition.py +112 -0
  20. tunacode/core/agents/agent_components/streaming.py +271 -0
  21. tunacode/core/agents/agent_components/task_completion.py +40 -0
  22. tunacode/core/agents/agent_components/tool_buffer.py +44 -0
  23. tunacode/core/agents/agent_components/tool_executor.py +101 -0
  24. tunacode/core/agents/agent_components/truncation_checker.py +37 -0
  25. tunacode/core/agents/delegation_tools.py +109 -0
  26. tunacode/core/agents/main.py +545 -0
  27. tunacode/core/agents/prompts.py +66 -0
  28. tunacode/core/agents/research_agent.py +231 -0
  29. tunacode/core/compaction.py +218 -0
  30. tunacode/core/prompting/__init__.py +27 -0
  31. tunacode/core/prompting/loader.py +66 -0
  32. tunacode/core/prompting/prompting_engine.py +98 -0
  33. tunacode/core/prompting/sections.py +50 -0
  34. tunacode/core/prompting/templates.py +69 -0
  35. tunacode/core/state.py +409 -0
  36. tunacode/exceptions.py +313 -0
  37. tunacode/indexing/__init__.py +5 -0
  38. tunacode/indexing/code_index.py +432 -0
  39. tunacode/indexing/constants.py +86 -0
  40. tunacode/lsp/__init__.py +112 -0
  41. tunacode/lsp/client.py +351 -0
  42. tunacode/lsp/diagnostics.py +19 -0
  43. tunacode/lsp/servers.py +101 -0
  44. tunacode/prompts/default_prompt.md +952 -0
  45. tunacode/prompts/research/sections/agent_role.xml +5 -0
  46. tunacode/prompts/research/sections/constraints.xml +14 -0
  47. tunacode/prompts/research/sections/output_format.xml +57 -0
  48. tunacode/prompts/research/sections/tool_use.xml +23 -0
  49. tunacode/prompts/sections/advanced_patterns.xml +255 -0
  50. tunacode/prompts/sections/agent_role.xml +8 -0
  51. tunacode/prompts/sections/completion.xml +10 -0
  52. tunacode/prompts/sections/critical_rules.xml +37 -0
  53. tunacode/prompts/sections/examples.xml +220 -0
  54. tunacode/prompts/sections/output_style.xml +94 -0
  55. tunacode/prompts/sections/parallel_exec.xml +105 -0
  56. tunacode/prompts/sections/search_pattern.xml +100 -0
  57. tunacode/prompts/sections/system_info.xml +6 -0
  58. tunacode/prompts/sections/tool_use.xml +84 -0
  59. tunacode/prompts/sections/user_instructions.xml +3 -0
  60. tunacode/py.typed +0 -0
  61. tunacode/templates/__init__.py +5 -0
  62. tunacode/templates/loader.py +15 -0
  63. tunacode/tools/__init__.py +10 -0
  64. tunacode/tools/authorization/__init__.py +29 -0
  65. tunacode/tools/authorization/context.py +32 -0
  66. tunacode/tools/authorization/factory.py +20 -0
  67. tunacode/tools/authorization/handler.py +58 -0
  68. tunacode/tools/authorization/notifier.py +35 -0
  69. tunacode/tools/authorization/policy.py +19 -0
  70. tunacode/tools/authorization/requests.py +119 -0
  71. tunacode/tools/authorization/rules.py +72 -0
  72. tunacode/tools/bash.py +222 -0
  73. tunacode/tools/decorators.py +213 -0
  74. tunacode/tools/glob.py +353 -0
  75. tunacode/tools/grep.py +468 -0
  76. tunacode/tools/grep_components/__init__.py +9 -0
  77. tunacode/tools/grep_components/file_filter.py +93 -0
  78. tunacode/tools/grep_components/pattern_matcher.py +158 -0
  79. tunacode/tools/grep_components/result_formatter.py +87 -0
  80. tunacode/tools/grep_components/search_result.py +34 -0
  81. tunacode/tools/list_dir.py +205 -0
  82. tunacode/tools/prompts/bash_prompt.xml +10 -0
  83. tunacode/tools/prompts/glob_prompt.xml +7 -0
  84. tunacode/tools/prompts/grep_prompt.xml +10 -0
  85. tunacode/tools/prompts/list_dir_prompt.xml +7 -0
  86. tunacode/tools/prompts/read_file_prompt.xml +9 -0
  87. tunacode/tools/prompts/todoclear_prompt.xml +12 -0
  88. tunacode/tools/prompts/todoread_prompt.xml +16 -0
  89. tunacode/tools/prompts/todowrite_prompt.xml +28 -0
  90. tunacode/tools/prompts/update_file_prompt.xml +9 -0
  91. tunacode/tools/prompts/web_fetch_prompt.xml +11 -0
  92. tunacode/tools/prompts/write_file_prompt.xml +7 -0
  93. tunacode/tools/react.py +111 -0
  94. tunacode/tools/read_file.py +68 -0
  95. tunacode/tools/todo.py +222 -0
  96. tunacode/tools/update_file.py +62 -0
  97. tunacode/tools/utils/__init__.py +1 -0
  98. tunacode/tools/utils/ripgrep.py +311 -0
  99. tunacode/tools/utils/text_match.py +352 -0
  100. tunacode/tools/web_fetch.py +245 -0
  101. tunacode/tools/write_file.py +34 -0
  102. tunacode/tools/xml_helper.py +34 -0
  103. tunacode/types/__init__.py +166 -0
  104. tunacode/types/base.py +94 -0
  105. tunacode/types/callbacks.py +53 -0
  106. tunacode/types/dataclasses.py +121 -0
  107. tunacode/types/pydantic_ai.py +31 -0
  108. tunacode/types/state.py +122 -0
  109. tunacode/ui/__init__.py +6 -0
  110. tunacode/ui/app.py +542 -0
  111. tunacode/ui/commands/__init__.py +430 -0
  112. tunacode/ui/components/__init__.py +1 -0
  113. tunacode/ui/headless/__init__.py +5 -0
  114. tunacode/ui/headless/output.py +72 -0
  115. tunacode/ui/main.py +252 -0
  116. tunacode/ui/renderers/__init__.py +41 -0
  117. tunacode/ui/renderers/errors.py +197 -0
  118. tunacode/ui/renderers/panels.py +550 -0
  119. tunacode/ui/renderers/search.py +314 -0
  120. tunacode/ui/renderers/tools/__init__.py +21 -0
  121. tunacode/ui/renderers/tools/bash.py +247 -0
  122. tunacode/ui/renderers/tools/diagnostics.py +186 -0
  123. tunacode/ui/renderers/tools/glob.py +226 -0
  124. tunacode/ui/renderers/tools/grep.py +228 -0
  125. tunacode/ui/renderers/tools/list_dir.py +198 -0
  126. tunacode/ui/renderers/tools/read_file.py +226 -0
  127. tunacode/ui/renderers/tools/research.py +294 -0
  128. tunacode/ui/renderers/tools/update_file.py +237 -0
  129. tunacode/ui/renderers/tools/web_fetch.py +182 -0
  130. tunacode/ui/repl_support.py +226 -0
  131. tunacode/ui/screens/__init__.py +16 -0
  132. tunacode/ui/screens/model_picker.py +303 -0
  133. tunacode/ui/screens/session_picker.py +181 -0
  134. tunacode/ui/screens/setup.py +218 -0
  135. tunacode/ui/screens/theme_picker.py +90 -0
  136. tunacode/ui/screens/update_confirm.py +69 -0
  137. tunacode/ui/shell_runner.py +129 -0
  138. tunacode/ui/styles/layout.tcss +98 -0
  139. tunacode/ui/styles/modals.tcss +38 -0
  140. tunacode/ui/styles/panels.tcss +81 -0
  141. tunacode/ui/styles/theme-nextstep.tcss +303 -0
  142. tunacode/ui/styles/widgets.tcss +33 -0
  143. tunacode/ui/styles.py +18 -0
  144. tunacode/ui/widgets/__init__.py +23 -0
  145. tunacode/ui/widgets/command_autocomplete.py +62 -0
  146. tunacode/ui/widgets/editor.py +402 -0
  147. tunacode/ui/widgets/file_autocomplete.py +47 -0
  148. tunacode/ui/widgets/messages.py +46 -0
  149. tunacode/ui/widgets/resource_bar.py +182 -0
  150. tunacode/ui/widgets/status_bar.py +98 -0
  151. tunacode/utils/__init__.py +0 -0
  152. tunacode/utils/config/__init__.py +13 -0
  153. tunacode/utils/config/user_configuration.py +91 -0
  154. tunacode/utils/messaging/__init__.py +10 -0
  155. tunacode/utils/messaging/message_utils.py +34 -0
  156. tunacode/utils/messaging/token_counter.py +77 -0
  157. tunacode/utils/parsing/__init__.py +13 -0
  158. tunacode/utils/parsing/command_parser.py +55 -0
  159. tunacode/utils/parsing/json_utils.py +188 -0
  160. tunacode/utils/parsing/retry.py +146 -0
  161. tunacode/utils/parsing/tool_parser.py +267 -0
  162. tunacode/utils/security/__init__.py +15 -0
  163. tunacode/utils/security/command.py +106 -0
  164. tunacode/utils/system/__init__.py +25 -0
  165. tunacode/utils/system/gitignore.py +155 -0
  166. tunacode/utils/system/paths.py +190 -0
  167. tunacode/utils/ui/__init__.py +9 -0
  168. tunacode/utils/ui/file_filter.py +135 -0
  169. tunacode/utils/ui/helpers.py +24 -0
  170. tunacode_cli-0.1.21.dist-info/METADATA +170 -0
  171. tunacode_cli-0.1.21.dist-info/RECORD +174 -0
  172. tunacode_cli-0.1.21.dist-info/WHEEL +4 -0
  173. tunacode_cli-0.1.21.dist-info/entry_points.txt +2 -0
  174. 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,5 @@
1
+ """Headless mode utilities."""
2
+
3
+ from tunacode.ui.headless.output import resolve_output
4
+
5
+ __all__ = ["resolve_output"]
@@ -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)