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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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()
|