holt-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 (65) hide show
  1. holt/__init__.py +0 -0
  2. holt/agent/__init__.py +0 -0
  3. holt/agent/entry.py +86 -0
  4. holt/agent/findings.py +49 -0
  5. holt/agent/landing.py +154 -0
  6. holt/agent/pipeline.py +244 -0
  7. holt/agent/progression.py +408 -0
  8. holt/agent/signals.py +220 -0
  9. holt/agent/stages.py +533 -0
  10. holt/agent/verdict.py +226 -0
  11. holt/agent/verify.py +140 -0
  12. holt/baseline.py +89 -0
  13. holt/baseline_matched.py +116 -0
  14. holt/cli.py +616 -0
  15. holt/discover.py +497 -0
  16. holt/evidence/__init__.py +3 -0
  17. holt/evidence/fixtures.py +154 -0
  18. holt/evidence/github_graphql.py +538 -0
  19. holt/evidence/provider.py +77 -0
  20. holt/evidence/redact.py +79 -0
  21. holt/issues.py +41 -0
  22. holt/model.py +516 -0
  23. holt/profile.py +126 -0
  24. holt/report.py +157 -0
  25. holt/tui/__init__.py +0 -0
  26. holt/tui/animation.py +84 -0
  27. holt/tui/app.py +294 -0
  28. holt/tui/clipboard.py +89 -0
  29. holt/tui/commands.py +134 -0
  30. holt/tui/discovery.py +305 -0
  31. holt/tui/env.py +49 -0
  32. holt/tui/events.py +245 -0
  33. holt/tui/mascot.py +121 -0
  34. holt/tui/models.py +590 -0
  35. holt/tui/observe.py +297 -0
  36. holt/tui/screens/__init__.py +35 -0
  37. holt/tui/screens/assessment.py +337 -0
  38. holt/tui/screens/confirm.py +62 -0
  39. holt/tui/screens/discover.py +444 -0
  40. holt/tui/screens/home.py +519 -0
  41. holt/tui/screens/inspector.py +106 -0
  42. holt/tui/screens/live.py +335 -0
  43. holt/tui/screens/models.py +393 -0
  44. holt/tui/screens/next_steps.py +425 -0
  45. holt/tui/screens/profile.py +129 -0
  46. holt/tui/session.py +711 -0
  47. holt/tui/store.py +458 -0
  48. holt/tui/theme.py +479 -0
  49. holt/tui/visual.py +33 -0
  50. holt/tui/widgets/__init__.py +0 -0
  51. holt/tui/widgets/candidates.py +78 -0
  52. holt/tui/widgets/claims.py +59 -0
  53. holt/tui/widgets/disclosure.py +121 -0
  54. holt/tui/widgets/evidence.py +121 -0
  55. holt/tui/widgets/masthead.py +122 -0
  56. holt/tui/widgets/recent.py +167 -0
  57. holt/tui/widgets/scrolling.py +38 -0
  58. holt/tui/widgets/stages.py +232 -0
  59. holt/types.py +48 -0
  60. holt_cli-0.1.0.dist-info/METADATA +198 -0
  61. holt_cli-0.1.0.dist-info/RECORD +65 -0
  62. holt_cli-0.1.0.dist-info/WHEEL +4 -0
  63. holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
  64. holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  65. holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
