ripperdoc 0.3.0__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.
Files changed (40) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -1
  3. ripperdoc/cli/commands/agents_cmd.py +93 -53
  4. ripperdoc/cli/commands/mcp_cmd.py +3 -0
  5. ripperdoc/cli/commands/models_cmd.py +768 -283
  6. ripperdoc/cli/commands/permissions_cmd.py +107 -52
  7. ripperdoc/cli/commands/resume_cmd.py +61 -51
  8. ripperdoc/cli/commands/themes_cmd.py +31 -1
  9. ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
  10. ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
  11. ripperdoc/cli/ui/choice.py +376 -0
  12. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  13. ripperdoc/cli/ui/message_display.py +7 -0
  14. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  15. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  16. ripperdoc/cli/ui/panels.py +19 -4
  17. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  18. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  19. ripperdoc/cli/ui/provider_options.py +220 -80
  20. ripperdoc/cli/ui/rich_ui.py +91 -83
  21. ripperdoc/cli/ui/tips.py +89 -0
  22. ripperdoc/cli/ui/wizard.py +98 -45
  23. ripperdoc/core/config.py +3 -0
  24. ripperdoc/core/permissions.py +66 -104
  25. ripperdoc/core/providers/anthropic.py +11 -0
  26. ripperdoc/protocol/stdio.py +3 -1
  27. ripperdoc/tools/bash_tool.py +2 -0
  28. ripperdoc/tools/file_edit_tool.py +100 -181
  29. ripperdoc/tools/file_read_tool.py +101 -25
  30. ripperdoc/tools/multi_edit_tool.py +239 -91
  31. ripperdoc/tools/notebook_edit_tool.py +11 -29
  32. ripperdoc/utils/file_editing.py +164 -0
  33. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  34. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  35. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
  36. ripperdoc/cli/ui/interrupt_handler.py +0 -208
  37. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  38. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  39. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  40. {ripperdoc-0.3.0.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