soothe-cli 0.1.0__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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,988 @@
1
+ """Interactive model selector screen for /model command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import TYPE_CHECKING, Any, ClassVar
8
+
9
+ from textual.binding import Binding, BindingType
10
+ from textual.containers import Container, Vertical, VerticalScroll
11
+ from textual.content import Content
12
+ from textual.events import (
13
+ Click, # noqa: TC002 - needed at runtime for Textual event dispatch
14
+ )
15
+ from textual.fuzzy import Matcher
16
+ from textual.message import Message
17
+ from textual.screen import ModalScreen
18
+ from textual.widgets import Input, Static
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Mapping
22
+
23
+ from textual.app import ComposeResult
24
+
25
+ from soothe_cli.tui import theme
26
+ from soothe_cli.tui.config import Glyphs, get_glyphs, is_ascii_mode
27
+ from soothe_cli.tui.model_config import (
28
+ ModelConfig,
29
+ clear_default_model,
30
+ get_available_models,
31
+ get_model_profiles,
32
+ has_provider_credentials,
33
+ save_default_model,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class ModelOption(Static):
40
+ """A clickable model option in the selector."""
41
+
42
+ def __init__(
43
+ self,
44
+ label: str | Content,
45
+ model_spec: str,
46
+ provider: str,
47
+ index: int,
48
+ *,
49
+ has_creds: bool | None = True,
50
+ classes: str = "",
51
+ ) -> None:
52
+ """Initialize a model option.
53
+
54
+ Args:
55
+ label: Display content — a `Content` object (preferred) or a
56
+ plain string that `Static` will parse as markup.
57
+ model_spec: The model specification (provider:model format).
58
+ provider: The provider name.
59
+ index: The index of this option in the filtered list.
60
+ has_creds: Whether the provider has valid credentials. True if
61
+ confirmed, False if missing, None if unknown.
62
+ classes: CSS classes for styling.
63
+ """
64
+ super().__init__(label, classes=classes)
65
+ self.model_spec = model_spec
66
+ self.provider = provider
67
+ self.index = index
68
+ self.has_creds = has_creds
69
+
70
+ class Clicked(Message):
71
+ """Message sent when a model option is clicked."""
72
+
73
+ def __init__(self, model_spec: str, provider: str, index: int) -> None:
74
+ """Initialize the Clicked message.
75
+
76
+ Args:
77
+ model_spec: The model specification.
78
+ provider: The provider name.
79
+ index: The index of the clicked option.
80
+ """
81
+ super().__init__()
82
+ self.model_spec = model_spec
83
+ self.provider = provider
84
+ self.index = index
85
+
86
+ def on_click(self, event: Click) -> None:
87
+ """Handle click on this option.
88
+
89
+ Args:
90
+ event: The click event.
91
+ """
92
+ event.stop()
93
+ self.post_message(self.Clicked(self.model_spec, self.provider, self.index))
94
+
95
+
96
+ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
97
+ """Full-screen modal for model selection.
98
+
99
+ Displays available models grouped by provider with keyboard navigation
100
+ and search filtering. Current model is highlighted.
101
+
102
+ Returns (model_spec, provider) tuple on selection, or None on cancel.
103
+ """
104
+
105
+ BINDINGS: ClassVar[list[BindingType]] = [
106
+ Binding("up", "move_up", "Up", show=False, priority=True),
107
+ Binding("k", "move_up", "Up", show=False, priority=True),
108
+ Binding("down", "move_down", "Down", show=False, priority=True),
109
+ Binding("j", "move_down", "Down", show=False, priority=True),
110
+ Binding("tab", "tab_complete", "Tab complete", show=False, priority=True),
111
+ Binding("pageup", "page_up", "Page up", show=False, priority=True),
112
+ Binding("pagedown", "page_down", "Page down", show=False, priority=True),
113
+ Binding("enter", "select", "Select", show=False, priority=True),
114
+ Binding("ctrl+s", "set_default", "Set default", show=False, priority=True),
115
+ Binding("escape", "cancel", "Cancel", show=False, priority=True),
116
+ ]
117
+
118
+ CSS = """
119
+ ModelSelectorScreen {
120
+ align: center middle;
121
+ }
122
+
123
+ ModelSelectorScreen > Vertical {
124
+ width: 80;
125
+ max-width: 90%;
126
+ height: 80%;
127
+ background: $surface;
128
+ border: solid $primary;
129
+ padding: 1 2;
130
+ }
131
+
132
+ ModelSelectorScreen .model-selector-title {
133
+ text-style: bold;
134
+ color: $primary;
135
+ text-align: center;
136
+ margin-bottom: 1;
137
+ }
138
+
139
+ ModelSelectorScreen #model-filter {
140
+ margin-bottom: 1;
141
+ border: solid $primary-lighten-2;
142
+ }
143
+
144
+ ModelSelectorScreen #model-filter:focus {
145
+ border: solid $primary;
146
+ }
147
+
148
+ ModelSelectorScreen .model-list {
149
+ height: 1fr;
150
+ min-height: 5;
151
+ scrollbar-gutter: stable;
152
+ background: $background;
153
+ }
154
+
155
+ ModelSelectorScreen #model-options {
156
+ height: auto;
157
+ }
158
+
159
+ ModelSelectorScreen .model-provider-header {
160
+ color: $primary;
161
+ margin-top: 1;
162
+ }
163
+
164
+ ModelSelectorScreen #model-options > .model-provider-header:first-child {
165
+ margin-top: 0;
166
+ }
167
+
168
+ ModelSelectorScreen .model-option {
169
+ height: 1;
170
+ padding: 0 1;
171
+ }
172
+
173
+ ModelSelectorScreen .model-option:hover {
174
+ background: $surface-lighten-1;
175
+ }
176
+
177
+ ModelSelectorScreen .model-option-selected {
178
+ background: $primary;
179
+ color: $background;
180
+ text-style: bold;
181
+ }
182
+
183
+ ModelSelectorScreen .model-option-selected:hover {
184
+ background: $primary-lighten-1;
185
+ }
186
+
187
+ ModelSelectorScreen .model-option-current {
188
+ text-style: italic;
189
+ }
190
+
191
+ ModelSelectorScreen .model-selector-help {
192
+ height: 1;
193
+ color: $text-muted;
194
+ text-style: italic;
195
+ margin-top: 1;
196
+ text-align: center;
197
+ }
198
+
199
+ ModelSelectorScreen .model-detail-footer {
200
+ height: 4;
201
+ padding: 0 2;
202
+ margin-top: 1;
203
+ }
204
+ """
205
+
206
+ def __init__(
207
+ self,
208
+ current_model: str | None = None,
209
+ current_provider: str | None = None,
210
+ cli_profile_override: dict[str, Any] | None = None,
211
+ *,
212
+ preloaded: tuple[
213
+ list[tuple[str, str]],
214
+ str | None,
215
+ Mapping[str, dict[str, Any]],
216
+ ]
217
+ | None = None,
218
+ wire_credential_map: dict[str, bool | None] | None = None,
219
+ ) -> None:
220
+ """Initialize the ModelSelectorScreen.
221
+
222
+ Data loading (model discovery, profiles) is deferred to `on_mount`
223
+ so the screen pushes instantly and populates asynchronously.
224
+
225
+ Args:
226
+ current_model: The currently active model name (to highlight).
227
+ current_provider: The provider of the current model.
228
+ cli_profile_override: Extra profile fields from `--profile-override`.
229
+
230
+ Merged on top of upstream + config.yml profiles so that CLI
231
+ overrides appear with `*` markers in the detail footer.
232
+ preloaded: When set, skip local config discovery and use this
233
+ ``(models, default_spec, profiles)`` tuple (e.g. from daemon ``models_list``).
234
+ wire_credential_map: Optional per-provider credential hints from the
235
+ daemon host (``has_credentials`` rows); when unset, uses local
236
+ ``has_provider_credentials``.
237
+ """
238
+ super().__init__()
239
+ self._current_model = current_model
240
+ self._current_provider = current_provider
241
+ self._cli_profile_override = cli_profile_override
242
+ self._preloaded = preloaded
243
+ self._wire_credential_map = wire_credential_map
244
+
245
+ # Model data — populated asynchronously in on_mount via _load_model_data
246
+ self._all_models: list[tuple[str, str]] = []
247
+ self._filtered_models: list[tuple[str, str]] = []
248
+ self._selected_index = 0
249
+ self._options_container: Container | None = None
250
+ self._option_widgets: list[ModelOption] = []
251
+ self._filter_text = ""
252
+ self._current_spec: str | None = None
253
+ if current_model and current_provider:
254
+ self._current_spec = f"{current_provider}:{current_model}"
255
+ self._default_spec: str | None = None
256
+ self._profiles: Mapping[str, dict[str, Any]] = {}
257
+ self._loaded = False
258
+
259
+ def _find_current_model_index(self) -> int:
260
+ """Find the index of the current model in the filtered list.
261
+
262
+ Returns:
263
+ Index of the current model, or 0 if not found.
264
+ """
265
+ if not self._current_model or not self._current_provider:
266
+ return 0
267
+
268
+ current_spec = f"{self._current_provider}:{self._current_model}"
269
+ for i, (model_spec, _) in enumerate(self._filtered_models):
270
+ if model_spec == current_spec:
271
+ return i
272
+ return 0
273
+
274
+ def compose(self) -> ComposeResult:
275
+ """Compose the screen layout.
276
+
277
+ Yields:
278
+ Widgets for the model selector UI.
279
+ """
280
+ glyphs = get_glyphs()
281
+
282
+ with Vertical():
283
+ # Title with current model in provider:model format
284
+ if self._current_model and self._current_provider:
285
+ current_spec = f"{self._current_provider}:{self._current_model}"
286
+ title = f"Select Model (current: {current_spec})"
287
+ elif self._current_model:
288
+ title = f"Select Model (current: {self._current_model})"
289
+ else:
290
+ title = "Select Model"
291
+ yield Static(title, classes="model-selector-title")
292
+
293
+ # Search input
294
+ yield Input(
295
+ placeholder="Type to filter or enter provider:model...",
296
+ id="model-filter",
297
+ )
298
+
299
+ # Scrollable model list
300
+ with VerticalScroll(classes="model-list"):
301
+ self._options_container = Container(id="model-options")
302
+ yield self._options_container
303
+
304
+ # Model detail footer
305
+ yield Static("", classes="model-detail-footer", id="model-detail-footer")
306
+
307
+ # Help text
308
+ help_text = (
309
+ f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate"
310
+ f" {glyphs.bullet} Enter select"
311
+ f" {glyphs.bullet} Ctrl+S set default"
312
+ f" {glyphs.bullet} Esc cancel"
313
+ )
314
+ yield Static(help_text, classes="model-selector-help")
315
+
316
+ @staticmethod
317
+ def _load_model_data(
318
+ cli_override: dict[str, Any] | None,
319
+ ) -> tuple[
320
+ list[tuple[str, str]],
321
+ str | None,
322
+ Mapping[str, dict[str, Any]],
323
+ ]:
324
+ """Gather model discovery data synchronously.
325
+
326
+ Intended to be called via `asyncio.to_thread` so filesystem I/O in
327
+ `get_available_models` does not block the event loop.
328
+
329
+ Returns:
330
+ Tuple of (all_models, default_spec, profiles) where
331
+ `all_models` is a list of `(provider:model spec, provider)`
332
+ pairs, `default_spec` is the configured default model or
333
+ `None`, and `profiles` maps spec strings to profile entries.
334
+ """
335
+ all_models: list[tuple[str, str]] = []
336
+ for entry in get_available_models():
337
+ if not entry.model:
338
+ continue
339
+ prov = entry.provider
340
+ mod = entry.model
341
+ all_models.append((f"{prov}:{mod}", prov))
342
+
343
+ config = ModelConfig.load()
344
+ profiles = get_model_profiles(cli_override=cli_override)
345
+ return all_models, config.default_model, profiles
346
+
347
+ async def on_mount(self) -> None:
348
+ """Set up the screen on mount.
349
+
350
+ Loads model data in a background thread so the screen frame renders
351
+ immediately, then populates the model list.
352
+ """
353
+ if is_ascii_mode():
354
+ colors = theme.get_theme_colors(self)
355
+ container = self.query_one(Vertical)
356
+ container.styles.border = ("ascii", colors.success)
357
+
358
+ # Focus the filter input immediately so the user can start typing
359
+ # while model data loads.
360
+ filter_input = self.query_one("#model-filter", Input)
361
+ filter_input.focus()
362
+
363
+ if self._preloaded is not None:
364
+ all_models, default_spec, profiles = self._preloaded
365
+ if not self.is_running:
366
+ return
367
+ self._all_models = all_models
368
+ self._default_spec = default_spec
369
+ self._profiles = profiles
370
+ self._filtered_models = list(self._all_models)
371
+ self._selected_index = self._find_current_model_index()
372
+ self._loaded = True
373
+ if self._filter_text:
374
+ self._update_filtered_list()
375
+ await self._update_display()
376
+ self._update_footer()
377
+ return
378
+
379
+ # Offload to thread because get_available_models does filesystem I/O
380
+ try:
381
+ all_models, default_spec, profiles = await asyncio.to_thread(
382
+ self._load_model_data, self._cli_profile_override
383
+ )
384
+ except Exception:
385
+ logger.exception("Failed to load model data for /model selector")
386
+ self._loaded = True
387
+ if self.is_running:
388
+ self.notify(
389
+ "Could not load model list. Check provider packages and config.yml.",
390
+ severity="error",
391
+ timeout=10,
392
+ markup=False,
393
+ )
394
+ await self._update_display()
395
+ self._update_footer()
396
+ return
397
+
398
+ # Screen may have been dismissed while the thread was running
399
+ if not self.is_running:
400
+ return
401
+
402
+ self._all_models = all_models
403
+ self._default_spec = default_spec
404
+ self._profiles = profiles
405
+ self._filtered_models = list(self._all_models)
406
+ self._selected_index = self._find_current_model_index()
407
+ self._loaded = True
408
+
409
+ # Re-apply any filter text the user typed while data was loading
410
+ if self._filter_text:
411
+ self._update_filtered_list()
412
+
413
+ await self._update_display()
414
+ self._update_footer()
415
+
416
+ def on_input_changed(self, event: Input.Changed) -> None:
417
+ """Filter models as user types.
418
+
419
+ Args:
420
+ event: The input changed event.
421
+ """
422
+ self._filter_text = event.value
423
+ if not self._loaded:
424
+ return # on_mount will re-apply filter after data loads
425
+ self._update_filtered_list()
426
+ self.call_after_refresh(self._update_display)
427
+
428
+ def on_input_submitted(self, event: Input.Submitted) -> None:
429
+ """Handle Enter key when filter input is focused.
430
+
431
+ Args:
432
+ event: The input submitted event.
433
+ """
434
+ event.stop()
435
+ self.action_select()
436
+
437
+ def on_model_option_clicked(self, event: ModelOption.Clicked) -> None:
438
+ """Handle click on a model option.
439
+
440
+ Args:
441
+ event: The click event with model info.
442
+ """
443
+ self._selected_index = event.index
444
+ self.dismiss((event.model_spec, event.provider))
445
+
446
+ def _update_filtered_list(self) -> None:
447
+ """Update the filtered models based on search text using fuzzy matching.
448
+
449
+ Results are sorted by match score (best first).
450
+ """
451
+ query = self._filter_text.strip()
452
+ if not query:
453
+ self._filtered_models = list(self._all_models)
454
+ self._selected_index = self._find_current_model_index()
455
+ return
456
+
457
+ tokens = query.split()
458
+
459
+ try:
460
+ matchers = [Matcher(token, case_sensitive=False) for token in tokens]
461
+ scored: list[tuple[float, str, str]] = []
462
+ for spec, provider in self._all_models:
463
+ scores = [m.match(spec) for m in matchers]
464
+ if all(s > 0 for s in scores):
465
+ scored.append((min(scores), spec, provider))
466
+ except Exception:
467
+ # graceful fallback if Matcher fails on edge-case input
468
+ logger.warning(
469
+ "Fuzzy matcher failed for query %r, falling back to full list",
470
+ query,
471
+ exc_info=True,
472
+ )
473
+ self._filtered_models = list(self._all_models)
474
+ self._selected_index = self._find_current_model_index()
475
+ return
476
+
477
+ self._filtered_models = [
478
+ (spec, provider) for score, spec, provider in sorted(scored, reverse=True)
479
+ ]
480
+ self._selected_index = 0
481
+
482
+ async def _update_display(self) -> None:
483
+ """Render the model list grouped by provider.
484
+
485
+ Performs a full DOM rebuild (removes all children, re-mounts).
486
+ Arrow-key navigation uses `_move_selection` instead to avoid
487
+ the cost of a full rebuild.
488
+ """
489
+ if not self._options_container:
490
+ return
491
+
492
+ await self._options_container.remove_children()
493
+ self._option_widgets = []
494
+
495
+ if not self._filtered_models:
496
+ msg = "Loading models…" if not self._loaded else "No matching models"
497
+ await self._options_container.mount(Static(Content.styled(msg, "dim")))
498
+ self._update_footer()
499
+ return
500
+
501
+ # Group by provider, preserving insertion order so models from the
502
+ # same provider cluster together in the visual list.
503
+ by_provider: dict[str, list[tuple[str, str]]] = {}
504
+ for model_spec, provider in self._filtered_models:
505
+ by_provider.setdefault(provider, []).append((model_spec, provider))
506
+
507
+ # Rebuild _filtered_models to match the provider-grouped display
508
+ # order. Without this, _filtered_models stays in score-sorted order
509
+ # while _option_widgets follow provider-grouped order, causing
510
+ # _update_footer to look up the wrong model for the highlighted
511
+ # index.
512
+ grouped_order: list[tuple[str, str]] = []
513
+ for entries in by_provider.values():
514
+ grouped_order.extend(entries)
515
+
516
+ # Remap selected_index so the same model stays highlighted.
517
+ old_spec = self._filtered_models[self._selected_index][0]
518
+ self._filtered_models = grouped_order
519
+ self._selected_index = next(
520
+ (i for i, (s, _) in enumerate(grouped_order) if s == old_spec),
521
+ 0,
522
+ )
523
+
524
+ glyphs = get_glyphs()
525
+ flat_index = 0
526
+ selected_widget: ModelOption | None = None
527
+
528
+ # Build current model spec for comparison
529
+ current_spec = None
530
+ if self._current_model and self._current_provider:
531
+ current_spec = f"{self._current_provider}:{self._current_model}"
532
+
533
+ # Resolve credentials upfront so the widget-building loop
534
+ # stays focused on layout
535
+ if self._wire_credential_map is not None:
536
+ creds = {p: self._wire_credential_map.get(p) for p in by_provider}
537
+ else:
538
+ creds = {p: has_provider_credentials(p) for p in by_provider}
539
+
540
+ # Collect all widgets first, then batch-mount once to avoid
541
+ # individual DOM mutations per widget
542
+ all_widgets: list[Static] = []
543
+
544
+ for provider, model_entries in by_provider.items():
545
+ # Provider header with credential indicator
546
+ has_creds = creds[provider]
547
+ if has_creds is True:
548
+ cred_indicator = glyphs.checkmark
549
+ elif has_creds is False:
550
+ cred_indicator = f"{glyphs.warning} missing credentials"
551
+ else:
552
+ cred_indicator = f"{glyphs.question} credentials unknown"
553
+ all_widgets.append(
554
+ Static(
555
+ Content.from_markup(
556
+ "[bold]$provider[/bold] [dim]$cred[/dim]",
557
+ provider=provider,
558
+ cred=cred_indicator,
559
+ ),
560
+ classes="model-provider-header",
561
+ )
562
+ )
563
+
564
+ for model_spec, _prov in model_entries:
565
+ is_current = model_spec == current_spec
566
+ is_selected = flat_index == self._selected_index
567
+
568
+ classes = "model-option"
569
+ if is_selected:
570
+ classes += " model-option-selected"
571
+ if is_current:
572
+ classes += " model-option-current"
573
+
574
+ label = self._format_option_label(
575
+ model_spec,
576
+ selected=is_selected,
577
+ current=is_current,
578
+ has_creds=has_creds,
579
+ is_default=model_spec == self._default_spec,
580
+ status=self._get_model_status(model_spec),
581
+ )
582
+ widget = ModelOption(
583
+ label=label,
584
+ model_spec=model_spec,
585
+ provider=provider,
586
+ index=flat_index,
587
+ has_creds=has_creds,
588
+ classes=classes,
589
+ )
590
+ all_widgets.append(widget)
591
+ self._option_widgets.append(widget)
592
+
593
+ if is_selected:
594
+ selected_widget = widget
595
+
596
+ flat_index += 1
597
+
598
+ await self._options_container.mount(*all_widgets)
599
+
600
+ # Scroll the selected item into view without animation so the list
601
+ # appears already scrolled to the current model on first paint.
602
+ if selected_widget:
603
+ if self._selected_index == 0:
604
+ # First item: scroll to top so header is visible
605
+ scroll_container = self.query_one(".model-list", VerticalScroll)
606
+ scroll_container.scroll_home(animate=False)
607
+ else:
608
+ selected_widget.scroll_visible(animate=False)
609
+
610
+ self._update_footer()
611
+
612
+ @staticmethod
613
+ def _format_option_label(
614
+ model_spec: str,
615
+ *,
616
+ selected: bool,
617
+ current: bool,
618
+ has_creds: bool | None,
619
+ is_default: bool = False,
620
+ status: str | None = None,
621
+ ) -> Content:
622
+ """Build the display label for a model option.
623
+
624
+ Args:
625
+ model_spec: The `provider:model` string.
626
+ selected: Whether this option is currently highlighted.
627
+ current: Whether this is the active model.
628
+ has_creds: Credential status (True/False/None).
629
+ is_default: Whether this is the configured default model.
630
+ status: Model status from profile (e.g., `'deprecated'`,
631
+ `'beta'`, `'alpha'`). `'deprecated'` renders in red;
632
+ other non-None values render in yellow.
633
+
634
+ Returns:
635
+ Styled Content label.
636
+ """
637
+ colors = theme.get_theme_colors()
638
+ glyphs = get_glyphs()
639
+ cursor = f"{glyphs.cursor} " if selected else " "
640
+ if not has_creds:
641
+ spec = Content.styled(model_spec, colors.warning)
642
+ elif is_default:
643
+ spec = Content.styled(model_spec, colors.primary)
644
+ else:
645
+ spec = Content(model_spec)
646
+ suffix = Content.styled(" (current)", "dim") if current else Content("")
647
+ default_suffix = Content.styled(" (default)", colors.primary) if is_default else Content("")
648
+ if status == "deprecated":
649
+ status_suffix = Content.styled(" (deprecated)", colors.error)
650
+ elif status:
651
+ status_suffix = Content.styled(f" ({status})", colors.warning)
652
+ else:
653
+ status_suffix = Content("")
654
+ return Content.assemble(cursor, spec, suffix, default_suffix, status_suffix)
655
+
656
+ @staticmethod
657
+ def _format_footer(
658
+ profile_entry: dict[str, Any] | None,
659
+ glyphs: Glyphs,
660
+ ) -> Content:
661
+ """Build the detail footer text for the highlighted model.
662
+
663
+ Args:
664
+ profile_entry: Profile data with override tracking, or None.
665
+ glyphs: Glyph set for display characters.
666
+
667
+ Returns:
668
+ Styled `Content` for the 4-line footer.
669
+ """
670
+ from soothe_cli.tui.textual_adapter import format_token_count
671
+
672
+ if profile_entry is None or not profile_entry["profile"]:
673
+ return Content.styled("Model profile not available :(\n\n\n", "dim")
674
+
675
+ profile = profile_entry["profile"]
676
+ overridden = profile_entry["overridden_keys"]
677
+
678
+ colors = theme.get_theme_colors()
679
+
680
+ def _mark(key: str, text: str) -> Content:
681
+ if key in overridden:
682
+ return Content.styled(f"*{text}", colors.warning)
683
+ return Content(text)
684
+
685
+ def _format_token(key: str, suffix: str) -> Content | None:
686
+ """Format a token-count profile key, falling back to the raw value.
687
+
688
+ Returns:
689
+ Styled `Content` with override marker, or None if key absent.
690
+ """
691
+ val = profile.get(key)
692
+ if val is None:
693
+ return None
694
+ try:
695
+ text = f"{format_token_count(int(val))} {suffix}"
696
+ except (ValueError, TypeError, OverflowError):
697
+ text = f"{val} {suffix}"
698
+ return _mark(key, text)
699
+
700
+ def _format_flags(keys: list[tuple[str, str]]) -> list[Content]:
701
+ """Render boolean profile keys as green (on) or dim (off) labels.
702
+
703
+ Returns:
704
+ List of styled `Content` objects for present keys.
705
+ """
706
+ parts: list[Content] = []
707
+ for key, label in keys:
708
+ if key in profile:
709
+ base = (
710
+ Content.styled(label, colors.success)
711
+ if profile[key]
712
+ else Content.styled(label, "dim")
713
+ )
714
+ if key in overridden:
715
+ base = Content.assemble(Content.styled("*", colors.warning), base)
716
+ parts.append(base)
717
+ return parts
718
+
719
+ # Line 1: Context window
720
+ token_keys = [("max_input_tokens", "in"), ("max_output_tokens", "out")]
721
+ ctx_parts = [p for k, s in token_keys if (p := _format_token(k, s)) is not None]
722
+ bullet_sep = Content(f" {glyphs.bullet} ")
723
+ line1 = (
724
+ Content.assemble("Context: ", bullet_sep.join(ctx_parts)) if ctx_parts else Content("")
725
+ )
726
+
727
+ # Line 2: Input modalities
728
+ modality_keys = [
729
+ ("text_inputs", "text"),
730
+ ("image_inputs", "image"),
731
+ ("audio_inputs", "audio"),
732
+ ("pdf_inputs", "pdf"),
733
+ ("video_inputs", "video"),
734
+ ]
735
+ modality_parts = _format_flags(modality_keys)
736
+ space = Content(" ")
737
+ line2 = (
738
+ Content.assemble("Input: ", space.join(modality_parts))
739
+ if modality_parts
740
+ else Content("")
741
+ )
742
+
743
+ # Line 3: Capabilities
744
+ capability_keys = [
745
+ ("reasoning_output", "reasoning"),
746
+ ("tool_calling", "tool calling"),
747
+ ("structured_output", "structured output"),
748
+ ]
749
+ cap_parts = _format_flags(capability_keys)
750
+ line3 = (
751
+ Content.assemble("Capabilities: ", space.join(cap_parts)) if cap_parts else Content("")
752
+ )
753
+
754
+ # Line 4: Override notice
755
+ displayed_keys = {k for k, _ in token_keys + modality_keys + capability_keys}
756
+ has_visible_override = bool(overridden & displayed_keys)
757
+ line4 = (
758
+ Content.from_markup("[dim][yellow]*[/yellow] = override[/dim]")
759
+ if has_visible_override
760
+ else Content("")
761
+ )
762
+
763
+ return Content.assemble(line1, "\n", line2, "\n", line3, "\n", line4)
764
+
765
+ def _get_model_status(self, model_spec: str) -> str | None:
766
+ """Look up the status field for a model from its profile.
767
+
768
+ Args:
769
+ model_spec: The `provider:model` string.
770
+
771
+ Returns:
772
+ Status string (e.g., `'deprecated'`) if the model has a profile
773
+ with a `status` key, otherwise None.
774
+ """
775
+ entry = self._profiles.get(model_spec)
776
+ if entry is None:
777
+ return None
778
+ profile = entry.get("profile")
779
+ if not profile:
780
+ return None
781
+ return profile.get("status")
782
+
783
+ def _update_footer(self) -> None:
784
+ """Update the detail footer for the currently highlighted model."""
785
+ footer = self.query_one("#model-detail-footer", Static)
786
+ if not self._filtered_models:
787
+ footer.update(Content.styled("No model selected", "dim"))
788
+ return
789
+ index = min(self._selected_index, len(self._filtered_models) - 1)
790
+ spec, _ = self._filtered_models[index]
791
+ entry = self._profiles.get(spec)
792
+ try:
793
+ text = self._format_footer(entry, get_glyphs())
794
+ except (KeyError, ValueError, TypeError): # Resilient footer rendering
795
+ logger.warning("Failed to format footer for %s", spec, exc_info=True)
796
+ text = Content.styled("Could not load profile details\n\n\n", "dim")
797
+ footer.update(text)
798
+
799
+ def _move_selection(self, delta: int) -> None:
800
+ """Move selection by delta, updating only the affected widgets.
801
+
802
+ Args:
803
+ delta: Number of positions to move (-1 for up, +1 for down).
804
+ """
805
+ if not self._filtered_models or not self._option_widgets:
806
+ return
807
+
808
+ count = len(self._filtered_models)
809
+ old_index = self._selected_index
810
+ new_index = (old_index + delta) % count
811
+ self._selected_index = new_index
812
+
813
+ # Update the previously selected widget
814
+ old_widget = self._option_widgets[old_index]
815
+ old_widget.remove_class("model-option-selected")
816
+ old_widget.update(
817
+ self._format_option_label(
818
+ old_widget.model_spec,
819
+ selected=False,
820
+ current=old_widget.model_spec == self._current_spec,
821
+ has_creds=old_widget.has_creds,
822
+ is_default=old_widget.model_spec == self._default_spec,
823
+ status=self._get_model_status(old_widget.model_spec),
824
+ )
825
+ )
826
+
827
+ # Update the newly selected widget
828
+ new_widget = self._option_widgets[new_index]
829
+ new_widget.add_class("model-option-selected")
830
+ new_widget.update(
831
+ self._format_option_label(
832
+ new_widget.model_spec,
833
+ selected=True,
834
+ current=new_widget.model_spec == self._current_spec,
835
+ has_creds=new_widget.has_creds,
836
+ is_default=new_widget.model_spec == self._default_spec,
837
+ status=self._get_model_status(new_widget.model_spec),
838
+ )
839
+ )
840
+
841
+ # Scroll the selected item into view
842
+ if new_index == 0:
843
+ scroll_container = self.query_one(".model-list", VerticalScroll)
844
+ scroll_container.scroll_home(animate=False)
845
+ else:
846
+ new_widget.scroll_visible()
847
+
848
+ self._update_footer()
849
+
850
+ def action_move_up(self) -> None:
851
+ """Move selection up."""
852
+ self._move_selection(-1)
853
+
854
+ def action_move_down(self) -> None:
855
+ """Move selection down."""
856
+ self._move_selection(1)
857
+
858
+ def action_tab_complete(self) -> None:
859
+ """Replace search text with the currently selected model spec."""
860
+ if not self._filtered_models:
861
+ return
862
+ model_spec, _ = self._filtered_models[self._selected_index]
863
+ filter_input = self.query_one("#model-filter", Input)
864
+ filter_input.value = model_spec
865
+ filter_input.cursor_position = len(model_spec)
866
+
867
+ def _visible_page_size(self) -> int:
868
+ """Return the number of model options that fit in one visual page.
869
+
870
+ Returns:
871
+ Number of model options per page, at least 1.
872
+ """
873
+ default_page_size = 10
874
+ try:
875
+ scroll = self.query_one(".model-list", VerticalScroll)
876
+ height = scroll.size.height
877
+ except Exception: # noqa: BLE001 # Fallback to default page size on any widget query error
878
+ return default_page_size
879
+ if height <= 0:
880
+ return default_page_size
881
+
882
+ total_models = len(self._filtered_models)
883
+ if total_models == 0:
884
+ return default_page_size
885
+
886
+ # Each provider header = 1 row + margin-top: 1 (first has margin 0)
887
+ num_headers = len(self.query(".model-provider-header"))
888
+ header_rows = max(0, num_headers * 2 - 1) if num_headers else 0
889
+ total_rows = total_models + header_rows
890
+ return max(1, int(height * total_models / total_rows))
891
+
892
+ def action_page_up(self) -> None:
893
+ """Move selection up by one visible page."""
894
+ if not self._filtered_models:
895
+ return
896
+ page = self._visible_page_size()
897
+ target = max(0, self._selected_index - page)
898
+ delta = target - self._selected_index
899
+ if delta != 0:
900
+ self._move_selection(delta)
901
+
902
+ def action_page_down(self) -> None:
903
+ """Move selection down by one visible page."""
904
+ if not self._filtered_models:
905
+ return
906
+ count = len(self._filtered_models)
907
+ page = self._visible_page_size()
908
+ target = min(count - 1, self._selected_index + page)
909
+ delta = target - self._selected_index
910
+ if delta != 0:
911
+ self._move_selection(delta)
912
+
913
+ def action_select(self) -> None:
914
+ """Select the current model."""
915
+ # If there are filtered results, always select the highlighted model
916
+ if self._filtered_models:
917
+ model_spec, provider = self._filtered_models[self._selected_index]
918
+ self.dismiss((model_spec, provider))
919
+ return
920
+
921
+ # No matches - check if user typed a custom provider:model spec
922
+ filter_input = self.query_one("#model-filter", Input)
923
+ custom_input = filter_input.value.strip()
924
+
925
+ if custom_input and ":" in custom_input:
926
+ provider = custom_input.split(":", 1)[0]
927
+ self.dismiss((custom_input, provider))
928
+ elif custom_input:
929
+ self.dismiss((custom_input, ""))
930
+
931
+ async def action_set_default(self) -> None:
932
+ """Toggle the highlighted model as the default.
933
+
934
+ If the highlighted model is already the default, clears it.
935
+ Otherwise sets it as the new default.
936
+ """
937
+ if not self._filtered_models or not self._option_widgets:
938
+ return
939
+
940
+ model_spec, _provider = self._filtered_models[self._selected_index]
941
+ help_widget = self.query_one(".model-selector-help", Static)
942
+
943
+ if model_spec == self._default_spec:
944
+ # Already default — clear it
945
+ if await asyncio.to_thread(clear_default_model):
946
+ self._default_spec = None
947
+ self.call_after_refresh(self._update_display)
948
+ help_widget.update(Content.styled("Default cleared", "bold"))
949
+ self.set_timer(3.0, self._restore_help_text)
950
+ else:
951
+ help_widget.update(
952
+ Content.styled(
953
+ "Failed to clear default",
954
+ f"bold {theme.get_theme_colors(self).error}",
955
+ )
956
+ )
957
+ self.set_timer(3.0, self._restore_help_text)
958
+ elif await asyncio.to_thread(save_default_model, model_spec):
959
+ self._default_spec = model_spec
960
+ self.call_after_refresh(self._update_display)
961
+ help_widget.update(
962
+ Content.from_markup("[bold]Default set to $spec[/bold]", spec=model_spec)
963
+ )
964
+ self.set_timer(3.0, self._restore_help_text)
965
+ else:
966
+ help_widget.update(
967
+ Content.styled(
968
+ "Failed to save default",
969
+ f"bold {theme.get_theme_colors(self).error}",
970
+ )
971
+ )
972
+ self.set_timer(3.0, self._restore_help_text)
973
+
974
+ def _restore_help_text(self) -> None:
975
+ """Restore the default help text after a temporary message."""
976
+ glyphs = get_glyphs()
977
+ help_text = (
978
+ f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate"
979
+ f" {glyphs.bullet} Enter select"
980
+ f" {glyphs.bullet} Ctrl+S set default"
981
+ f" {glyphs.bullet} Esc cancel"
982
+ )
983
+ help_widget = self.query_one(".model-selector-help", Static)
984
+ help_widget.update(help_text)
985
+
986
+ def action_cancel(self) -> None:
987
+ """Cancel the selection."""
988
+ self.dismiss(None)