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,62 @@
|
|
|
1
|
+
"""A yes/no question, over whatever you were looking at.
|
|
2
|
+
|
|
3
|
+
Used for the two decisions that destroy work: stopping a run, and quitting with
|
|
4
|
+
runs still in flight. Both are cheap to ask about and expensive to get wrong —
|
|
5
|
+
a live run stopped at stage D has already been paid for.
|
|
6
|
+
|
|
7
|
+
Modal rather than a screen you navigate to, because the question is about the
|
|
8
|
+
thing underneath it: dismissing has to put you back exactly where you were.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual.app import ComposeResult
|
|
15
|
+
from textual.containers import Vertical
|
|
16
|
+
from textual.screen import ModalScreen
|
|
17
|
+
|
|
18
|
+
from holt.tui import theme
|
|
19
|
+
from holt.tui.visual import Line
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
23
|
+
"""Returns True if the reader confirmed. Escape and `n` both mean no.
|
|
24
|
+
|
|
25
|
+
`n` is bound as well as escape because the question is phrased as a
|
|
26
|
+
question: someone reading "quit anyway? y/n" reaches for `n`, and a key
|
|
27
|
+
that the prompt names has to work.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
BINDINGS = [
|
|
31
|
+
("y", "confirm", "yes"),
|
|
32
|
+
("n", "cancel", "no"),
|
|
33
|
+
("escape", "cancel", "no"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
question: str,
|
|
39
|
+
detail: str = "",
|
|
40
|
+
yes: str = "y stop",
|
|
41
|
+
no: str = "n keep running",
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.question = question
|
|
45
|
+
self.detail = detail
|
|
46
|
+
self.yes = yes
|
|
47
|
+
self.no = no
|
|
48
|
+
|
|
49
|
+
def compose(self) -> ComposeResult:
|
|
50
|
+
with Vertical(id="confirm-box"):
|
|
51
|
+
yield Line(Text(self.question, style=theme.DIM), id="confirm-question")
|
|
52
|
+
if self.detail:
|
|
53
|
+
yield Line(Text(self.detail, style=theme.FAINT), id="confirm-detail")
|
|
54
|
+
yield Line(
|
|
55
|
+
Text(f"{self.yes} {self.no}", style=theme.FAINT), id="confirm-keys"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def action_confirm(self) -> None:
|
|
59
|
+
self.dismiss(True)
|
|
60
|
+
|
|
61
|
+
def action_cancel(self) -> None:
|
|
62
|
+
self.dismiss(False)
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Finding a repository when you do not have one in mind.
|
|
2
|
+
|
|
3
|
+
The rest of the interface assumes you can name what you want assessed. Often you
|
|
4
|
+
cannot — that is the actual starting position — and this is the screen for it.
|
|
5
|
+
|
|
6
|
+
**It opens on a choice, not on a list.** It used to open on a recorded search
|
|
7
|
+
that shipped in the repository: twenty-five repositories somebody else's profile
|
|
8
|
+
turned up, on a date in the past, presented exactly as a result would be. As a
|
|
9
|
+
demo of the screening rule that is honest; as the first thing `ctrl+f` does it
|
|
10
|
+
is not, because those repositories are not an answer to *your* question and no
|
|
11
|
+
amount of labelling makes them one. So the recording is still here, still free,
|
|
12
|
+
and now something you ask for. What the screen offers first is a live search.
|
|
13
|
+
|
|
14
|
+
Live search is free in the sense that matters: `screen_records` runs no model,
|
|
15
|
+
so a sweep costs GitHub API quota and about a minute of waiting. Rows are drawn
|
|
16
|
+
as they land rather than after the last one, because a minute of blank screen
|
|
17
|
+
reads as a hang. The model cost is where it always was — pressing enter on a row
|
|
18
|
+
starts an ordinary assessment run.
|
|
19
|
+
|
|
20
|
+
Two things it will not do:
|
|
21
|
+
|
|
22
|
+
* **Hide what it rejected.** Nine of twenty-five candidates being cut for free
|
|
23
|
+
is the interesting result, not an implementation detail. They stay listed.
|
|
24
|
+
* **Reorder survivors.** Screening says a candidate is worth a closer look; it
|
|
25
|
+
does not say one is better than another, and sorting them would assert that it
|
|
26
|
+
did. They keep the order the search returned them in.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
|
|
33
|
+
from rich.text import Text
|
|
34
|
+
from textual.app import ComposeResult
|
|
35
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
36
|
+
from textual.css.query import NoMatches
|
|
37
|
+
from textual.screen import Screen
|
|
38
|
+
from textual.widgets import Footer, ListItem, ListView
|
|
39
|
+
|
|
40
|
+
from holt import profile as profile_mod
|
|
41
|
+
from holt.profile import Profile
|
|
42
|
+
from holt.tui import animation, discovery, session as session_module, theme
|
|
43
|
+
from holt.tui.visual import Line
|
|
44
|
+
from holt.tui.widgets.candidates import CandidateList
|
|
45
|
+
|
|
46
|
+
#: How often the screen picks up rows the search thread has finished. The sweep
|
|
47
|
+
#: is network-bound and lands a row every few seconds, so this is about not
|
|
48
|
+
#: looking frozen rather than about frame rate.
|
|
49
|
+
POLL_SECONDS = 0.1
|
|
50
|
+
|
|
51
|
+
START_HINT = "enter choose ctrl+o change what you are looking for esc back"
|
|
52
|
+
SEARCH_HINT = "enter assess this one ctrl+x stop searching esc back"
|
|
53
|
+
DONE_HINT = "enter assess this one ctrl+o change what you are looking for esc back"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Choice:
|
|
58
|
+
"""One way out of the start screen."""
|
|
59
|
+
|
|
60
|
+
action: str
|
|
61
|
+
title: str
|
|
62
|
+
detail: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ChoiceItem(ListItem):
|
|
66
|
+
def __init__(self, choice: Choice, **kwargs) -> None:
|
|
67
|
+
super().__init__(**kwargs)
|
|
68
|
+
self.choice = choice
|
|
69
|
+
|
|
70
|
+
def compose(self):
|
|
71
|
+
yield Line(Text(self.choice.title))
|
|
72
|
+
yield Line(Text(f" {self.choice.detail}", style=theme.FAINT))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ChoiceList(ListView):
|
|
76
|
+
def __init__(self, choices: list[Choice], **kwargs) -> None:
|
|
77
|
+
super().__init__(*[ChoiceItem(c) for c in choices], **kwargs)
|
|
78
|
+
self.choices = choices
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DiscoverScreen(Screen):
|
|
82
|
+
BINDINGS = [
|
|
83
|
+
("ctrl+o", "profile", "change what you want"),
|
|
84
|
+
("ctrl+x", "stop", "stop searching"),
|
|
85
|
+
("escape", "home", "home"),
|
|
86
|
+
("q", "quit", "quit"),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def __init__(self, session: str | None = None, **kwargs) -> None:
|
|
90
|
+
super().__init__(**kwargs)
|
|
91
|
+
# Not `self.name`: Textual widgets already own that, and assigning to it
|
|
92
|
+
# raises inside the screen's constructor, where the failure surfaces as
|
|
93
|
+
# a screen that simply never appears.
|
|
94
|
+
#
|
|
95
|
+
# `None` means "start on the choice". A name means the caller asked for
|
|
96
|
+
# that recording specifically, which is how the demo is still reachable.
|
|
97
|
+
self.session_name = session
|
|
98
|
+
self.session: discovery.Session | None = None
|
|
99
|
+
self.search: discovery.Search | None = None
|
|
100
|
+
self.error = ""
|
|
101
|
+
#: How many of the search's rows are already on screen.
|
|
102
|
+
self._cursor = 0
|
|
103
|
+
self._timer = None
|
|
104
|
+
|
|
105
|
+
# ─── layout ─────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def compose(self) -> ComposeResult:
|
|
108
|
+
if self.session_name is not None:
|
|
109
|
+
self._load_recording(self.session_name)
|
|
110
|
+
|
|
111
|
+
with Horizontal(id="chrome"):
|
|
112
|
+
yield Line("holt · discover", id="chrome-left")
|
|
113
|
+
yield Line(self._provenance(), id="chrome-right")
|
|
114
|
+
yield Line("─" * 240, classes="rule")
|
|
115
|
+
|
|
116
|
+
with Vertical(id="discover-body"):
|
|
117
|
+
if self.error:
|
|
118
|
+
yield Line(Text(self.error, style=theme.DROP), classes="empty")
|
|
119
|
+
else:
|
|
120
|
+
# Both panes are built once and shown one at a time. Tearing a
|
|
121
|
+
# pane down and building another in its place mid-search is a
|
|
122
|
+
# race against the poll timer that has nothing to gain.
|
|
123
|
+
with Vertical(id="discover-start"):
|
|
124
|
+
yield Line("", id="start-looking", classes="section-label")
|
|
125
|
+
yield Line(
|
|
126
|
+
Text(START_HINT, style=theme.FAINT), id="start-hint"
|
|
127
|
+
)
|
|
128
|
+
yield ChoiceList(self._choices(), id="choices")
|
|
129
|
+
with Vertical(id="discover-results"):
|
|
130
|
+
yield Line("", id="results-looking", classes="section-label")
|
|
131
|
+
yield Line("", id="results-counts", classes="section-label")
|
|
132
|
+
yield Line("", id="discover-hint")
|
|
133
|
+
with VerticalScroll(id="candidate-scroll"):
|
|
134
|
+
yield CandidateList(id="candidates")
|
|
135
|
+
yield Footer()
|
|
136
|
+
|
|
137
|
+
def on_mount(self) -> None:
|
|
138
|
+
if self.error:
|
|
139
|
+
return
|
|
140
|
+
if self.session is not None:
|
|
141
|
+
self._present_recording()
|
|
142
|
+
else:
|
|
143
|
+
self._show_start()
|
|
144
|
+
|
|
145
|
+
def check_action(self, action: str, parameters):
|
|
146
|
+
"""Stopping is only offered while there is something to stop.
|
|
147
|
+
|
|
148
|
+
The footer is read as a statement of what the screen can do right now,
|
|
149
|
+
and advertising a key that does nothing on the start screen made the
|
|
150
|
+
choice look like a search that was already running.
|
|
151
|
+
"""
|
|
152
|
+
if action == "stop":
|
|
153
|
+
return self.search is not None and self.search.running
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def on_screen_resume(self) -> None:
|
|
157
|
+
"""Coming back from the profile screen must not show the old profile."""
|
|
158
|
+
if self.error or self.search is not None or self.session is not None:
|
|
159
|
+
return
|
|
160
|
+
# Resume can arrive before the first mount, when there is nothing on
|
|
161
|
+
# screen to bring up to date and nothing to report either.
|
|
162
|
+
try:
|
|
163
|
+
self.query_one("#start-looking", Line).update(self._looking())
|
|
164
|
+
except NoMatches:
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# ─── the choice ─────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
def _choices(self) -> list[Choice]:
|
|
170
|
+
choices = [
|
|
171
|
+
Choice(
|
|
172
|
+
"live",
|
|
173
|
+
"Search GitHub for repositories",
|
|
174
|
+
"reads GitHub directly and screens every candidate for free",
|
|
175
|
+
),
|
|
176
|
+
Choice(
|
|
177
|
+
"profile",
|
|
178
|
+
"Change what you are looking for",
|
|
179
|
+
"languages, topics, the kind of work, how long you will wait",
|
|
180
|
+
),
|
|
181
|
+
]
|
|
182
|
+
# Offered only when it exists, and described as what it is. A recorded
|
|
183
|
+
# search presented without its date is a result presented as current.
|
|
184
|
+
if discovery.manifest_path_exists():
|
|
185
|
+
choices.append(
|
|
186
|
+
Choice(
|
|
187
|
+
"replay",
|
|
188
|
+
"Replay the recorded example search",
|
|
189
|
+
"somebody else's profile, screened from committed fixtures",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return choices
|
|
193
|
+
|
|
194
|
+
def _show_start(self) -> None:
|
|
195
|
+
self.query_one("#discover-start").display = True
|
|
196
|
+
self.query_one("#discover-results").display = False
|
|
197
|
+
self.query_one("#start-looking", Line).update(self._looking())
|
|
198
|
+
choices = self.query_one("#choices", ChoiceList)
|
|
199
|
+
for index, item in enumerate(choices.children):
|
|
200
|
+
animation.reveal(item, delay=animation.stagger(index))
|
|
201
|
+
choices.focus()
|
|
202
|
+
|
|
203
|
+
def _looking(self) -> Text:
|
|
204
|
+
stored = profile_mod.load()
|
|
205
|
+
if stored is None:
|
|
206
|
+
return Text(
|
|
207
|
+
"No profile saved — a search would look for any active repository. "
|
|
208
|
+
"Change what you are looking for to narrow it.",
|
|
209
|
+
style=theme.DIM,
|
|
210
|
+
)
|
|
211
|
+
return Text(f"Looking for {stored.describe()}", style=theme.DIM)
|
|
212
|
+
|
|
213
|
+
def _profile(self) -> Profile:
|
|
214
|
+
"""What to search for. An unsaved profile is a broad search, not a
|
|
215
|
+
refusal: `build_queries` handles an empty one and returns a real query."""
|
|
216
|
+
return profile_mod.load() or Profile()
|
|
217
|
+
|
|
218
|
+
# ─── the live search ────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def _start_live(self) -> None:
|
|
221
|
+
missing = discovery.missing_token()
|
|
222
|
+
if missing:
|
|
223
|
+
# Said on the screen that asked for it, and the choice stays put.
|
|
224
|
+
self.query_one("#start-hint", Line).update(Text(missing, style=theme.DROP))
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
self.search = discovery.Search(profile=self._profile())
|
|
228
|
+
self.search.start()
|
|
229
|
+
|
|
230
|
+
self.query_one("#discover-start").display = False
|
|
231
|
+
self.query_one("#discover-results").display = True
|
|
232
|
+
self.query_one("#results-looking", Line).update(self._looking())
|
|
233
|
+
self.query_one("#results-counts", Line).update(
|
|
234
|
+
Text(self.search.describe(), style=theme.FAINT)
|
|
235
|
+
)
|
|
236
|
+
self.query_one("#discover-hint", Line).update(
|
|
237
|
+
Text(SEARCH_HINT, style=theme.FAINT)
|
|
238
|
+
)
|
|
239
|
+
self.query_one("#chrome-right", Line).update(self._provenance())
|
|
240
|
+
self.query_one("#candidates", CandidateList).focus()
|
|
241
|
+
self._timer = self.set_interval(POLL_SECONDS, self._pump)
|
|
242
|
+
self.refresh_bindings()
|
|
243
|
+
|
|
244
|
+
def _pump(self) -> None:
|
|
245
|
+
"""Draw whatever the worker has screened since the last tick."""
|
|
246
|
+
search = self.search
|
|
247
|
+
if search is None:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
candidates = self.query_one("#candidates", CandidateList)
|
|
251
|
+
pending = search.rows[self._cursor :]
|
|
252
|
+
self._cursor = len(search.rows)
|
|
253
|
+
for row in pending:
|
|
254
|
+
animation.reveal(candidates.add(row))
|
|
255
|
+
if pending and candidates.index is None:
|
|
256
|
+
# Nothing is highlighted until the list has something in it, and
|
|
257
|
+
# without this the first row arrives with no cursor on it.
|
|
258
|
+
candidates.index = 0
|
|
259
|
+
|
|
260
|
+
self.query_one("#results-counts", Line).update(
|
|
261
|
+
Text(search.describe(), style=theme.FAINT)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if search.finished and self._cursor == len(search.rows):
|
|
265
|
+
self._finish()
|
|
266
|
+
|
|
267
|
+
def _finish(self) -> None:
|
|
268
|
+
if self._timer is not None:
|
|
269
|
+
self._timer.stop()
|
|
270
|
+
self._timer = None
|
|
271
|
+
self.refresh_bindings()
|
|
272
|
+
search = self.search
|
|
273
|
+
if search is None:
|
|
274
|
+
return
|
|
275
|
+
self.query_one("#chrome-right", Line).update(self._provenance())
|
|
276
|
+
if search.error:
|
|
277
|
+
self.query_one("#discover-hint", Line).update(
|
|
278
|
+
Text(search.error, style=theme.DROP)
|
|
279
|
+
)
|
|
280
|
+
return
|
|
281
|
+
if not search.rows:
|
|
282
|
+
self.query_one("#discover-hint", Line).update(
|
|
283
|
+
Text(
|
|
284
|
+
"Nothing came back. Widening the languages or topics is the "
|
|
285
|
+
"next thing to try — ctrl+o.",
|
|
286
|
+
style=theme.DROP,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return
|
|
290
|
+
self.query_one("#discover-hint", Line).update(
|
|
291
|
+
Text(DONE_HINT, style=theme.FAINT)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# ─── the recording ──────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
def _load_recording(self, name: str) -> None:
|
|
297
|
+
try:
|
|
298
|
+
self.session = discovery.load(name)
|
|
299
|
+
except FileNotFoundError:
|
|
300
|
+
self.error = (
|
|
301
|
+
f"No recorded search named {name!r}. "
|
|
302
|
+
"`holt discover --live --record <name>` makes one."
|
|
303
|
+
)
|
|
304
|
+
except Exception as exc: # noqa: BLE001 - reported, never a traceback
|
|
305
|
+
self.error = session_module.readable(exc)
|
|
306
|
+
|
|
307
|
+
def _present_recording(self) -> None:
|
|
308
|
+
found = self.session
|
|
309
|
+
if found is None:
|
|
310
|
+
return
|
|
311
|
+
self.query_one("#discover-start").display = False
|
|
312
|
+
self.query_one("#discover-results").display = True
|
|
313
|
+
self.query_one("#results-looking", Line).update(
|
|
314
|
+
Text(f"Looking for {found.profile_description}", style=theme.DIM)
|
|
315
|
+
)
|
|
316
|
+
self.query_one("#results-counts", Line).update(
|
|
317
|
+
Text(self._counts(), style=theme.FAINT)
|
|
318
|
+
)
|
|
319
|
+
self.query_one("#discover-hint", Line).update(
|
|
320
|
+
Text(DONE_HINT, style=theme.FAINT)
|
|
321
|
+
)
|
|
322
|
+
candidates = self.query_one("#candidates", CandidateList)
|
|
323
|
+
for index, row in enumerate(found.rows):
|
|
324
|
+
animation.reveal(candidates.add(row), delay=animation.stagger(index))
|
|
325
|
+
if found.rows:
|
|
326
|
+
candidates.index = 0
|
|
327
|
+
# The list, not the scroll box around it. Focus landed on the container
|
|
328
|
+
# by default, where ↑↓ scrolled past the candidates and enter did
|
|
329
|
+
# nothing — the only way to assess one was to click it.
|
|
330
|
+
candidates.focus()
|
|
331
|
+
|
|
332
|
+
def _counts(self) -> str:
|
|
333
|
+
found = self.session
|
|
334
|
+
if found is None:
|
|
335
|
+
return ""
|
|
336
|
+
total, survived = len(found.rows), len(found.survivors)
|
|
337
|
+
text = (
|
|
338
|
+
f"{total} candidates screened at no model cost · "
|
|
339
|
+
f"{survived} worth a closer look · {total - survived} cut"
|
|
340
|
+
)
|
|
341
|
+
if found.skipped:
|
|
342
|
+
text += f" · {len(found.skipped)} had no recorded evidence"
|
|
343
|
+
return text
|
|
344
|
+
|
|
345
|
+
def _provenance(self) -> Text:
|
|
346
|
+
text = Text()
|
|
347
|
+
if self.session is not None:
|
|
348
|
+
text.append(f"{self.session.name} ", style=theme.FAINT)
|
|
349
|
+
text.append(
|
|
350
|
+
f"recorded {self.session.as_of.date().isoformat()}", style=theme.FAINT
|
|
351
|
+
)
|
|
352
|
+
elif self.search is not None:
|
|
353
|
+
when = "searching" if self.search.running else "live"
|
|
354
|
+
text.append(when, style=theme.FAINT)
|
|
355
|
+
if self.search.as_of is not None:
|
|
356
|
+
text.append(
|
|
357
|
+
f" {self.search.as_of.date().isoformat()}", style=theme.FAINT
|
|
358
|
+
)
|
|
359
|
+
return text
|
|
360
|
+
|
|
361
|
+
# ─── actions ────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
def on_list_view_selected(self, event) -> None:
|
|
364
|
+
choice = getattr(event.item, "choice", None)
|
|
365
|
+
if choice is not None:
|
|
366
|
+
self._choose(choice)
|
|
367
|
+
return
|
|
368
|
+
row = getattr(event.item, "row", None)
|
|
369
|
+
if row is not None:
|
|
370
|
+
self._assess(row)
|
|
371
|
+
|
|
372
|
+
def _choose(self, choice: Choice) -> None:
|
|
373
|
+
if choice.action == "live":
|
|
374
|
+
self._start_live()
|
|
375
|
+
elif choice.action == "profile":
|
|
376
|
+
self.action_profile()
|
|
377
|
+
elif choice.action == "replay":
|
|
378
|
+
self._load_recording(discovery.DEFAULT_SESSION)
|
|
379
|
+
if self.error:
|
|
380
|
+
self.query_one("#start-hint", Line).update(
|
|
381
|
+
Text(self.error, style=theme.DROP)
|
|
382
|
+
)
|
|
383
|
+
self.error = ""
|
|
384
|
+
return
|
|
385
|
+
self.session_name = discovery.DEFAULT_SESSION
|
|
386
|
+
self.query_one("#chrome-right", Line).update(self._provenance())
|
|
387
|
+
self._present_recording()
|
|
388
|
+
|
|
389
|
+
def _assess(self, row) -> None:
|
|
390
|
+
"""Assess this candidate the way the search that found it read GitHub.
|
|
391
|
+
|
|
392
|
+
**The mode follows the search, not the disk.** It used to be
|
|
393
|
+
`replay = has_recording(row.slug)`: search GitHub live, press enter on
|
|
394
|
+
a result, and if a trajectory happened to be sitting in `fixtures/` you
|
|
395
|
+
were handed a recorded answer from June instead of an assessment of
|
|
396
|
+
what you had just found. Nothing on screen said so. Which evidence a
|
|
397
|
+
run reads is a choice, and it is not one the contents of a directory
|
|
398
|
+
get to make on the reader's behalf.
|
|
399
|
+
|
|
400
|
+
So: rows from a live sweep are assessed live, rows from a recorded
|
|
401
|
+
session are replayed, and a recorded row with no trajectory says that
|
|
402
|
+
rather than quietly going live and asking for credentials.
|
|
403
|
+
"""
|
|
404
|
+
replay = self.session_name is not None
|
|
405
|
+
if replay and not session_module.has_recording(row.slug):
|
|
406
|
+
self._hint(
|
|
407
|
+
f"No recording of an assessment of {row.slug}, so it cannot be "
|
|
408
|
+
"replayed. Assess it live from the home screen."
|
|
409
|
+
)
|
|
410
|
+
return
|
|
411
|
+
options = session_module.RunOptions(
|
|
412
|
+
repo=row.slug, replay=replay, live=not replay
|
|
413
|
+
)
|
|
414
|
+
cached = self.app.store.fresh(
|
|
415
|
+
row.slug, options.mode, options.contributor_days
|
|
416
|
+
)
|
|
417
|
+
if cached is not None:
|
|
418
|
+
self.app.open_stored(cached)
|
|
419
|
+
return
|
|
420
|
+
missing = session_module.missing_credentials(options)
|
|
421
|
+
if missing:
|
|
422
|
+
self._hint(missing[0])
|
|
423
|
+
return
|
|
424
|
+
self.app.start_run(options)
|
|
425
|
+
|
|
426
|
+
def _hint(self, message: str) -> None:
|
|
427
|
+
self.query_one("#discover-hint", Line).update(Text(message, style=theme.DROP))
|
|
428
|
+
|
|
429
|
+
def action_profile(self) -> None:
|
|
430
|
+
"""The profile is what this search was built from, so it is edited here."""
|
|
431
|
+
self.app.push_screen("profile")
|
|
432
|
+
|
|
433
|
+
def action_stop(self) -> None:
|
|
434
|
+
"""Stop the sweep. Rows already screened stay: they were free, they are
|
|
435
|
+
still true, and throwing them away would punish impatience."""
|
|
436
|
+
if self.search is not None and self.search.running:
|
|
437
|
+
self.search.cancel()
|
|
438
|
+
self.refresh_bindings()
|
|
439
|
+
|
|
440
|
+
def action_home(self) -> None:
|
|
441
|
+
self.app.go_home()
|
|
442
|
+
|
|
443
|
+
def action_quit(self) -> None:
|
|
444
|
+
self.app.exit()
|