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
holt/tui/theme.py ADDED
@@ -0,0 +1,479 @@
1
+ """Every colour in the interface, and what each one is for.
2
+
3
+ Colour is used to mean something or it is not used. Three jobs:
4
+
5
+ 1. **verdict state** — viable, not viable, insufficient evidence
6
+ 2. **evidence resolution** — an id that resolves against the provider, or one
7
+ that does not
8
+ 3. **Stage D drops** — the one place red appears
9
+
10
+ Everything else is the terminal's own foreground and two greys. There is no
11
+ background colour anywhere in the stylesheet: the app sits on whatever ground
12
+ the user's terminal already has, which is both the restrained choice and the
13
+ reason it reads correctly on a light and a dark profile without a theme switch.
14
+
15
+ `not_viable` is clay rather than red on purpose. It is a finding about a
16
+ repository, not a failure of the tool, and colouring it like an error would
17
+ misreport it. `insufficient_evidence` is grey on purpose too: an absence of
18
+ evidence has not earned a colour.
19
+
20
+ The one consequence of having no background colour: **a list cannot show where
21
+ the keyboard is by lighting a row.** Textual's own idiom for that is a `$boost`
22
+ background, and boost is a translucent overlay — over `transparent` it
23
+ composites to nothing, so every list in the app rendered with no visible
24
+ selection and had to be driven with a mouse. Selection is therefore a rail in
25
+ the margin, in `CITE`, the same colour and the same shape as the rail that marks
26
+ a focused input. The column is reserved on every row so moving the selection
27
+ does not shift the text sideways.
28
+
29
+ Both `.-highlight` and `.--highlight` are listed on every one of those rules.
30
+ Textual renamed the class between versions this project supports, and a
31
+ stylesheet that names only one of them silently stops highlighting anything —
32
+ which is not a failure any test of *text* can see.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ # ─── tokens ─────────────────────────────────────────────────────────────────
38
+
39
+ DIM = "#8a8a8a" # labels, chrome, secondary prose
40
+ FAINT = "#585858" # counts, timings, read only if you look for them
41
+ RULE = "#444444" # hairlines
42
+ RAIL = "#767676" # the measured-result rail: quiet, but meant to be seen
43
+
44
+ VIABLE = "#5faf87"
45
+ NOT_VIABLE = "#d7875f"
46
+ INSUFFICIENT = "#8a8a8a"
47
+ DROP = "#af5f5f"
48
+ CITE = "#5fafaf"
49
+
50
+ #: Verdicts are looked up, never matched exhaustively. `holt.report.Verdict`
51
+ #: owns the enum; a member added there renders in the neutral tone instead of
52
+ #: raising, so the TUI never has to be edited in lockstep with the engine.
53
+ VERDICT_COLOURS: dict[str, str] = {
54
+ "viable": VIABLE,
55
+ "not_viable": NOT_VIABLE,
56
+ "insufficient_evidence": INSUFFICIENT,
57
+ }
58
+ VERDICT_FALLBACK = DIM
59
+
60
+
61
+ def verdict_colour(value: str) -> str:
62
+ return VERDICT_COLOURS.get(value, VERDICT_FALLBACK)
63
+
64
+
65
+ def verdict_label(value: str) -> str:
66
+ """`not_viable` reads as "not viable". Spelled out, never abbreviated."""
67
+ return value.replace("_", " ")
68
+
69
+
70
+ CSS = f"""
71
+ Screen {{
72
+ background: transparent;
73
+ color: $foreground;
74
+ }}
75
+
76
+ /* chrome ------------------------------------------------------------------ */
77
+
78
+ #chrome {{
79
+ height: 1;
80
+ color: {DIM};
81
+ padding: 0 1;
82
+ }}
83
+ #chrome-left {{
84
+ width: 1fr;
85
+ }}
86
+ #chrome-right {{
87
+ width: auto;
88
+ color: {FAINT};
89
+ text-align: right;
90
+ }}
91
+ .rule {{
92
+ height: 1;
93
+ color: {RULE};
94
+ }}
95
+ Footer {{
96
+ background: transparent;
97
+ color: {FAINT};
98
+ }}
99
+ Footer > .footer--key {{
100
+ background: transparent;
101
+ color: {DIM};
102
+ }}
103
+ Footer > .footer--description {{
104
+ background: transparent;
105
+ color: {FAINT};
106
+ }}
107
+
108
+ /* stages ------------------------------------------------------------------ */
109
+
110
+ StageList {{
111
+ /* `auto`, not the container default of `1fr`. As a fraction it expanded to
112
+ fill the screen and left a block of dead space between the last stage and
113
+ the stream of findings. */
114
+ height: auto;
115
+ padding: 0 0 1 0;
116
+ }}
117
+ #stream {{
118
+ height: 1fr;
119
+ scrollbar-size: 1 1;
120
+ }}
121
+ StageRow {{
122
+ height: 1;
123
+ padding: 0 1;
124
+ }}
125
+ .stage-mark {{ color: {FAINT}; width: 3; }}
126
+ .stage-name {{ color: {DIM}; width: 16; }}
127
+ .stage-pending {{ color: {FAINT}; }}
128
+ .stage-running {{ color: {DIM}; }}
129
+
130
+ /* findings as they stream -------------------------------------------------- */
131
+
132
+ .finding {{
133
+ padding: 0 1 0 6;
134
+ height: auto;
135
+ }}
136
+ .finding-note {{ color: {FAINT}; }}
137
+
138
+ /* the drop ---------------------------------------------------------------- */
139
+
140
+ DroppedFinding {{
141
+ height: auto;
142
+ padding: 1 1 1 6;
143
+ }}
144
+ .drop-mark {{ color: {DROP}; }}
145
+ .drop-reason {{ color: {FAINT}; padding-left: 2; }}
146
+ .drop-id {{ color: {DROP}; text-style: strike; }}
147
+
148
+ /* evidence ---------------------------------------------------------------- */
149
+
150
+ .cite {{ color: {CITE}; text-style: underline; }}
151
+ .cite-broken {{ color: {DROP}; text-style: strike; }}
152
+
153
+ EvidenceDetail {{
154
+ height: auto;
155
+ padding: 0 1;
156
+ }}
157
+ .record-meta {{ color: {FAINT}; }}
158
+ .record-key {{ color: {DIM}; width: 17; }}
159
+
160
+ /* claims ------------------------------------------------------------------ */
161
+
162
+ ClaimList {{
163
+ /* `auto`, not `1fr`. Inside the scrolling page, a fractional height
164
+ resolves against whatever space a long summary has left over, which on a
165
+ verbose repository collapses the whole list to a single row. The page
166
+ scrolls; the list should be as tall as it has claims. */
167
+ height: auto;
168
+ background: transparent;
169
+ border: none;
170
+ scrollbar-size: 1 1;
171
+ }}
172
+ ClaimList > ListItem {{
173
+ background: transparent;
174
+ padding: 0 1;
175
+ border-left: outer transparent;
176
+ }}
177
+ ClaimList > ListItem.-highlight, ClaimList > ListItem.--highlight {{
178
+ border-left: outer {CITE};
179
+ }}
180
+
181
+ /* verdict ----------------------------------------------------------------- */
182
+
183
+ #verdict {{
184
+ height: auto;
185
+ padding: 1 1 0 1;
186
+ text-style: bold;
187
+ }}
188
+ #verdict-budget {{
189
+ height: auto;
190
+ padding: 0 1 1 1;
191
+ }}
192
+ #bottom-line {{
193
+ height: auto;
194
+ padding: 0 1 1 1;
195
+ }}
196
+ .prose {{
197
+ height: auto;
198
+ padding: 0 1 1 1;
199
+ }}
200
+ .rule-line {{
201
+ height: auto;
202
+ padding: 0 1 0 2;
203
+ }}
204
+ .landing-line {{
205
+ height: auto;
206
+ padding: 0 1 0 2;
207
+ }}
208
+ #report {{
209
+ height: 1fr;
210
+ scrollbar-size: 1 1;
211
+ }}
212
+
213
+ /* masthead ----------------------------------------------------------------- */
214
+
215
+ Masthead {{
216
+ /* `auto`, not the widget default of `1fr`. As a fraction it filled the
217
+ screen and pushed the input, the hint and the whole recent list off the
218
+ bottom. */
219
+ height: auto;
220
+ }}
221
+ #masthead {{
222
+ height: auto;
223
+ padding: 1 1 0 1;
224
+ }}
225
+ #cat {{
226
+ width: 13;
227
+ height: 1;
228
+ }}
229
+ .chrome-cat {{
230
+ width: 12;
231
+ height: 1;
232
+ }}
233
+ #masthead-text {{
234
+ width: 1fr;
235
+ height: auto;
236
+ }}
237
+ .masthead-name {{
238
+ text-style: bold;
239
+ }}
240
+ .masthead-fact {{
241
+ color: {FAINT};
242
+ }}
243
+
244
+ /* home --------------------------------------------------------------------- */
245
+
246
+ #home-body {{
247
+ height: 1fr;
248
+ padding: 0 1;
249
+ }}
250
+ #home-notice {{
251
+ height: auto;
252
+ /* No top padding: the input already carries a bottom margin, and both
253
+ together left two blank lines between the box and its own hint. */
254
+ padding: 0 1 0 2;
255
+ }}
256
+ #recent-scroll {{
257
+ height: 1fr;
258
+ scrollbar-size: 1 1;
259
+ }}
260
+ RecentList {{
261
+ height: auto;
262
+ background: transparent;
263
+ border: none;
264
+ }}
265
+ RecentList > ListItem {{
266
+ background: transparent;
267
+ padding: 0 1;
268
+ border-left: outer transparent;
269
+ }}
270
+ RecentList > ListItem.-highlight, RecentList > ListItem.--highlight {{
271
+ border-left: outer {CITE};
272
+ }}
273
+
274
+ /* the measured result ------------------------------------------------------ */
275
+
276
+ MeasuredResult {{
277
+ height: auto;
278
+ padding: 0 1;
279
+ border-left: outer {RAIL};
280
+ }}
281
+ .measured-lede {{ text-style: bold; }}
282
+ .measured-row {{ color: {DIM}; }}
283
+ .measured-row-ours {{ color: $foreground; }}
284
+ .measured-check {{ color: {DIM}; }}
285
+
286
+ /* reading order ------------------------------------------------------------ */
287
+
288
+ EntryPointRow {{
289
+ height: auto;
290
+ padding: 1 1 0 1;
291
+ }}
292
+ .entry-index {{ color: {FAINT}; width: 4; }}
293
+ .entry-why {{ color: {FAINT}; padding-left: 4; }}
294
+
295
+ /* discover, profile, what-next ---------------------------------------------- */
296
+
297
+ #models-body {{
298
+ height: 1fr;
299
+ padding: 0 1;
300
+ }}
301
+ #provider-scroll, #model-scroll {{
302
+ height: auto;
303
+ max-height: 12;
304
+ scrollbar-size: 1 1;
305
+ }}
306
+ #providers, #models {{
307
+ height: auto;
308
+ background: transparent;
309
+ border: none;
310
+ }}
311
+ #model-count {{
312
+ height: auto;
313
+ padding: 0 1 0 2;
314
+ }}
315
+ #providers > ListItem, #models > ListItem {{
316
+ background: transparent;
317
+ padding: 0 1;
318
+ border-left: outer transparent;
319
+ }}
320
+ #providers > ListItem.-highlight, #models > ListItem.-highlight,
321
+ #providers > ListItem.--highlight, #models > ListItem.--highlight {{
322
+ border-left: outer {CITE};
323
+ }}
324
+ #models-notice {{
325
+ height: auto;
326
+ padding: 1 1 0 2;
327
+ }}
328
+ #models-warning {{
329
+ height: auto;
330
+ padding: 1 1 0 2;
331
+ border-left: outer {RAIL};
332
+ }}
333
+
334
+ #discover-body, #profile-body, #next-body {{
335
+ height: 1fr;
336
+ padding: 0 1;
337
+ }}
338
+ #candidate-scroll, #next-results {{
339
+ height: 1fr;
340
+ scrollbar-size: 1 1;
341
+ }}
342
+ CandidateList {{
343
+ height: auto;
344
+ background: transparent;
345
+ border: none;
346
+ }}
347
+ CandidateList > ListItem {{
348
+ background: transparent;
349
+ padding: 0 1 1 1;
350
+ border-left: outer transparent;
351
+ }}
352
+ CandidateList > ListItem.-highlight, CandidateList > ListItem.--highlight {{
353
+ border-left: outer {CITE};
354
+ }}
355
+ #discover-hint {{
356
+ height: auto;
357
+ padding: 1 1 1 1;
358
+ }}
359
+ Input {{
360
+ /* Every input in the app, marked by a rail rather than boxed in. The
361
+ default border draws three lines around one line of text, which on a
362
+ screen made of hairlines is the loudest thing on it. */
363
+ background: transparent;
364
+ border: none;
365
+ border-left: outer {RULE};
366
+ padding: 0 1;
367
+ height: 1;
368
+ margin: 0 0 1 0;
369
+ }}
370
+ Input:focus {{
371
+ border-left: outer {CITE};
372
+ }}
373
+ .field-note {{
374
+ color: {FAINT};
375
+ padding: 0 1 0 2;
376
+ }}
377
+ #profile-notice, #next-notice {{
378
+ height: auto;
379
+ padding: 1 1 0 2;
380
+ }}
381
+ .next-row {{
382
+ height: auto;
383
+ padding: 0 1 1 1;
384
+ }}
385
+
386
+ .section-label {{
387
+ color: {DIM};
388
+ padding: 1 1 0 1;
389
+ }}
390
+ .empty {{
391
+ color: {FAINT};
392
+ padding: 1 1;
393
+ }}
394
+
395
+ /* A run in flight, in the list on home. */
396
+ .running-row {{ color: {DIM}; }}
397
+
398
+ /* The one modal: stopping a run, and quitting with runs still going. */
399
+ ConfirmScreen {{
400
+ align: center middle;
401
+ }}
402
+ #confirm-box {{
403
+ width: 64;
404
+ height: auto;
405
+ padding: 1 3;
406
+ border: round {RULE};
407
+ background: $surface;
408
+ }}
409
+ /* Explicit, because a `Static` inside an auto-width box measures as zero and
410
+ the whole question renders as an empty frame. */
411
+ #confirm-question, #confirm-detail, #confirm-keys {{ width: 100%; }}
412
+ #confirm-question {{ color: {DIM}; }}
413
+ #confirm-detail {{ color: {FAINT}; padding: 1 0 0 0; }}
414
+ #confirm-keys {{ color: {FAINT}; padding: 1 0 0 0; }}
415
+
416
+ /* The command palette.
417
+ *
418
+ * Textual's own styling is a full-width panel between two heavy `hkey` bars,
419
+ * with a magnifying-glass emoji floating above its input. Everything else in
420
+ * holt is a narrow column between hairlines, so the palette is brought into
421
+ * line: same width discipline, same rules, no box.
422
+ *
423
+ * A box is not available anyway. The results list is `overlay: screen`, so it
424
+ * escapes any border drawn on the container and the frame would close above
425
+ * the commands it is meant to contain. Hairlines above and below each part
426
+ * read as one surface without needing to enclose anything. */
427
+ CommandPalette > Vertical {{
428
+ margin-top: 4;
429
+ width: 76;
430
+ height: auto;
431
+ /* Textual hides the container and shows only its children, so the panel
432
+ has no background of its own and home reads through the gaps. */
433
+ visibility: visible;
434
+ background: $surface;
435
+ }}
436
+ CommandPalette #--input {{
437
+ height: auto;
438
+ border: none;
439
+ border-top: solid {RULE};
440
+ border-bottom: solid {RULE};
441
+ background: $surface;
442
+ }}
443
+ CommandPalette #--input.--list-visible {{
444
+ border-bottom: solid {RULE};
445
+ }}
446
+ /* The emoji. It sits on its own line, out of line with the text it labels,
447
+ and the placeholder already says what the box is for. */
448
+ CommandPalette SearchIcon {{
449
+ display: none;
450
+ }}
451
+ CommandPalette #--results {{
452
+ background: $surface;
453
+ }}
454
+ CommandPalette CommandList {{
455
+ background: $surface;
456
+ border: none;
457
+ border-bottom: solid {RULE};
458
+ padding: 0 1;
459
+ max-height: 22;
460
+ }}
461
+ /* The selection has to be visible while the *input* holds focus, which is the
462
+ whole time the palette is open. Textual falls back to its blurred cursor
463
+ tokens there, and blurred on this surface is no cursor at all — up and down
464
+ moved something invisible. The focused tokens are used instead: they are the
465
+ theme's own, so this stays right on a light terminal as well as a dark one. */
466
+ CommandPalette CommandList > .option-list--option-highlighted {{
467
+ color: $block-cursor-foreground;
468
+ background: $block-cursor-background;
469
+ text-style: $block-cursor-text-style;
470
+ }}
471
+ CommandPalette > .command-palette--help-text {{
472
+ color: {FAINT};
473
+ text-style: none;
474
+ }}
475
+ CommandPalette > .command-palette--highlight {{
476
+ color: {RAIL};
477
+ text-style: bold;
478
+ }}
479
+ """
holt/tui/visual.py ADDED
@@ -0,0 +1,33 @@
1
+ """One place where styled text becomes something Textual can lay out.
2
+
3
+ Spans are built with Rich `Text` throughout the widgets: evidence ids contain
4
+ brackets, colons and hashes, and building them as spans rather than console
5
+ markup means a repository name can never inject styling into the interface.
6
+
7
+ Textual's layout engine wants a `Content`. Converting in a single helper keeps
8
+ that detail out of every widget, and means a future change to how Textual
9
+ consumes renderables is a change to this file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from rich.text import Text
15
+ from textual.content import Content
16
+ from textual.widgets import Static
17
+
18
+
19
+ def visual(renderable):
20
+ """Rich `Text` → Textual `Content`. Anything else passes through."""
21
+ if isinstance(renderable, Text):
22
+ return Content.from_rich_text(renderable)
23
+ return renderable
24
+
25
+
26
+ class Line(Static):
27
+ """A `Static` that accepts Rich `Text` as well as a plain string."""
28
+
29
+ def __init__(self, renderable="", **kwargs) -> None:
30
+ super().__init__(visual(renderable), **kwargs)
31
+
32
+ def update(self, renderable="") -> None:
33
+ super().update(visual(renderable))
File without changes
@@ -0,0 +1,78 @@
1
+ """Candidates a search turned up, and what free screening made of them.
2
+
3
+ Screening is the cheap half: it runs no model, so it can look at everything and
4
+ throw most of it away before anything is spent. That is the fact the list has to
5
+ communicate, which is why the ones that were cut stay on screen with the reason
6
+ attached. A discovery tool that shows only its survivors is asking to be trusted
7
+ about the ones it hid.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from rich.text import Text
15
+ from textual.widgets import ListItem, ListView
16
+
17
+ from holt.tui import theme
18
+ from holt.tui.discovery import cut_reason
19
+ from holt.tui.visual import Line
20
+ from holt.tui.widgets.scrolling import KeepsHighlightVisible
21
+
22
+
23
+ class CandidateRow(ListItem):
24
+ def __init__(self, row: Any, **kwargs) -> None:
25
+ super().__init__(**kwargs)
26
+ self.row = row
27
+
28
+ def compose(self):
29
+ row = self.row
30
+ head = Text()
31
+ head.append(f"{_slug(row.slug):<38}", style="" if row.survived else theme.FAINT)
32
+ if row.survived:
33
+ head.append("survived screening", style=theme.verdict_colour(row.verdict))
34
+ else:
35
+ head.append(cut_reason(row.category), style=theme.FAINT)
36
+ yield Line(head)
37
+
38
+ detail = Text(" ")
39
+ if row.language:
40
+ detail.append(f"{row.language} ", style=theme.FAINT)
41
+ if row.stars:
42
+ detail.append(f"{row.stars:,}★ ", style=theme.FAINT)
43
+ if row.description:
44
+ detail.append(row.description[:70], style=theme.FAINT)
45
+ yield Line(detail)
46
+
47
+
48
+ def _slug(slug: str, width: int = 37) -> str:
49
+ if len(slug) <= width:
50
+ return slug
51
+ owner, sep, name = slug.partition("/")
52
+ if sep and len(name) + 2 <= width:
53
+ return f"…/{name}"
54
+ return slug[: width - 1] + "…"
55
+
56
+
57
+ class CandidateList(KeepsHighlightVisible, ListView):
58
+ def __init__(self, rows: list | None = None, **kwargs) -> None:
59
+ rows = list(rows or [])
60
+ super().__init__(*[CandidateRow(r) for r in rows], **kwargs)
61
+ self.rows = rows
62
+
63
+ def add(self, row: Any) -> CandidateRow:
64
+ """Append one candidate that has just been screened.
65
+
66
+ A live sweep reads a page of pull-request threads per candidate, so the
67
+ list is built a row at a time rather than handed over complete. The
68
+ widget's own `rows` is kept in step with what is on screen; a count
69
+ taken from a list that disagrees with the display is a count that lies.
70
+ """
71
+ item = CandidateRow(row)
72
+ self.rows.append(row)
73
+ self.append(item)
74
+ return item
75
+
76
+ @property
77
+ def selected(self) -> Any | None:
78
+ return getattr(self.highlighted_child, "row", None)
@@ -0,0 +1,59 @@
1
+ """The claim list: statements, each with the id that survived Stage D.
2
+
3
+ Selection is by keyboard. Enter on a claim resolves its evidence id and opens
4
+ the record, which is the interaction the report is really about — a reader
5
+ should be one keystroke from checking any sentence Holt writes.
6
+
7
+ The list makes no assumption about how many claims there are or what fields
8
+ they carry. It renders what the `Assessment` holds.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from rich.text import Text
16
+ from textual.widgets import ListItem, ListView
17
+
18
+ from holt.tui import theme
19
+ from holt.tui.widgets.evidence import cite
20
+ from holt.tui.visual import Line
21
+ from holt.tui.widgets.scrolling import KeepsHighlightVisible
22
+
23
+
24
+ class ClaimItem(ListItem):
25
+ def __init__(self, claim: Any, repo: str | None = None, **kwargs) -> None:
26
+ super().__init__(**kwargs)
27
+ self.claim = claim
28
+ self.repo = repo
29
+
30
+ def compose(self):
31
+ claim = self.claim
32
+ yield Line(
33
+ Text(_headline(claim.text), no_wrap=True, overflow="ellipsis")
34
+ )
35
+ if claim.evidence_id:
36
+ row = Text(" ")
37
+ row.append_text(cite(claim.evidence_id, width=64, repo=self.repo))
38
+ yield Line(row)
39
+ else:
40
+ # Should not occur — Stage D removes these — but the view reports
41
+ # what it is given rather than asserting the engine's invariant.
42
+ yield Line(Text(" no evidence id", style=theme.DROP))
43
+
44
+
45
+ class ClaimList(KeepsHighlightVisible, ListView):
46
+ def __init__(self, claims: list, repo: str | None = None, **kwargs) -> None:
47
+ super().__init__(*[ClaimItem(c, repo) for c in claims], **kwargs)
48
+ self.claims = claims
49
+
50
+ @property
51
+ def selected(self) -> Any | None:
52
+ item = self.highlighted_child
53
+ return getattr(item, "claim", None)
54
+
55
+
56
+ def _headline(text: str) -> str:
57
+ """The claim's first clause. The rationale in parentheses is the detail."""
58
+ head = text.split(" (", 1)[0]
59
+ return " ".join(head.split())