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,181 @@
1
+ """Session picker modal screen for TunaCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from textual.app import ComposeResult
10
+ from textual.containers import Vertical
11
+ from textual.screen import Screen
12
+ from textual.widgets import OptionList, Static
13
+ from textual.widgets.option_list import Option
14
+
15
+
16
+ class SessionPickerScreen(Screen[str | None]):
17
+ """Modal screen for session selection with message preview."""
18
+
19
+ CSS = """
20
+ SessionPickerScreen {
21
+ align: center middle;
22
+ }
23
+
24
+ #session-container {
25
+ width: 70;
26
+ height: auto;
27
+ max-height: 24;
28
+ border: solid $primary;
29
+ background: $surface;
30
+ padding: 1 2;
31
+ }
32
+
33
+ #session-title {
34
+ text-style: bold;
35
+ color: $accent;
36
+ text-align: center;
37
+ margin-bottom: 1;
38
+ }
39
+
40
+ #session-list {
41
+ height: auto;
42
+ max-height: 12;
43
+ }
44
+
45
+ #session-preview {
46
+ height: 6;
47
+ margin-top: 1;
48
+ border-top: solid $primary;
49
+ padding-top: 1;
50
+ color: $text-muted;
51
+ }
52
+ """
53
+
54
+ BINDINGS = [
55
+ ("escape", "cancel", "Cancel"),
56
+ ]
57
+
58
+ def __init__(
59
+ self,
60
+ sessions: list[dict[str, Any]],
61
+ current_session_id: str,
62
+ ) -> None:
63
+ super().__init__()
64
+ self._sessions = sessions
65
+ self._current_session_id = current_session_id
66
+ self._session_map: dict[str, dict[str, Any]] = {s["session_id"]: s for s in sessions}
67
+
68
+ def compose(self) -> ComposeResult:
69
+ options: list[Option] = []
70
+ highlight_index = 0
71
+
72
+ for i, session in enumerate(self._sessions):
73
+ session_id = session["session_id"]
74
+ short_id = session_id[:8]
75
+ msg_count = session.get("message_count", 0)
76
+ model = session.get("current_model", "unknown")
77
+ if "/" in model:
78
+ model = model.split("/")[-1]
79
+ last_mod = session.get("last_modified", "")[:10]
80
+
81
+ is_current = session_id == self._current_session_id
82
+ current_marker = " (current)" if is_current else ""
83
+
84
+ label = f"{short_id}{current_marker} | {msg_count} msgs | {model} | {last_mod}"
85
+ options.append(Option(label, id=session_id))
86
+
87
+ if is_current:
88
+ highlight_index = i
89
+
90
+ with Vertical(id="session-container"):
91
+ yield Static("Resume Session", id="session-title")
92
+ option_list = OptionList(*options, id="session-list")
93
+ if options:
94
+ option_list.highlighted = highlight_index
95
+ yield option_list
96
+ yield Static("Select a session to preview", id="session-preview")
97
+
98
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
99
+ """Show preview of messages when highlighting a session."""
100
+ preview_widget = self.query_one("#session-preview", Static)
101
+
102
+ if not event.option or not event.option.id:
103
+ preview_widget.update("Select a session to preview")
104
+ return
105
+
106
+ session_id = str(event.option.id)
107
+ session_data = self._session_map.get(session_id)
108
+ if not session_data:
109
+ preview_widget.update("Session not found")
110
+ return
111
+
112
+ file_path = session_data.get("file_path")
113
+ if not file_path:
114
+ preview_widget.update("No preview available")
115
+ return
116
+
117
+ preview_text = self._load_preview(Path(file_path))
118
+ preview_widget.update(preview_text)
119
+
120
+ def _load_preview(self, file_path: Path) -> str:
121
+ """Load first 3 user messages from session file for preview."""
122
+ try:
123
+ with open(file_path) as f:
124
+ data = json.load(f)
125
+ except Exception:
126
+ return "Could not load preview"
127
+
128
+ messages = data.get("messages", [])
129
+ previews: list[str] = []
130
+
131
+ for msg in messages:
132
+ if len(previews) >= 3:
133
+ break
134
+
135
+ if not isinstance(msg, dict):
136
+ continue
137
+
138
+ # Only show request messages
139
+ if msg.get("kind") != "request":
140
+ continue
141
+
142
+ # Extract user-prompt parts only (skip system-prompt)
143
+ content = self._extract_user_content(msg)
144
+ if not content:
145
+ continue
146
+
147
+ # Truncate long messages
148
+ if len(content) > 60:
149
+ content = content[:57] + "..."
150
+ previews.append(f"> {content}")
151
+
152
+ if not previews:
153
+ return "No messages to preview"
154
+
155
+ return "\n".join(previews)
156
+
157
+ def _extract_user_content(self, msg: dict) -> str:
158
+ """Extract only user-prompt content from a message."""
159
+ parts = msg.get("parts", [])
160
+ user_parts: list[str] = []
161
+
162
+ for part in parts:
163
+ if not isinstance(part, dict):
164
+ continue
165
+ # Only include user-prompt parts, skip system-prompt
166
+ if part.get("part_kind") != "user-prompt":
167
+ continue
168
+ content = part.get("content", "")
169
+ if content:
170
+ user_parts.append(str(content))
171
+
172
+ return " ".join(user_parts)
173
+
174
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
175
+ """Confirm selection and dismiss with session ID."""
176
+ if event.option and event.option.id:
177
+ self.dismiss(str(event.option.id))
178
+
179
+ def action_cancel(self) -> None:
180
+ """Cancel selection."""
181
+ self.dismiss(None)
@@ -0,0 +1,218 @@
1
+ """Setup screen for TunaCode first-time configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Horizontal, Vertical
9
+ from textual.screen import Screen
10
+ from textual.widgets import Button, Input, Label, Select, Static
11
+
12
+ from tunacode.configuration.models import (
13
+ get_models_for_provider,
14
+ get_provider_base_url,
15
+ get_provider_env_var,
16
+ get_providers,
17
+ )
18
+ from tunacode.constants import SETTINGS_BASE_URL
19
+ from tunacode.utils.config import save_config
20
+
21
+ if TYPE_CHECKING:
22
+ from tunacode.core.state import StateManager
23
+
24
+
25
+ class SetupScreen(Screen[bool]):
26
+ """Setup wizard screen for first-time configuration."""
27
+
28
+ CSS = """
29
+ SetupScreen {
30
+ align: center middle;
31
+ }
32
+
33
+ #setup-container {
34
+ width: 70;
35
+ height: auto;
36
+ border: solid $primary;
37
+ background: $surface;
38
+ padding: 1 2;
39
+ }
40
+
41
+ #setup-title {
42
+ text-style: bold;
43
+ color: $accent;
44
+ text-align: center;
45
+ margin-bottom: 1;
46
+ }
47
+
48
+ #setup-subtitle {
49
+ color: $text-muted;
50
+ text-align: center;
51
+ margin-bottom: 1;
52
+ }
53
+
54
+ .field-label {
55
+ margin-top: 1;
56
+ color: $text;
57
+ }
58
+
59
+ #provider-select {
60
+ width: 100%;
61
+ margin-bottom: 1;
62
+ }
63
+
64
+ #model-select {
65
+ width: 100%;
66
+ margin-bottom: 1;
67
+ }
68
+
69
+ #api-key-input {
70
+ width: 100%;
71
+ margin-bottom: 1;
72
+ }
73
+
74
+ #button-row {
75
+ margin-top: 1;
76
+ align: center middle;
77
+ }
78
+
79
+ #save-button {
80
+ margin-right: 2;
81
+ }
82
+
83
+ #skip-button {
84
+ margin-left: 2;
85
+ }
86
+
87
+ #error-label {
88
+ color: $error;
89
+ text-align: center;
90
+ margin-top: 1;
91
+ }
92
+ """
93
+
94
+ BINDINGS = [
95
+ ("escape", "skip", "Skip Setup"),
96
+ ]
97
+
98
+ def __init__(self, state_manager: StateManager) -> None:
99
+ super().__init__()
100
+ self.state_manager = state_manager
101
+ self._selected_provider: str = ""
102
+
103
+ def compose(self) -> ComposeResult:
104
+ providers = get_providers()
105
+ has_openai = any(p[1] == "openai" for p in providers)
106
+ default_provider = "openai" if has_openai else (providers[0][1] if providers else "")
107
+
108
+ with Vertical(id="setup-container"):
109
+ yield Static("TunaCode Setup", id="setup-title")
110
+ yield Static("Configure your AI provider to get started.", id="setup-subtitle")
111
+
112
+ yield Label("Provider:", classes="field-label")
113
+ yield Select(
114
+ options=providers,
115
+ value=default_provider,
116
+ id="provider-select",
117
+ allow_blank=False,
118
+ )
119
+
120
+ yield Label("Model:", classes="field-label")
121
+ initial_models = get_models_for_provider(default_provider) if default_provider else []
122
+ yield Select(
123
+ options=initial_models,
124
+ value=initial_models[0][1] if initial_models else Select.BLANK,
125
+ id="model-select",
126
+ allow_blank=False,
127
+ )
128
+
129
+ yield Label("API Key:", classes="field-label")
130
+ yield Input(
131
+ placeholder="Enter your API key",
132
+ password=True,
133
+ id="api-key-input",
134
+ )
135
+
136
+ yield Static("", id="error-label")
137
+
138
+ with Horizontal(id="button-row"):
139
+ yield Button("Save & Start", variant="success", id="save-button")
140
+ yield Button("Skip", variant="default", id="skip-button")
141
+
142
+ self._selected_provider = default_provider
143
+
144
+ def on_select_changed(self, event: Select.Changed) -> None:
145
+ """Update model options when provider changes."""
146
+ if event.select.id != "provider-select":
147
+ return
148
+
149
+ provider = str(event.value)
150
+ self._selected_provider = provider
151
+
152
+ models = get_models_for_provider(provider)
153
+ model_select = self.query_one("#model-select", Select)
154
+ model_select.set_options(models)
155
+
156
+ if models:
157
+ model_select.value = models[0][1]
158
+
159
+ def on_button_pressed(self, event: Button.Pressed) -> None:
160
+ """Handle button presses."""
161
+ if event.button.id == "save-button":
162
+ self._save_and_dismiss()
163
+ elif event.button.id == "skip-button":
164
+ self.dismiss(False)
165
+
166
+ def action_skip(self) -> None:
167
+ """Skip setup without saving."""
168
+ self.dismiss(False)
169
+
170
+ def _save_and_dismiss(self) -> None:
171
+ """Validate inputs, save config, and dismiss screen."""
172
+ error_label = self.query_one("#error-label", Static)
173
+ error_label.update("")
174
+
175
+ provider_select = self.query_one("#provider-select", Select)
176
+ model_select = self.query_one("#model-select", Select)
177
+ api_key_input = self.query_one("#api-key-input", Input)
178
+
179
+ provider = str(provider_select.value) if provider_select.value != Select.BLANK else ""
180
+ model = str(model_select.value) if model_select.value != Select.BLANK else ""
181
+ api_key = api_key_input.value.strip()
182
+
183
+ if not provider:
184
+ error_label.update("Please select a provider")
185
+ return
186
+
187
+ if not model:
188
+ error_label.update("Please select a model")
189
+ return
190
+
191
+ if not api_key:
192
+ error_label.update("API key is required")
193
+ return
194
+
195
+ full_model = f"{provider}:{model}"
196
+ env_var = get_provider_env_var(provider)
197
+ base_url = get_provider_base_url(provider)
198
+
199
+ user_config = self.state_manager.session.user_config
200
+ user_config["default_model"] = full_model
201
+
202
+ if "env" not in user_config:
203
+ user_config["env"] = {}
204
+ user_config["env"][env_var] = api_key
205
+
206
+ if base_url:
207
+ if "settings" not in user_config:
208
+ user_config["settings"] = {}
209
+ user_config["settings"][SETTINGS_BASE_URL] = base_url
210
+
211
+ self.state_manager.session.current_model = full_model
212
+
213
+ try:
214
+ save_config(self.state_manager)
215
+ self.notify("Configuration saved!", severity="information")
216
+ self.dismiss(True)
217
+ except Exception as e:
218
+ error_label.update(f"Failed to save: {e}")
@@ -0,0 +1,90 @@
1
+ """Theme picker modal screen for TunaCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.containers import Vertical
9
+ from textual.screen import Screen
10
+ from textual.widgets import OptionList, Static
11
+ from textual.widgets.option_list import Option
12
+
13
+ if TYPE_CHECKING:
14
+ from textual.theme import Theme
15
+
16
+
17
+ class ThemePickerScreen(Screen[str | None]):
18
+ """Modal screen for theme selection with live preview."""
19
+
20
+ CSS = """
21
+ ThemePickerScreen {
22
+ align: center middle;
23
+ }
24
+
25
+ #theme-container {
26
+ width: 40;
27
+ height: auto;
28
+ max-height: 20;
29
+ border: solid $primary;
30
+ background: $surface;
31
+ padding: 1 2;
32
+ }
33
+
34
+ #theme-title {
35
+ text-style: bold;
36
+ color: $accent;
37
+ text-align: center;
38
+ margin-bottom: 1;
39
+ }
40
+
41
+ #theme-list {
42
+ height: auto;
43
+ max-height: 14;
44
+ }
45
+ """
46
+
47
+ BINDINGS = [
48
+ ("escape", "cancel", "Cancel"),
49
+ ]
50
+
51
+ def __init__(
52
+ self,
53
+ themes: dict[str, Theme],
54
+ current_theme: str,
55
+ ) -> None:
56
+ super().__init__()
57
+ self._themes = themes
58
+ self._current_theme = current_theme
59
+ self._original_theme = current_theme
60
+
61
+ def compose(self) -> ComposeResult:
62
+ options = []
63
+ highlight_index = 0
64
+
65
+ for i, (name, theme) in enumerate(sorted(self._themes.items())):
66
+ label = f"{name} ({'Dark' if theme.dark else 'Light'})"
67
+ options.append(Option(label, id=name))
68
+ if name == self._current_theme:
69
+ highlight_index = i
70
+
71
+ with Vertical(id="theme-container"):
72
+ yield Static("Select Theme", id="theme-title")
73
+ option_list = OptionList(*options, id="theme-list")
74
+ option_list.highlighted = highlight_index
75
+ yield option_list
76
+
77
+ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
78
+ """Live preview: change theme as user navigates."""
79
+ if event.option and event.option.id:
80
+ self.app.theme = str(event.option.id)
81
+
82
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
83
+ """Confirm selection and dismiss."""
84
+ if event.option and event.option.id:
85
+ self.dismiss(str(event.option.id))
86
+
87
+ def action_cancel(self) -> None:
88
+ """Cancel and revert to original theme."""
89
+ self.app.theme = self._original_theme
90
+ self.dismiss(None)
@@ -0,0 +1,69 @@
1
+ """Update confirmation screen for TunaCode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.screen import Screen
8
+ from textual.widgets import Static
9
+
10
+ # Modal layout constants
11
+ MODAL_WIDTH = 50
12
+ MODAL_PADDING_VERTICAL = 1
13
+ MODAL_PADDING_HORIZONTAL = 2
14
+ SECTION_MARGIN_BOTTOM = 1
15
+
16
+
17
+ class UpdateConfirmScreen(Screen[bool]):
18
+ """Modal screen to confirm update installation."""
19
+
20
+ CSS = f"""
21
+ UpdateConfirmScreen {{
22
+ align: center middle;
23
+ }}
24
+
25
+ #update-container {{
26
+ width: {MODAL_WIDTH};
27
+ height: auto;
28
+ border: solid $primary;
29
+ background: $surface;
30
+ padding: {MODAL_PADDING_VERTICAL} {MODAL_PADDING_HORIZONTAL};
31
+ }}
32
+
33
+ #update-title {{
34
+ text-style: bold;
35
+ color: $accent;
36
+ text-align: center;
37
+ margin-bottom: {SECTION_MARGIN_BOTTOM};
38
+ }}
39
+
40
+ #update-info {{
41
+ margin-bottom: {SECTION_MARGIN_BOTTOM};
42
+ }}
43
+ """
44
+
45
+ BINDINGS = [
46
+ ("escape", "cancel", "Cancel"),
47
+ ("y", "confirm", "Yes"),
48
+ ("n", "cancel", "No"),
49
+ ]
50
+
51
+ def __init__(self, current_version: str, latest_version: str) -> None:
52
+ super().__init__()
53
+ self._current = current_version
54
+ self._latest = latest_version
55
+
56
+ def compose(self) -> ComposeResult:
57
+ with Vertical(id="update-container"):
58
+ yield Static("Update Available", id="update-title")
59
+ yield Static(
60
+ f"Current: {self._current}\nLatest: {self._latest}",
61
+ id="update-info",
62
+ )
63
+ yield Static("Install update? (y/n)")
64
+
65
+ def action_confirm(self) -> None:
66
+ self.dismiss(True)
67
+
68
+ def action_cancel(self) -> None:
69
+ self.dismiss(False)
@@ -0,0 +1,129 @@
1
+ """Async shell command runner for the Textual TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import signal
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from typing import Protocol
10
+
11
+ from rich.text import Text
12
+
13
+ SHELL_COMMAND_TIMEOUT_SECONDS: float = 30.0
14
+ SHELL_COMMAND_CANCEL_GRACE_SECONDS: float = 0.5
15
+ SHELL_COMMAND_USAGE_TEXT = "Usage: !<command>"
16
+ SHELL_OUTPUT_ENCODING = "utf-8"
17
+ SHELL_CANCEL_SIGNAL = signal.SIGINT
18
+
19
+
20
+ class ShellRunnerHost(Protocol):
21
+ def notify(self, message: str, severity: str = "information") -> None: ...
22
+
23
+ def write_shell_output(self, renderable: Text) -> None: ...
24
+
25
+ def shell_status_running(self) -> None: ...
26
+
27
+ def shell_status_last(self) -> None: ...
28
+
29
+
30
+ @dataclass
31
+ class ShellRunner:
32
+ host: ShellRunnerHost
33
+
34
+ _task: asyncio.Task[None] | None = None
35
+ _process: asyncio.subprocess.Process | None = None
36
+
37
+ def is_running(self) -> bool:
38
+ return self._task is not None and not self._task.done()
39
+
40
+ def start(self, raw_cmd: str) -> None:
41
+ cmd = raw_cmd.strip()
42
+ if not cmd:
43
+ self.host.notify(SHELL_COMMAND_USAGE_TEXT, severity="warning")
44
+ return
45
+
46
+ if self.is_running():
47
+ self.host.notify(
48
+ "Shell command already running (! or Esc to cancel)",
49
+ severity="warning",
50
+ )
51
+ return
52
+
53
+ self._task = asyncio.create_task(self._run(cmd))
54
+ self._task.add_done_callback(self._on_done)
55
+
56
+ def cancel(self) -> None:
57
+ if not self.is_running():
58
+ return
59
+
60
+ process = self._process
61
+ if process is None:
62
+ assert self._task is not None
63
+ self._task.cancel()
64
+ return
65
+
66
+ try:
67
+ process.send_signal(SHELL_CANCEL_SIGNAL)
68
+ except ProcessLookupError:
69
+ assert self._task is not None
70
+ self._task.cancel()
71
+ return
72
+
73
+ assert self._task is not None
74
+ self._task.cancel()
75
+
76
+ def _on_done(self, task: asyncio.Task[None]) -> None:
77
+ try:
78
+ task.result()
79
+ except asyncio.CancelledError:
80
+ return
81
+ except Exception as exc:
82
+ self.host.write_shell_output(Text(f"Shell error: {exc}"))
83
+
84
+ async def _wait_or_kill_process(self, process: asyncio.subprocess.Process) -> None:
85
+ if process.returncode is not None:
86
+ return
87
+
88
+ try:
89
+ await asyncio.wait_for(process.wait(), timeout=SHELL_COMMAND_CANCEL_GRACE_SECONDS)
90
+ except TimeoutError:
91
+ process.kill()
92
+ await process.wait()
93
+
94
+ async def _run(self, cmd: str) -> None:
95
+ self.host.shell_status_running()
96
+ self.host.notify(f"Running: {cmd}")
97
+
98
+ process = await asyncio.create_subprocess_shell(
99
+ cmd,
100
+ stdout=asyncio.subprocess.PIPE,
101
+ stderr=asyncio.subprocess.STDOUT,
102
+ stdin=subprocess.DEVNULL,
103
+ )
104
+ self._process = process
105
+
106
+ try:
107
+ stdout, _ = await asyncio.wait_for(
108
+ process.communicate(),
109
+ timeout=SHELL_COMMAND_TIMEOUT_SECONDS,
110
+ )
111
+ except TimeoutError:
112
+ process.kill()
113
+ await process.wait()
114
+ self.host.notify("Command timed out", severity="error")
115
+ return
116
+ except asyncio.CancelledError:
117
+ await self._wait_or_kill_process(process)
118
+ self.host.notify("Shell command cancelled", severity="warning")
119
+ raise
120
+ finally:
121
+ self._process = None
122
+ self.host.shell_status_last()
123
+
124
+ output = (stdout or b"").decode(SHELL_OUTPUT_ENCODING, errors="replace").rstrip()
125
+ if output:
126
+ self.host.write_shell_output(Text(output))
127
+
128
+ if process.returncode is not None and process.returncode != 0:
129
+ self.host.notify(f"Exit code: {process.returncode}", severity="warning")