ripperdoc 0.3.1__py3-none-any.whl → 0.3.2__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +9 -1
- ripperdoc/cli/commands/agents_cmd.py +93 -53
- ripperdoc/cli/commands/mcp_cmd.py +3 -0
- ripperdoc/cli/commands/models_cmd.py +768 -283
- ripperdoc/cli/commands/permissions_cmd.py +107 -52
- ripperdoc/cli/commands/resume_cmd.py +61 -51
- ripperdoc/cli/commands/themes_cmd.py +31 -1
- ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
- ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
- ripperdoc/cli/ui/choice.py +376 -0
- ripperdoc/cli/ui/models_tui/__init__.py +5 -0
- ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
- ripperdoc/cli/ui/panels.py +19 -4
- ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
- ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
- ripperdoc/cli/ui/provider_options.py +220 -80
- ripperdoc/cli/ui/rich_ui.py +9 -11
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +25 -70
- ripperdoc/core/providers/anthropic.py +11 -0
- ripperdoc/protocol/stdio.py +3 -1
- ripperdoc/tools/bash_tool.py +2 -0
- ripperdoc/tools/file_edit_tool.py +100 -181
- ripperdoc/tools/file_read_tool.py +101 -25
- ripperdoc/tools/multi_edit_tool.py +239 -91
- ripperdoc/tools/notebook_edit_tool.py +11 -29
- ripperdoc/utils/file_editing.py +164 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
"""Textual app for managing model profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional, Callable
|
|
7
|
+
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from textual.app import App, ComposeResult
|
|
13
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import (
|
|
16
|
+
Button,
|
|
17
|
+
Checkbox,
|
|
18
|
+
DataTable,
|
|
19
|
+
Footer,
|
|
20
|
+
Header,
|
|
21
|
+
Input,
|
|
22
|
+
Select,
|
|
23
|
+
Static,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from ripperdoc.core.config import (
|
|
27
|
+
ModelProfile,
|
|
28
|
+
ProviderType,
|
|
29
|
+
add_model_profile,
|
|
30
|
+
delete_model_profile,
|
|
31
|
+
get_global_config,
|
|
32
|
+
model_supports_vision,
|
|
33
|
+
set_model_pointer,
|
|
34
|
+
)
|
|
35
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ModelFormResult:
|
|
40
|
+
name: str
|
|
41
|
+
profile: ModelProfile
|
|
42
|
+
set_as_main: bool = False
|
|
43
|
+
set_as_quick: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
47
|
+
"""Simple confirmation dialog."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self._message = message
|
|
52
|
+
|
|
53
|
+
def compose(self) -> ComposeResult:
|
|
54
|
+
with Container(id="confirm_dialog"):
|
|
55
|
+
yield Static(self._message, id="confirm_message")
|
|
56
|
+
with Horizontal(id="confirm_buttons"):
|
|
57
|
+
yield Button("Yes", id="confirm_yes", variant="primary")
|
|
58
|
+
yield Button("No", id="confirm_no")
|
|
59
|
+
|
|
60
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
61
|
+
if event.button.id == "confirm_yes":
|
|
62
|
+
self.dismiss(True)
|
|
63
|
+
else:
|
|
64
|
+
self.dismiss(False)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ModelFormScreen(ModalScreen[Optional[ModelFormResult]]):
|
|
68
|
+
"""Modal form for adding/editing models."""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
mode: str,
|
|
73
|
+
*,
|
|
74
|
+
existing_name: Optional[str] = None,
|
|
75
|
+
existing_profile: Optional[ModelProfile] = None,
|
|
76
|
+
default_set_main: bool = False,
|
|
77
|
+
default_set_quick: bool = False,
|
|
78
|
+
) -> None:
|
|
79
|
+
super().__init__()
|
|
80
|
+
self._mode = mode
|
|
81
|
+
self._existing_name = existing_name
|
|
82
|
+
self._existing_profile = existing_profile
|
|
83
|
+
self._default_set_main = default_set_main
|
|
84
|
+
self._default_set_quick = default_set_quick
|
|
85
|
+
self._error_text: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
def compose(self) -> ComposeResult:
|
|
88
|
+
title = "Add model" if self._mode == "add" else "Edit model"
|
|
89
|
+
with Container(id="form_dialog"):
|
|
90
|
+
yield Static(title, id="form_title")
|
|
91
|
+
yield Static("", id="form_error")
|
|
92
|
+
with VerticalScroll(id="form_fields"):
|
|
93
|
+
if self._mode == "add":
|
|
94
|
+
yield Static("Profile name", classes="field_label")
|
|
95
|
+
yield Input(placeholder="Profile name", id="name_input")
|
|
96
|
+
else:
|
|
97
|
+
name_display = self._existing_name or ""
|
|
98
|
+
yield Static("Profile name", classes="field_label")
|
|
99
|
+
yield Static(f"{name_display}", id="name_static", classes="field_value")
|
|
100
|
+
|
|
101
|
+
current_profile = get_profile_for_pointer("main")
|
|
102
|
+
provider_default = (
|
|
103
|
+
self._existing_profile.provider.value
|
|
104
|
+
if self._existing_profile
|
|
105
|
+
else (
|
|
106
|
+
current_profile.provider.value
|
|
107
|
+
if current_profile
|
|
108
|
+
else ProviderType.ANTHROPIC.value
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
provider_options = [(p.value, p.value) for p in ProviderType]
|
|
112
|
+
yield Static("Provider", classes="field_label")
|
|
113
|
+
yield Select(provider_options, value=provider_default, id="provider_select")
|
|
114
|
+
|
|
115
|
+
model_default = (
|
|
116
|
+
self._existing_profile.model if self._existing_profile else ""
|
|
117
|
+
)
|
|
118
|
+
yield Static("Model name", classes="field_label")
|
|
119
|
+
yield Input(value=model_default, placeholder="Model name", id="model_input")
|
|
120
|
+
|
|
121
|
+
api_key_placeholder = "[set]" if (self._existing_profile and self._existing_profile.api_key) else "[not set]"
|
|
122
|
+
yield Static("API key", classes="field_label")
|
|
123
|
+
yield Input(
|
|
124
|
+
placeholder=f"API key {api_key_placeholder} (blank=keep, '-'=clear)",
|
|
125
|
+
password=True,
|
|
126
|
+
id="api_key_input",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
auth_placeholder = "[set]" if (self._existing_profile and self._existing_profile.auth_token) else "[not set]"
|
|
130
|
+
yield Static("Auth token (Anthropic only)", classes="field_label")
|
|
131
|
+
yield Input(
|
|
132
|
+
placeholder=f"Auth token (Anthropic only) {auth_placeholder} (blank=keep, '-'=clear)",
|
|
133
|
+
password=True,
|
|
134
|
+
id="auth_token_input",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
api_base_default = self._existing_profile.api_base if self._existing_profile else ""
|
|
138
|
+
yield Static("API base", classes="field_label")
|
|
139
|
+
yield Input(
|
|
140
|
+
value=api_base_default or "",
|
|
141
|
+
placeholder="API base (optional)",
|
|
142
|
+
id="api_base_input",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
max_tokens_default = self._existing_profile.max_tokens if self._existing_profile else 4096
|
|
146
|
+
yield Static("Max output tokens", classes="field_label")
|
|
147
|
+
yield Input(
|
|
148
|
+
value=str(max_tokens_default),
|
|
149
|
+
placeholder="Max output tokens",
|
|
150
|
+
id="max_tokens_input",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
temp_default = self._existing_profile.temperature if self._existing_profile else 0.7
|
|
154
|
+
yield Static("Temperature", classes="field_label")
|
|
155
|
+
yield Input(
|
|
156
|
+
value=str(temp_default),
|
|
157
|
+
placeholder="Temperature",
|
|
158
|
+
id="temperature_input",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
context_default = (
|
|
162
|
+
str(self._existing_profile.context_window)
|
|
163
|
+
if self._existing_profile and self._existing_profile.context_window
|
|
164
|
+
else ""
|
|
165
|
+
)
|
|
166
|
+
yield Static("Context window tokens", classes="field_label")
|
|
167
|
+
yield Input(
|
|
168
|
+
value=context_default,
|
|
169
|
+
placeholder="Context window tokens (optional)",
|
|
170
|
+
id="context_window_input",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
yield Static("Supports vision", classes="field_label")
|
|
174
|
+
supports_default = (
|
|
175
|
+
"auto"
|
|
176
|
+
if not self._existing_profile or self._existing_profile.supports_vision is None
|
|
177
|
+
else ("yes" if self._existing_profile.supports_vision else "no")
|
|
178
|
+
)
|
|
179
|
+
supports_options = [
|
|
180
|
+
("auto (detect)", "auto"),
|
|
181
|
+
("yes (image input)", "yes"),
|
|
182
|
+
("no (text-only)", "no"),
|
|
183
|
+
]
|
|
184
|
+
yield Select(supports_options, value=supports_default, id="vision_select")
|
|
185
|
+
|
|
186
|
+
set_main_value = self._default_set_main
|
|
187
|
+
set_quick_value = self._default_set_quick
|
|
188
|
+
if self._mode == "edit" and self._existing_name:
|
|
189
|
+
config = get_global_config()
|
|
190
|
+
set_main_value = getattr(config.model_pointers, "main", "") == self._existing_name
|
|
191
|
+
set_quick_value = (
|
|
192
|
+
getattr(config.model_pointers, "quick", "") == self._existing_name
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
yield Static("Set as main", classes="field_label")
|
|
196
|
+
yield Checkbox("Set as main", value=set_main_value, id="set_main")
|
|
197
|
+
yield Static("Set as quick", classes="field_label")
|
|
198
|
+
yield Checkbox("Set as quick", value=set_quick_value, id="set_quick")
|
|
199
|
+
|
|
200
|
+
with Horizontal(id="form_buttons"):
|
|
201
|
+
yield Button("Save", id="form_save", variant="primary")
|
|
202
|
+
yield Button("Cancel", id="form_cancel")
|
|
203
|
+
|
|
204
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
205
|
+
if event.button.id == "form_cancel":
|
|
206
|
+
self.dismiss(None)
|
|
207
|
+
return
|
|
208
|
+
if event.button.id != "form_save":
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
name_input = self.query_one("#name_input", Input) if self._mode == "add" else None
|
|
212
|
+
provider_select = self.query_one("#provider_select", Select)
|
|
213
|
+
model_input = self.query_one("#model_input", Input)
|
|
214
|
+
api_key_input = self.query_one("#api_key_input", Input)
|
|
215
|
+
auth_token_input = self.query_one("#auth_token_input", Input)
|
|
216
|
+
api_base_input = self.query_one("#api_base_input", Input)
|
|
217
|
+
max_tokens_input = self.query_one("#max_tokens_input", Input)
|
|
218
|
+
temperature_input = self.query_one("#temperature_input", Input)
|
|
219
|
+
context_window_input = self.query_one("#context_window_input", Input)
|
|
220
|
+
vision_select = self.query_one("#vision_select", Select)
|
|
221
|
+
|
|
222
|
+
name = self._existing_name or ""
|
|
223
|
+
if self._mode == "add":
|
|
224
|
+
name = (name_input.value or "").strip() if name_input else ""
|
|
225
|
+
if not name:
|
|
226
|
+
self._set_error("Profile name is required.")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
provider_value = (provider_select.value or "").strip()
|
|
230
|
+
try:
|
|
231
|
+
provider = ProviderType(provider_value)
|
|
232
|
+
except ValueError:
|
|
233
|
+
self._set_error("Invalid provider.")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
model_name = (model_input.value or "").strip()
|
|
237
|
+
if not model_name:
|
|
238
|
+
self._set_error("Model name is required.")
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
api_key_raw = (api_key_input.value or "").strip()
|
|
242
|
+
if self._existing_profile:
|
|
243
|
+
if api_key_raw == "-":
|
|
244
|
+
api_key = None
|
|
245
|
+
elif api_key_raw:
|
|
246
|
+
api_key = api_key_raw
|
|
247
|
+
else:
|
|
248
|
+
api_key = self._existing_profile.api_key
|
|
249
|
+
else:
|
|
250
|
+
api_key = api_key_raw or None
|
|
251
|
+
|
|
252
|
+
auth_token_raw = (auth_token_input.value or "").strip()
|
|
253
|
+
if provider == ProviderType.ANTHROPIC or (
|
|
254
|
+
self._existing_profile and self._existing_profile.provider == ProviderType.ANTHROPIC
|
|
255
|
+
):
|
|
256
|
+
if self._existing_profile:
|
|
257
|
+
if auth_token_raw == "-":
|
|
258
|
+
auth_token = None
|
|
259
|
+
elif auth_token_raw:
|
|
260
|
+
auth_token = auth_token_raw
|
|
261
|
+
else:
|
|
262
|
+
auth_token = self._existing_profile.auth_token
|
|
263
|
+
else:
|
|
264
|
+
auth_token = auth_token_raw or None
|
|
265
|
+
else:
|
|
266
|
+
auth_token = None
|
|
267
|
+
|
|
268
|
+
api_base = (api_base_input.value or "").strip() or None
|
|
269
|
+
|
|
270
|
+
max_tokens = self._parse_int(max_tokens_input.value, "Max output tokens")
|
|
271
|
+
if max_tokens is None:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
temperature = self._parse_float(temperature_input.value, "Temperature")
|
|
275
|
+
if temperature is None:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
context_window = None
|
|
279
|
+
context_raw = (context_window_input.value or "").strip()
|
|
280
|
+
if context_raw:
|
|
281
|
+
context_window = self._parse_int(context_raw, "Context window tokens")
|
|
282
|
+
if context_window is None:
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
supports_value = (vision_select.value or "auto").strip().lower()
|
|
286
|
+
if supports_value == "yes":
|
|
287
|
+
supports_vision = True
|
|
288
|
+
elif supports_value == "no":
|
|
289
|
+
supports_vision = False
|
|
290
|
+
else:
|
|
291
|
+
supports_vision = None
|
|
292
|
+
|
|
293
|
+
set_as_main = bool(self.query_one("#set_main", Checkbox).value)
|
|
294
|
+
set_as_quick = bool(self.query_one("#set_quick", Checkbox).value)
|
|
295
|
+
|
|
296
|
+
profile = ModelProfile(
|
|
297
|
+
provider=provider,
|
|
298
|
+
model=model_name,
|
|
299
|
+
api_key=api_key,
|
|
300
|
+
auth_token=auth_token,
|
|
301
|
+
api_base=api_base,
|
|
302
|
+
max_tokens=max_tokens,
|
|
303
|
+
temperature=temperature,
|
|
304
|
+
context_window=context_window,
|
|
305
|
+
supports_vision=supports_vision,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self.dismiss(
|
|
309
|
+
ModelFormResult(
|
|
310
|
+
name=name,
|
|
311
|
+
profile=profile,
|
|
312
|
+
set_as_main=set_as_main,
|
|
313
|
+
set_as_quick=set_as_quick,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def _set_error(self, message: str) -> None:
|
|
318
|
+
error_widget = self.query_one("#form_error", Static)
|
|
319
|
+
error_widget.update(message)
|
|
320
|
+
|
|
321
|
+
def _parse_int(self, raw: str, label: str) -> Optional[int]:
|
|
322
|
+
raw = (raw or "").strip()
|
|
323
|
+
if not raw:
|
|
324
|
+
if self._existing_profile and label == "Max output tokens":
|
|
325
|
+
return self._existing_profile.max_tokens
|
|
326
|
+
if self._existing_profile and label == "Context window tokens":
|
|
327
|
+
return self._existing_profile.context_window
|
|
328
|
+
if label == "Max output tokens":
|
|
329
|
+
return 4096
|
|
330
|
+
return None
|
|
331
|
+
try:
|
|
332
|
+
return int(raw)
|
|
333
|
+
except ValueError:
|
|
334
|
+
self._set_error(f"Invalid number for {label}.")
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
def _parse_float(self, raw: str, label: str) -> Optional[float]:
|
|
338
|
+
raw = (raw or "").strip()
|
|
339
|
+
if not raw:
|
|
340
|
+
if self._existing_profile and label == "Temperature":
|
|
341
|
+
return self._existing_profile.temperature
|
|
342
|
+
if label == "Temperature":
|
|
343
|
+
return 0.7
|
|
344
|
+
return None
|
|
345
|
+
try:
|
|
346
|
+
return float(raw)
|
|
347
|
+
except ValueError:
|
|
348
|
+
self._set_error(f"Invalid number for {label}.")
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class ModelsApp(App[None]):
|
|
353
|
+
CSS = """
|
|
354
|
+
#status_bar {
|
|
355
|
+
height: 1;
|
|
356
|
+
color: $text-muted;
|
|
357
|
+
padding: 0 1;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#body {
|
|
361
|
+
layout: horizontal;
|
|
362
|
+
height: 1fr;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#models_table {
|
|
366
|
+
width: 42%;
|
|
367
|
+
min-width: 40;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#details_panel {
|
|
371
|
+
width: 58%;
|
|
372
|
+
padding: 0 1;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#form_dialog, #confirm_dialog {
|
|
376
|
+
width: 72;
|
|
377
|
+
max-height: 90%;
|
|
378
|
+
background: $panel;
|
|
379
|
+
border: round $accent;
|
|
380
|
+
padding: 1 2;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#form_title {
|
|
384
|
+
text-style: bold;
|
|
385
|
+
padding: 0 0 1 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#form_error {
|
|
389
|
+
color: $error;
|
|
390
|
+
padding: 0 0 1 0;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#form_fields Input, #form_fields Select {
|
|
394
|
+
margin: 0 0 1 0;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#form_fields {
|
|
398
|
+
height: 1fr;
|
|
399
|
+
overflow: auto;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.field_label {
|
|
403
|
+
color: $text-muted;
|
|
404
|
+
padding: 0 0 0 0;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.field_value {
|
|
408
|
+
color: $accent;
|
|
409
|
+
text-style: bold;
|
|
410
|
+
padding: 0 0 1 0;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
#form_buttons, #confirm_buttons {
|
|
415
|
+
align-horizontal: right;
|
|
416
|
+
padding-top: 1;
|
|
417
|
+
height: auto;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#confirm_message {
|
|
421
|
+
padding: 0 0 1 0;
|
|
422
|
+
}
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
BINDINGS = [
|
|
426
|
+
("a", "add", "Add"),
|
|
427
|
+
("e", "edit", "Edit"),
|
|
428
|
+
("d", "delete", "Delete"),
|
|
429
|
+
("m", "set_main", "Set main"),
|
|
430
|
+
("k", "set_quick", "Set quick"),
|
|
431
|
+
("r", "refresh", "Refresh"),
|
|
432
|
+
("q", "quit", "Quit"),
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
def __init__(self) -> None:
|
|
436
|
+
super().__init__()
|
|
437
|
+
self._selected_name: Optional[str] = None
|
|
438
|
+
self._row_names: list[str] = []
|
|
439
|
+
|
|
440
|
+
def compose(self) -> ComposeResult:
|
|
441
|
+
yield Header(show_clock=False)
|
|
442
|
+
yield Static("", id="status_bar")
|
|
443
|
+
with Container(id="body"):
|
|
444
|
+
yield DataTable(id="models_table")
|
|
445
|
+
yield Static(id="details_panel")
|
|
446
|
+
yield Footer()
|
|
447
|
+
|
|
448
|
+
def on_mount(self) -> None:
|
|
449
|
+
table = self.query_one("#models_table", DataTable)
|
|
450
|
+
table.add_columns("#", "Name", "Ptr", "Provider", "Model")
|
|
451
|
+
try:
|
|
452
|
+
table.cursor_type = "row"
|
|
453
|
+
table.zebra_stripes = True
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
self._refresh_models(select_first=True)
|
|
457
|
+
|
|
458
|
+
def action_refresh(self) -> None:
|
|
459
|
+
self._refresh_models(select_first=False)
|
|
460
|
+
self._set_status("Refreshed.")
|
|
461
|
+
|
|
462
|
+
def action_add(self) -> None:
|
|
463
|
+
config = get_global_config()
|
|
464
|
+
default_set_main = (
|
|
465
|
+
not config.model_profiles
|
|
466
|
+
or getattr(config.model_pointers, "main", "") not in config.model_profiles
|
|
467
|
+
)
|
|
468
|
+
screen = ModelFormScreen(
|
|
469
|
+
"add",
|
|
470
|
+
default_set_main=default_set_main,
|
|
471
|
+
default_set_quick=False,
|
|
472
|
+
)
|
|
473
|
+
self.push_screen(screen, self._handle_add_result)
|
|
474
|
+
|
|
475
|
+
def action_edit(self) -> None:
|
|
476
|
+
profile = self._selected_profile()
|
|
477
|
+
if not profile:
|
|
478
|
+
self._set_status("No model selected.")
|
|
479
|
+
return
|
|
480
|
+
screen = ModelFormScreen(
|
|
481
|
+
"edit",
|
|
482
|
+
existing_name=self._selected_name,
|
|
483
|
+
existing_profile=profile,
|
|
484
|
+
)
|
|
485
|
+
self.push_screen(screen, self._handle_edit_result)
|
|
486
|
+
|
|
487
|
+
def action_delete(self) -> None:
|
|
488
|
+
profile = self._selected_profile()
|
|
489
|
+
if not profile:
|
|
490
|
+
self._set_status("No model selected.")
|
|
491
|
+
return
|
|
492
|
+
screen = ConfirmScreen(f"Delete model '{self._selected_name}'?")
|
|
493
|
+
self.push_screen(screen, self._handle_delete_confirm)
|
|
494
|
+
|
|
495
|
+
def action_set_main(self) -> None:
|
|
496
|
+
if not self._selected_name:
|
|
497
|
+
self._set_status("No model selected.")
|
|
498
|
+
return
|
|
499
|
+
try:
|
|
500
|
+
set_model_pointer("main", self._selected_name)
|
|
501
|
+
self._set_status(f"Main -> {self._selected_name}")
|
|
502
|
+
self._refresh_models(select_first=False)
|
|
503
|
+
except (ValueError, KeyError, OSError, IOError, PermissionError) as exc:
|
|
504
|
+
self._set_status(str(exc))
|
|
505
|
+
|
|
506
|
+
def action_set_quick(self) -> None:
|
|
507
|
+
if not self._selected_name:
|
|
508
|
+
self._set_status("No model selected.")
|
|
509
|
+
return
|
|
510
|
+
try:
|
|
511
|
+
set_model_pointer("quick", self._selected_name)
|
|
512
|
+
self._set_status(f"Quick -> {self._selected_name}")
|
|
513
|
+
self._refresh_models(select_first=False)
|
|
514
|
+
except (ValueError, KeyError, OSError, IOError, PermissionError) as exc:
|
|
515
|
+
self._set_status(str(exc))
|
|
516
|
+
|
|
517
|
+
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
518
|
+
self._select_by_index(int(event.cursor_row))
|
|
519
|
+
|
|
520
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
521
|
+
self._select_by_index(int(event.cursor_row))
|
|
522
|
+
self.action_edit()
|
|
523
|
+
|
|
524
|
+
def _handle_add_result(self, result: Optional[ModelFormResult]) -> None:
|
|
525
|
+
if not result:
|
|
526
|
+
return
|
|
527
|
+
try:
|
|
528
|
+
add_model_profile(
|
|
529
|
+
result.name,
|
|
530
|
+
result.profile,
|
|
531
|
+
overwrite=False,
|
|
532
|
+
set_as_main=result.set_as_main,
|
|
533
|
+
)
|
|
534
|
+
if result.set_as_quick:
|
|
535
|
+
set_model_pointer("quick", result.name)
|
|
536
|
+
except (OSError, IOError, ValueError, TypeError, PermissionError) as exc:
|
|
537
|
+
self._set_status(str(exc))
|
|
538
|
+
return
|
|
539
|
+
self._selected_name = result.name
|
|
540
|
+
self._set_status(f"Saved {result.name}.")
|
|
541
|
+
self._refresh_models(select_first=False)
|
|
542
|
+
|
|
543
|
+
def _handle_edit_result(self, result: Optional[ModelFormResult]) -> None:
|
|
544
|
+
if not result:
|
|
545
|
+
return
|
|
546
|
+
try:
|
|
547
|
+
add_model_profile(
|
|
548
|
+
result.name,
|
|
549
|
+
result.profile,
|
|
550
|
+
overwrite=True,
|
|
551
|
+
set_as_main=False,
|
|
552
|
+
)
|
|
553
|
+
if result.set_as_main:
|
|
554
|
+
set_model_pointer("main", result.name)
|
|
555
|
+
if result.set_as_quick:
|
|
556
|
+
set_model_pointer("quick", result.name)
|
|
557
|
+
except (OSError, IOError, ValueError, TypeError, PermissionError) as exc:
|
|
558
|
+
self._set_status(str(exc))
|
|
559
|
+
return
|
|
560
|
+
self._selected_name = result.name
|
|
561
|
+
self._set_status(f"Updated {result.name}.")
|
|
562
|
+
self._refresh_models(select_first=False)
|
|
563
|
+
|
|
564
|
+
def _handle_delete_confirm(self, confirmed: bool) -> None:
|
|
565
|
+
if not confirmed or not self._selected_name:
|
|
566
|
+
return
|
|
567
|
+
try:
|
|
568
|
+
delete_model_profile(self._selected_name)
|
|
569
|
+
except (OSError, IOError, KeyError, PermissionError) as exc:
|
|
570
|
+
self._set_status(str(exc))
|
|
571
|
+
return
|
|
572
|
+
self._set_status(f"Deleted {self._selected_name}.")
|
|
573
|
+
self._selected_name = None
|
|
574
|
+
self._refresh_models(select_first=True)
|
|
575
|
+
|
|
576
|
+
def _refresh_models(self, select_first: bool) -> None:
|
|
577
|
+
config = get_global_config()
|
|
578
|
+
table = self.query_one("#models_table", DataTable)
|
|
579
|
+
table.clear(columns=False)
|
|
580
|
+
pointer_map = config.model_pointers.model_dump()
|
|
581
|
+
self._row_names = []
|
|
582
|
+
|
|
583
|
+
for idx, (name, profile) in enumerate(config.model_profiles.items(), start=1):
|
|
584
|
+
self._row_names.append(name)
|
|
585
|
+
markers = [ptr for ptr, value in pointer_map.items() if value == name]
|
|
586
|
+
pointer_label = ",".join(markers) if markers else "-"
|
|
587
|
+
table.add_row(
|
|
588
|
+
str(idx),
|
|
589
|
+
name,
|
|
590
|
+
pointer_label,
|
|
591
|
+
profile.provider.value,
|
|
592
|
+
profile.model,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
if not config.model_profiles:
|
|
596
|
+
self._selected_name = None
|
|
597
|
+
self._update_details()
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
if self._selected_name and self._selected_name in config.model_profiles:
|
|
601
|
+
try:
|
|
602
|
+
row_index = self._row_names.index(self._selected_name)
|
|
603
|
+
except ValueError:
|
|
604
|
+
row_index = 0
|
|
605
|
+
self._move_cursor(table, row_index)
|
|
606
|
+
self._update_details()
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
if select_first:
|
|
610
|
+
first_name = next(iter(config.model_profiles))
|
|
611
|
+
self._selected_name = first_name
|
|
612
|
+
self._move_cursor(table, 0)
|
|
613
|
+
self._update_details()
|
|
614
|
+
|
|
615
|
+
def _select_by_index(self, row_index: int) -> None:
|
|
616
|
+
if row_index < 0 or row_index >= len(self._row_names):
|
|
617
|
+
return
|
|
618
|
+
self._selected_name = self._row_names[row_index]
|
|
619
|
+
self._update_details()
|
|
620
|
+
|
|
621
|
+
def _move_cursor(self, table: DataTable, row_index: int) -> None:
|
|
622
|
+
try:
|
|
623
|
+
table.move_cursor(row=row_index)
|
|
624
|
+
except TypeError:
|
|
625
|
+
try:
|
|
626
|
+
table.move_cursor(row_index, 0)
|
|
627
|
+
except Exception:
|
|
628
|
+
pass
|
|
629
|
+
except Exception:
|
|
630
|
+
pass
|
|
631
|
+
|
|
632
|
+
def _selected_profile(self) -> Optional[ModelProfile]:
|
|
633
|
+
if not self._selected_name:
|
|
634
|
+
return None
|
|
635
|
+
config = get_global_config()
|
|
636
|
+
return config.model_profiles.get(self._selected_name)
|
|
637
|
+
|
|
638
|
+
def _update_details(self) -> None:
|
|
639
|
+
details = self.query_one("#details_panel", Static)
|
|
640
|
+
if not self._selected_name:
|
|
641
|
+
details.update("No model selected.")
|
|
642
|
+
return
|
|
643
|
+
config = get_global_config()
|
|
644
|
+
profile = config.model_profiles.get(self._selected_name)
|
|
645
|
+
if not profile:
|
|
646
|
+
details.update("No model selected.")
|
|
647
|
+
return
|
|
648
|
+
pointer_map = config.model_pointers.model_dump()
|
|
649
|
+
markers = [ptr for ptr, value in pointer_map.items() if value == self._selected_name]
|
|
650
|
+
marker_text = ", ".join(markers) if markers else "-"
|
|
651
|
+
vision_display = self._vision_display(profile)
|
|
652
|
+
|
|
653
|
+
table = Table.grid(padding=(0, 2))
|
|
654
|
+
table.add_column(style="cyan", no_wrap=True)
|
|
655
|
+
table.add_column()
|
|
656
|
+
table.add_row("Profile", self._selected_name)
|
|
657
|
+
table.add_row("Pointers", marker_text)
|
|
658
|
+
table.add_row("Provider", profile.provider.value)
|
|
659
|
+
table.add_row("Model", profile.model)
|
|
660
|
+
table.add_row("API base", profile.api_base or "-")
|
|
661
|
+
table.add_row(
|
|
662
|
+
"Context",
|
|
663
|
+
str(profile.context_window) if profile.context_window else "auto",
|
|
664
|
+
)
|
|
665
|
+
table.add_row("Max tokens", str(profile.max_tokens))
|
|
666
|
+
table.add_row("Temperature", str(profile.temperature))
|
|
667
|
+
table.add_row("Vision", vision_display)
|
|
668
|
+
table.add_row("API key", "set" if profile.api_key else "unset")
|
|
669
|
+
if profile.provider == ProviderType.ANTHROPIC:
|
|
670
|
+
table.add_row(
|
|
671
|
+
"Auth token",
|
|
672
|
+
"set" if getattr(profile, "auth_token", None) else "unset",
|
|
673
|
+
)
|
|
674
|
+
if profile.openai_tool_mode:
|
|
675
|
+
table.add_row("OpenAI tool mode", profile.openai_tool_mode)
|
|
676
|
+
if profile.thinking_mode:
|
|
677
|
+
table.add_row("Thinking mode", profile.thinking_mode)
|
|
678
|
+
|
|
679
|
+
details.update(Panel(table, title=f"Model: {self._selected_name}", box=box.ROUNDED))
|
|
680
|
+
|
|
681
|
+
def _vision_display(self, profile: ModelProfile) -> str:
|
|
682
|
+
if profile.supports_vision is None:
|
|
683
|
+
detected = model_supports_vision(profile)
|
|
684
|
+
return f"auto (detected {'yes' if detected else 'no'})"
|
|
685
|
+
return "yes" if profile.supports_vision else "no"
|
|
686
|
+
|
|
687
|
+
def _set_status(self, message: str) -> None:
|
|
688
|
+
status = self.query_one("#status_bar", Static)
|
|
689
|
+
status.update(message)
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def run_models_tui(on_exit: Optional[Callable[[], Any]] = None) -> bool:
|
|
693
|
+
"""Run the Textual models TUI."""
|
|
694
|
+
app = ModelsApp()
|
|
695
|
+
app.run()
|
|
696
|
+
if on_exit:
|
|
697
|
+
on_exit()
|
|
698
|
+
return True
|