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
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())
|