@@ -0,0 +1,393 @@
1
+ """Which model answers, in two steps.
2
+
3
+ Providers first, then the models that provider actually has — asked for over the
4
+ wire rather than hardcoded, because a list of model ids goes stale faster than
5
+ anything else in a tool like this and a stale list is worse than none.
6
+
7
+ Every network call runs on a worker thread. The screen stays responsive while a
8
+ provider is thinking, and a provider that never answers produces a sentence
9
+ after twelve seconds instead of a frozen interface.
10
+
11
+ Three things are always on screen once they are true, because each is something
12
+ you would otherwise discover at the worst moment:
13
+
14
+ * whether the key for a provider is even set, before you pick it
15
+ * whether a chosen model actually answers, on demand
16
+ * that a non-default choice stops the committed recordings replaying
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from rich.text import Text
22
+ from textual.app import ComposeResult
23
+ from textual.containers import Horizontal, Vertical, VerticalScroll
24
+ from textual.binding import Binding
25
+ from textual.screen import Screen
26
+ from textual.widgets import Footer, Input, ListItem, ListView
27
+
28
+ from holt import model as model_module
29
+ from holt.tui import animation, mascot, models as models_layer, theme
30
+ from holt.tui.visual import Line
31
+ from holt.tui.widgets.masthead import Cat
32
+
33
+
34
+ class ProviderRow(ListItem):
35
+ def __init__(self, provider, current: bool, **kwargs) -> None:
36
+ super().__init__(**kwargs)
37
+ self.provider = provider
38
+ self.current = current
39
+
40
+ def compose(self):
41
+ row = Text()
42
+ row.append("▸ " if self.current else " ", style=theme.DIM)
43
+ row.append(f"{self.provider.name:<20}")
44
+ row.append(
45
+ self.provider.status(),
46
+ style=theme.FAINT if self.provider.usable else theme.DROP,
47
+ )
48
+ yield Line(row)
49
+
50
+
51
+ class ModelRow(ListItem):
52
+ def __init__(self, entry, current: bool, **kwargs) -> None:
53
+ super().__init__(**kwargs)
54
+ self.entry = entry
55
+ self.current = current
56
+
57
+ def compose(self):
58
+ row = Text()
59
+ row.append("▸ " if self.current else " ", style=theme.DIM)
60
+ row.append(f"{self.entry.id:<38}")
61
+ row.append(
62
+ self.entry.pricing,
63
+ style=theme.FAINT if self.entry.priced else theme.NOT_VIABLE,
64
+ )
65
+ if not self.entry.from_provider:
66
+ row.append(" not from the provider", style=theme.FAINT)
67
+ yield Line(row)
68
+
69
+
70
+ class ModelsScreen(Screen):
71
+ BINDINGS = [
72
+ # Handled here, not by the list: the filter box holds focus so that
73
+ # typing and choosing are one gesture. Hidden from the footer, which
74
+ # already carries the hint line under the list.
75
+ Binding("down", "browse_down", "choose", show=False),
76
+ Binding("up", "browse_up", "choose", show=False),
77
+ ("ctrl+t", "test", "test connection"),
78
+ ("ctrl+d", "reset", "back to defaults"),
79
+ ("escape", "back", "back"),
80
+ ]
81
+
82
+ def __init__(self, **kwargs) -> None:
83
+ super().__init__(**kwargs)
84
+ self.config = model_module.load_models_config()
85
+ self.providers = models_layer.providers(self.config)
86
+ self.chosen = next(
87
+ (p for p in self.providers if p.name == self.config.provider),
88
+ self.providers[0],
89
+ )
90
+ self.listing: models_layer.Listing | None = None
91
+ self.step = "provider"
92
+ #: What is typed in the filter box. Held on the screen rather than read
93
+ #: back off the widget, because the list is rebuilt from it and the
94
+ #: widget does not exist on the provider step.
95
+ self._filter = ""
96
+
97
+ # ─── layout ─────────────────────────────────────────────────────────────
98
+
99
+ def compose(self) -> ComposeResult:
100
+ with Horizontal(id="chrome"):
101
+ yield Cat("idle", id="cat", classes="chrome-cat")
102
+ yield Line("models", id="chrome-left")
103
+ yield Line(self._summary(), id="chrome-right")
104
+ yield Line("─" * 240, classes="rule")
105
+
106
+ yield Vertical(id="models-body")
107
+ yield Footer()
108
+
109
+ async def on_mount(self) -> None:
110
+ await self._show_providers()
111
+
112
+ # ─── the two steps, each rebuilding the same body ───────────────────────
113
+
114
+ async def _body(self) -> Vertical:
115
+ """Empty the body and hand it back.
116
+
117
+ The removal is awaited. It returns before the widgets are actually gone
118
+ otherwise, and mounting a replacement with the same id against the old
119
+ one still present fails on a duplicate id — silently, inside a handler,
120
+ which looks exactly like a key that does nothing.
121
+ """
122
+ body = self.query_one("#models-body", Vertical)
123
+ await body.remove_children()
124
+ return body
125
+
126
+ def _footer_lines(self, body: Vertical, hint: str) -> None:
127
+ body.mount(Line(Text(hint, style=theme.FAINT), id="models-notice"))
128
+ body.mount(Line("", id="models-warning"))
129
+ self._paint_warning()
130
+
131
+ async def _show_providers(self) -> None:
132
+ self.step = "provider"
133
+ self._filter = ""
134
+ body = await self._body()
135
+ body.mount(Line(Text("PROVIDER", style=theme.DIM), classes="section-label"))
136
+ scroll = VerticalScroll(id="provider-scroll")
137
+ body.mount(scroll)
138
+ listed = ListView(
139
+ *[ProviderRow(p, p.name == self.config.provider) for p in self.providers],
140
+ id="providers",
141
+ )
142
+ scroll.mount(listed)
143
+ # The list is the only thing to interact with, so it takes focus. A
144
+ # screen whose enter key does nothing until you guess to press tab is a
145
+ # screen that appears broken.
146
+ listed.focus()
147
+ self._footer_lines(
148
+ body,
149
+ "enter list this provider's models ctrl+t test ctrl+d defaults",
150
+ )
151
+ animation.reveal(body)
152
+
153
+ def _summary(self) -> Text:
154
+ text = Text()
155
+ text.append(f"{self.config.provider} ", style=theme.FAINT)
156
+ text.append(
157
+ model_module.model_for("classify"), style=theme.DIM
158
+ )
159
+ return text
160
+
161
+ def _notice(self, message: str, tone: str = "") -> None:
162
+ self.query_one("#models-notice", Line).update(
163
+ Text(message, style=tone or theme.FAINT)
164
+ )
165
+
166
+ def _paint_warning(self) -> None:
167
+ warning = models_layer.replay_warning(model_module.active_config())
168
+ widget = self.query_one("#models-warning", Line)
169
+ widget.update(Text(warning, style=theme.DIM) if warning else "")
170
+ widget.display = bool(warning)
171
+
172
+ # ─── step one: providers ────────────────────────────────────────────────
173
+
174
+ def on_list_view_selected(self, event) -> None:
175
+ item = event.item
176
+ if isinstance(item, ProviderRow):
177
+ self.chosen = item.provider
178
+ self._load_models()
179
+ elif isinstance(item, ModelRow):
180
+ self._use(item.entry.id)
181
+
182
+ def _load_models(self) -> None: # noqa: D401
183
+ if self.chosen.needs_base_url:
184
+ self._notice(
185
+ "This provider needs a base url. Set one with "
186
+ "`holt models --provider openai-compatible --base-url <url>`.",
187
+ theme.DROP,
188
+ )
189
+ return
190
+ self._notice(f"Asking {self.chosen.name} what it has…")
191
+ self._fetch(self.chosen)
192
+
193
+ def _fetch(self, provider) -> None:
194
+ """On a thread: a provider that hangs must not take the screen with it."""
195
+
196
+ def work():
197
+ return models_layer.list_models(provider)
198
+
199
+ self.run_worker(
200
+ lambda: self._fetched(work()), thread=True, exclusive=True, group="models"
201
+ )
202
+
203
+ def _fetched(self, listing) -> None:
204
+ self.app.call_from_thread(self._show_models, listing)
205
+
206
+ async def _show_models(self, listing) -> None:
207
+ self.listing = listing
208
+ self.step = "model"
209
+ # A filter belongs to the provider you are looking at, not to the
210
+ # screen: carrying "gpt" over to Ollama would show an empty list.
211
+ self._filter = ""
212
+
213
+ body = await self._body()
214
+ body.mount(
215
+ Line(
216
+ Text(f"MODELS · {self.chosen.name}", style=theme.DIM),
217
+ classes="section-label",
218
+ )
219
+ )
220
+
221
+ if listing.error:
222
+ # The failure and whatever we still know, in that order. A short
223
+ # list with no explanation of why it is short is worse than an error.
224
+ body.mount(Line(Text(listing.error, style=theme.DROP), classes="empty"))
225
+ if not listing.models:
226
+ body.mount(
227
+ Line(
228
+ Text("Nothing to choose from here.", style=theme.FAINT),
229
+ classes="empty",
230
+ )
231
+ )
232
+ else:
233
+ if listing.guessed:
234
+ body.mount(
235
+ Line(
236
+ Text(
237
+ "Listed from what this build knows, not from the "
238
+ "provider — it may be wrong or out of date.",
239
+ style=theme.FAINT,
240
+ ),
241
+ classes="empty",
242
+ )
243
+ )
244
+ if listing.hidden:
245
+ # The list is shorter than `ollama list` and has to say why,
246
+ # otherwise the missing model reads as the interface losing it.
247
+ body.mount(
248
+ Line(
249
+ Text(
250
+ f"{listing.hidden} more "
251
+ + (
252
+ "is not a chat model"
253
+ if listing.hidden == 1
254
+ else "are not chat models"
255
+ )
256
+ + " — embeddings, speech, images. Every stage "
257
+ "calls chat completions, so they are not choices.",
258
+ style=theme.FAINT,
259
+ ),
260
+ classes="empty",
261
+ )
262
+ )
263
+ # A provider can offer eighty ids. Typing narrows them, and the
264
+ # box holds focus so narrowing and choosing are the same gesture —
265
+ # ↑↓ are handled by this screen, exactly as on home.
266
+ body.mount(
267
+ Input(placeholder="filter by name", id="model-filter")
268
+ )
269
+ body.mount(Line("", id="model-count", classes="field-note"))
270
+ scroll = VerticalScroll(id="model-scroll")
271
+ body.mount(scroll)
272
+ scroll.mount(ListView(id="models"))
273
+ await self._paint_models()
274
+ self.query_one("#model-filter", Input).focus()
275
+
276
+ self._footer_lines(
277
+ body,
278
+ "type to filter ↑↓ choose enter use everywhere "
279
+ "ctrl+t test esc providers",
280
+ )
281
+ animation.reveal(body)
282
+
283
+ async def _paint_models(self) -> None:
284
+ """Refill the list from the current filter, keeping the offer order."""
285
+ listing = self.listing
286
+ if listing is None:
287
+ return
288
+ needle = self._filter
289
+ shown = [m for m in listing.models if models_layer.matches(m, needle)]
290
+
291
+ listed = self.query_one("#models", ListView)
292
+ await listed.clear()
293
+ current = model_module.model_for("classify")
294
+ for entry in shown:
295
+ await listed.append(ModelRow(entry, entry.id == current))
296
+ # Always something under the cursor, so ↑↓ has a position to move from.
297
+ listed.index = 0 if shown else None
298
+
299
+ count = self.query_one("#model-count", Line)
300
+ total = len(listing.models)
301
+ if not shown:
302
+ count.update(
303
+ Text(f"Nothing here matches “{needle}”.", style=theme.DROP)
304
+ )
305
+ elif len(shown) < total:
306
+ count.update(
307
+ Text(f"{len(shown)} of {total}", style=theme.FAINT)
308
+ )
309
+ else:
310
+ # The pinned models are first, and saying why beats a reader
311
+ # wondering what the order is.
312
+ count.update(
313
+ Text(
314
+ f"{total} — the ones holt can cost first, then the rest",
315
+ style=theme.FAINT,
316
+ )
317
+ )
318
+
319
+ async def on_input_changed(self, event: Input.Changed) -> None:
320
+ self._filter = event.value
321
+ await self._paint_models()
322
+
323
+ def on_input_submitted(self, event: Input.Submitted) -> None:
324
+ """Enter in the filter box uses whatever the cursor is on."""
325
+ listed = self.query_one("#models", ListView)
326
+ row = listed.highlighted_child
327
+ if row is not None:
328
+ self._use(row.entry.id)
329
+
330
+ def action_browse_down(self) -> None:
331
+ self._browse(1)
332
+
333
+ def action_browse_up(self) -> None:
334
+ self._browse(-1)
335
+
336
+ def _browse(self, delta: int) -> None:
337
+ """Move the cursor without taking focus off whichever box has it."""
338
+ try:
339
+ listed = self.query_one("#models", ListView)
340
+ except Exception: # noqa: BLE001 - on the provider step there is no list
341
+ return
342
+ total = len(listed.children)
343
+ if not total:
344
+ return
345
+ current = listed.index
346
+ if current is None:
347
+ listed.index = 0 if delta > 0 else total - 1
348
+ else:
349
+ listed.index = max(0, min(total - 1, current + delta))
350
+
351
+ # ─── step two: choosing ─────────────────────────────────────────────────
352
+
353
+ def _use(self, model_id: str) -> None:
354
+ try:
355
+ self.config = models_layer.apply(self.chosen, model_id)
356
+ except OSError as exc:
357
+ self._notice(f"Could not save: {exc}", theme.DROP)
358
+ return
359
+ self.providers = models_layer.providers(self.config)
360
+ self.query_one("#chrome-right", Line).update(self._summary())
361
+ self._paint_warning()
362
+ self._notice(f"{model_id} now answers every stage. ctrl+t to test it.")
363
+
364
+ def action_test(self) -> None:
365
+ model_id = model_module.model_for("classify")
366
+ self._notice(f"Calling {model_id}…")
367
+
368
+ def work():
369
+ return models_layer.test_connection(self.chosen, model_id)
370
+
371
+ self.run_worker(
372
+ lambda: self.app.call_from_thread(self._probed, work()),
373
+ thread=True,
374
+ exclusive=True,
375
+ group="probe",
376
+ )
377
+
378
+ def _probed(self, probe) -> None:
379
+ self._notice(probe.detail, "" if probe.ok else theme.DROP)
380
+
381
+ def action_reset(self) -> None:
382
+ self.config = models_layer.reset()
383
+ self.query_one("#chrome-right", Line).update(self._summary())
384
+ self._paint_warning()
385
+ self._notice("Back to the pinned defaults. Replay works again.")
386
+
387
+ async def action_back(self) -> None:
388
+ """One step back, not out. Escape from the models list returns to the
389
+ providers rather than leaving the screen entirely."""
390
+ if self.step == "model":
391
+ await self._show_providers()
392
+ return
393
+ self.app.go_home()