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,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")
|