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,121 @@
|
|
|
1
|
+
"""The tool's negative result about itself, and the ranking it qualifies.
|
|
2
|
+
|
|
3
|
+
The reading order Holt prints was measured against the comparators that could
|
|
4
|
+
make it unnecessary, and it did not beat them. That measurement is displayed
|
|
5
|
+
here, above the ranking, in full.
|
|
6
|
+
|
|
7
|
+
Three deliberate choices, because how this is presented is the claim:
|
|
8
|
+
|
|
9
|
+
* **It is not an error.** No red, no warning icon. It is a considered disclosure
|
|
10
|
+
and it is styled like the rest of the prose, set off by a quiet rail.
|
|
11
|
+
* **It is not collapsed.** There is no key to press to reveal it. A caveat filed
|
|
12
|
+
behind a keystroke is a caveat filed where nobody looks.
|
|
13
|
+
* **It gets more room than the ranking.** The four precision figures are a table
|
|
14
|
+
rather than a sentence, because the point is that they sit on top of each
|
|
15
|
+
other, and a table shows that in one glance where prose hides it.
|
|
16
|
+
|
|
17
|
+
Every number comes from `holt.agent.entry.MEASURED`, the same constant the
|
|
18
|
+
report renders and the harness regenerates. There are no figures typed into this
|
|
19
|
+
file, so the interface cannot drift from the measurement.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
from textual.app import ComposeResult
|
|
28
|
+
from textual.widget import Widget
|
|
29
|
+
|
|
30
|
+
from holt.agent.entry import MEASURED
|
|
31
|
+
from holt.tui import theme
|
|
32
|
+
from holt.tui.widgets.evidence import cite
|
|
33
|
+
from holt.tui.visual import Line
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Display names for the comparators, in the order they are shown. Ours first,
|
|
37
|
+
#: because the honest reading is "this one, against the ones that beat it".
|
|
38
|
+
COMPARATORS: tuple[tuple[str, str], ...] = (
|
|
39
|
+
("this ranking", "holt"),
|
|
40
|
+
("good first issue", "good_first_issue"),
|
|
41
|
+
("recency", "recency"),
|
|
42
|
+
("random", "random"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MeasuredResult(Widget):
|
|
47
|
+
def compose(self) -> ComposeResult:
|
|
48
|
+
precision = MEASURED.get("precision_at_3", {})
|
|
49
|
+
repos = MEASURED.get("repositories")
|
|
50
|
+
issues = MEASURED.get("issues_ranked")
|
|
51
|
+
low, high = MEASURED.get("paired_ci_vs_label", (0.0, 0.0))
|
|
52
|
+
p_value = MEASURED.get("sign_test_p")
|
|
53
|
+
unlabelled = MEASURED.get("repos_with_no_labelled_issue")
|
|
54
|
+
|
|
55
|
+
yield Line(
|
|
56
|
+
Text(
|
|
57
|
+
"This ranking is not measurably better than picking at random.",
|
|
58
|
+
style="bold",
|
|
59
|
+
),
|
|
60
|
+
classes="measured-lede",
|
|
61
|
+
)
|
|
62
|
+
yield Line(
|
|
63
|
+
f"Measured over {repos} repositories and {issues:,} issues held out "
|
|
64
|
+
"before the cutoff."
|
|
65
|
+
)
|
|
66
|
+
yield Line("")
|
|
67
|
+
|
|
68
|
+
for label, key in COMPARATORS:
|
|
69
|
+
if key not in precision:
|
|
70
|
+
continue
|
|
71
|
+
row = Text()
|
|
72
|
+
row.append(f" {label:<20}", style="" if key == "holt" else theme.DIM)
|
|
73
|
+
row.append(f"{precision[key]:.3f}", style="" if key == "holt" else theme.DIM)
|
|
74
|
+
yield Line(row, classes="measured-row")
|
|
75
|
+
|
|
76
|
+
yield Line("")
|
|
77
|
+
yield Line(
|
|
78
|
+
f"Differences well inside noise — paired 95% CI [{low:+.2f}, {high:+.2f}], "
|
|
79
|
+
f"sign test p = {p_value}."
|
|
80
|
+
)
|
|
81
|
+
yield Line("")
|
|
82
|
+
yield Line(
|
|
83
|
+
f"Printed anyway because {unlabelled} of those {repos} repositories had no "
|
|
84
|
+
"beginner-labelled issue at all, so on half of them there is no free "
|
|
85
|
+
"signal to lose to. Read it as a reading order, not a recommendation."
|
|
86
|
+
)
|
|
87
|
+
yield Line("")
|
|
88
|
+
check = Text()
|
|
89
|
+
check.append("Check it yourself: ", style=theme.DIM)
|
|
90
|
+
check.append("uv run python eval/pathfinder_harness.py --replay")
|
|
91
|
+
yield Line(check, classes="measured-check")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class EntryPointRow(Widget):
|
|
95
|
+
"""One suggested starting point.
|
|
96
|
+
|
|
97
|
+
Numbered, because here the order is the content — it is a reading order, and
|
|
98
|
+
the number is the only thing the ranking actually asserts. Nothing else in
|
|
99
|
+
the interface is a sequence, so nothing else is numbered.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, index: int, point: Any, **kwargs) -> None:
|
|
103
|
+
super().__init__(**kwargs)
|
|
104
|
+
self.index = index
|
|
105
|
+
self.point = point
|
|
106
|
+
|
|
107
|
+
def compose(self) -> ComposeResult:
|
|
108
|
+
head = Text()
|
|
109
|
+
head.append(f"{self.index} ", style=theme.FAINT)
|
|
110
|
+
head.append(" ".join(self.point.first_step.split()))
|
|
111
|
+
yield Line(head)
|
|
112
|
+
|
|
113
|
+
row = Text(" ")
|
|
114
|
+
row.append_text(cite(self.point.evidence_id, width=60))
|
|
115
|
+
yield Line(row)
|
|
116
|
+
|
|
117
|
+
if self.point.why:
|
|
118
|
+
yield Line(
|
|
119
|
+
Text(" ".join(self.point.why.split()), style=theme.FAINT),
|
|
120
|
+
classes="entry-why",
|
|
121
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Rendering an evidence id, and the record behind it.
|
|
2
|
+
|
|
3
|
+
An evidence id is the load-bearing element of the whole report: a claim with one
|
|
4
|
+
that resolves is checkable, and a claim without one never reaches the reader. So
|
|
5
|
+
it gets the only underline in the interface, and the difference between resolving
|
|
6
|
+
and not resolving is visible without reading a word.
|
|
7
|
+
|
|
8
|
+
Ids are rendered through Rich `Text` spans rather than console markup. Ids
|
|
9
|
+
contain brackets, colons and hashes, and markup would either mangle them or, at
|
|
10
|
+
worst, let a repository name inject styling into the interface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.widget import Widget
|
|
20
|
+
|
|
21
|
+
from holt.tui import theme
|
|
22
|
+
from holt.tui.visual import Line
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def elide(evidence_id: str, width: int) -> str:
|
|
26
|
+
"""Shorten an id from the middle, never from the end.
|
|
27
|
+
|
|
28
|
+
Ids look like `pr:owner/name#172481:opened`. The owner and name repeat on
|
|
29
|
+
every row and the part that identifies the record — the number and the
|
|
30
|
+
event — is at the end, so cutting the tail would remove the only thing worth
|
|
31
|
+
reading. The middle goes instead.
|
|
32
|
+
"""
|
|
33
|
+
if len(evidence_id) <= width:
|
|
34
|
+
return evidence_id
|
|
35
|
+
if width <= 3:
|
|
36
|
+
return evidence_id[:width]
|
|
37
|
+
keep_tail = max(width * 2 // 3, 1)
|
|
38
|
+
keep_head = width - keep_tail - 1
|
|
39
|
+
if keep_head <= 0:
|
|
40
|
+
return "…" + evidence_id[-(width - 1) :]
|
|
41
|
+
return evidence_id[:keep_head] + "…" + evidence_id[-keep_tail:]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def cite(
|
|
45
|
+
evidence_id: str,
|
|
46
|
+
resolves: bool = True,
|
|
47
|
+
width: int | None = None,
|
|
48
|
+
repo: str | None = None,
|
|
49
|
+
) -> Text:
|
|
50
|
+
"""One evidence id, styled by whether the provider can resolve it.
|
|
51
|
+
|
|
52
|
+
Inside a report about a single repository the `owner/name` in every id is
|
|
53
|
+
the same, and on a long slug it crowds out the part that differs. Passing
|
|
54
|
+
`repo` folds it to an ellipsis for display. The inspector always shows the
|
|
55
|
+
id in full, so nothing is hidden from anyone checking a claim.
|
|
56
|
+
"""
|
|
57
|
+
shown = evidence_id
|
|
58
|
+
if repo and repo in shown:
|
|
59
|
+
shown = shown.replace(repo, "…", 1)
|
|
60
|
+
if width:
|
|
61
|
+
shown = elide(shown, width)
|
|
62
|
+
style = f"{theme.CITE} underline" if resolves else f"{theme.DROP} strike"
|
|
63
|
+
return Text(shown, style=style, no_wrap=True, overflow="ellipsis")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class EvidenceDetail(Widget):
|
|
67
|
+
"""The record an id resolves to: where it came from, when, and what it says.
|
|
68
|
+
|
|
69
|
+
Renders whatever payload the record carries rather than a fixed set of
|
|
70
|
+
fields, because payloads differ by source and a hardcoded list would quietly
|
|
71
|
+
hide anything new.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, record: Any, **kwargs) -> None:
|
|
75
|
+
super().__init__(**kwargs)
|
|
76
|
+
self.record = record
|
|
77
|
+
|
|
78
|
+
def compose(self) -> ComposeResult:
|
|
79
|
+
record = self.record
|
|
80
|
+
if record is None:
|
|
81
|
+
yield Line(
|
|
82
|
+
Text(
|
|
83
|
+
"This id does not resolve. Stage D removed the claim that cited it.",
|
|
84
|
+
style=theme.DROP,
|
|
85
|
+
),
|
|
86
|
+
classes="empty",
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
yield Line(cite(record.evidence_id))
|
|
91
|
+
yield Line(
|
|
92
|
+
Text(f"{record.source} · {record.timestamp.isoformat()}", style=theme.FAINT),
|
|
93
|
+
classes="record-meta",
|
|
94
|
+
)
|
|
95
|
+
yield Line(
|
|
96
|
+
Text(record.url, style=theme.FAINT, no_wrap=True, overflow="ellipsis"),
|
|
97
|
+
classes="record-meta",
|
|
98
|
+
)
|
|
99
|
+
yield Line("")
|
|
100
|
+
for key, value in record.payload.items():
|
|
101
|
+
yield Line(_field(key, value))
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _noop() -> None: # pragma: no cover - placeholder for future actions
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _field(key: str, value: Any) -> Text:
|
|
109
|
+
text = Text()
|
|
110
|
+
text.append(f"{key:<17}", style=theme.DIM)
|
|
111
|
+
if isinstance(value, list):
|
|
112
|
+
for i, item in enumerate(value[:8]):
|
|
113
|
+
if i:
|
|
114
|
+
text.append("\n" + " " * 17)
|
|
115
|
+
text.append(str(item))
|
|
116
|
+
if len(value) > 8:
|
|
117
|
+
text.append("\n" + " " * 17)
|
|
118
|
+
text.append(f"+{len(value) - 8} more", style=theme.FAINT)
|
|
119
|
+
else:
|
|
120
|
+
text.append(str(value))
|
|
121
|
+
return text
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""The cat, and the masthead it leads.
|
|
2
|
+
|
|
3
|
+
Two widgets. `Cat` is one line that can be dropped anywhere — a masthead, a
|
|
4
|
+
status bar, a chrome row — and changes mood when the run does. `Masthead` is the
|
|
5
|
+
home screen's opening: the cat, the tool's own description of itself, and the
|
|
6
|
+
three facts worth knowing before you type.
|
|
7
|
+
|
|
8
|
+
The launch reveal is deliberately short. It exists to make the screen feel like
|
|
9
|
+
it arrived rather than blinked into being, and it is over in about a quarter of
|
|
10
|
+
a second. It never takes the keyboard: the input is focused from the first
|
|
11
|
+
frame, so typing straight over the top of it works and the animation simply
|
|
12
|
+
stops. An intro you have to sit through is a worse thing than no intro.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from textual.app import ComposeResult
|
|
19
|
+
from textual.containers import Horizontal, Vertical
|
|
20
|
+
from textual.widget import Widget
|
|
21
|
+
|
|
22
|
+
from holt.tui import animation, mascot, theme
|
|
23
|
+
from holt.tui.visual import Line
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _version() -> str:
|
|
27
|
+
"""The installed version, or nothing. Never a guess and never a crash."""
|
|
28
|
+
try:
|
|
29
|
+
from importlib.metadata import version
|
|
30
|
+
|
|
31
|
+
return version("holt")
|
|
32
|
+
except Exception: # noqa: BLE001 - a masthead is not worth an exception
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Cat(Widget):
|
|
37
|
+
"""One line of cat. Blinks at rest, looks about while a run is going."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, mood: str = "idle", **kwargs) -> None:
|
|
40
|
+
super().__init__(**kwargs)
|
|
41
|
+
self._mood = mood
|
|
42
|
+
self._tick = 0
|
|
43
|
+
self._timer = None
|
|
44
|
+
|
|
45
|
+
def on_mount(self) -> None:
|
|
46
|
+
self._paint()
|
|
47
|
+
if animation.enabled():
|
|
48
|
+
self._timer = self.set_interval(mascot.TICK, self._advance)
|
|
49
|
+
|
|
50
|
+
def on_unmount(self) -> None:
|
|
51
|
+
self._stop()
|
|
52
|
+
|
|
53
|
+
def _stop(self) -> None:
|
|
54
|
+
if self._timer is not None:
|
|
55
|
+
self._timer.stop()
|
|
56
|
+
self._timer = None
|
|
57
|
+
|
|
58
|
+
def set_mood(self, mood: str) -> None:
|
|
59
|
+
if mood == self._mood:
|
|
60
|
+
return
|
|
61
|
+
self._mood = mood
|
|
62
|
+
# Restart the cycle so a mood change reads immediately rather than
|
|
63
|
+
# arriving part-way through the previous one's blink.
|
|
64
|
+
self._tick = 0
|
|
65
|
+
self._paint()
|
|
66
|
+
|
|
67
|
+
def _advance(self) -> None:
|
|
68
|
+
self._tick += 1
|
|
69
|
+
self._paint()
|
|
70
|
+
|
|
71
|
+
def _paint(self) -> None:
|
|
72
|
+
cycle = mascot.frames(self._mood)
|
|
73
|
+
frame = cycle[self._tick % len(cycle)] if animation.enabled() else cycle[0]
|
|
74
|
+
self.update(Text(frame, style=self.app.accent))
|
|
75
|
+
|
|
76
|
+
def update(self, renderable) -> None:
|
|
77
|
+
# `Cat` is a plain Widget so it can own a timer; rendering goes through
|
|
78
|
+
# the same conversion every other line uses.
|
|
79
|
+
self._renderable = renderable
|
|
80
|
+
self.refresh()
|
|
81
|
+
|
|
82
|
+
def render(self):
|
|
83
|
+
from holt.tui.visual import visual
|
|
84
|
+
|
|
85
|
+
return visual(getattr(self, "_renderable", ""))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Masthead(Widget):
|
|
89
|
+
def compose(self) -> ComposeResult:
|
|
90
|
+
with Horizontal(id="masthead"):
|
|
91
|
+
yield Cat(id="cat")
|
|
92
|
+
with Vertical(id="masthead-text"):
|
|
93
|
+
title = Text("holt", style="bold")
|
|
94
|
+
if _version():
|
|
95
|
+
# Not a bare repeat of the title bar two lines above: the
|
|
96
|
+
# version is the one thing here the chrome does not say.
|
|
97
|
+
title.append(f" {_version()}", style=theme.FAINT)
|
|
98
|
+
yield Line(title, id="masthead-title", classes="masthead-name")
|
|
99
|
+
yield Line(
|
|
100
|
+
Text(mascot.TAGLINE, style=theme.DIM), id="masthead-tagline"
|
|
101
|
+
)
|
|
102
|
+
yield Line("")
|
|
103
|
+
for index, fact in enumerate(mascot.FACTS):
|
|
104
|
+
yield Line(
|
|
105
|
+
Text(f"· {fact}", style=theme.FAINT),
|
|
106
|
+
id=f"masthead-fact-{index}",
|
|
107
|
+
classes="masthead-fact",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def on_mount(self) -> None:
|
|
111
|
+
"""Reveal, briefly. Everything is already on screen and readable if
|
|
112
|
+
motion is off, so this only ever adds, never gates."""
|
|
113
|
+
if not animation.enabled():
|
|
114
|
+
return
|
|
115
|
+
for index, widget in enumerate(self.query(Line)):
|
|
116
|
+
animation.reveal(widget, delay=animation.stagger(index))
|
|
117
|
+
|
|
118
|
+
def set_mood(self, mood: str) -> None:
|
|
119
|
+
try:
|
|
120
|
+
self.query_one("#cat", Cat).set_mood(mood)
|
|
121
|
+
except Exception: # noqa: BLE001 - the cat is never load-bearing
|
|
122
|
+
pass
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""The list of assessments you already have.
|
|
2
|
+
|
|
3
|
+
Each row answers the three questions you actually have when you reopen the tool:
|
|
4
|
+
what did it say, how old is it, and how was it produced. The verdict carries the
|
|
5
|
+
only colour, because it is the only thing on the row you might act on.
|
|
6
|
+
|
|
7
|
+
Age is shown on every row without exception. A stored assessment that does not
|
|
8
|
+
say how old it is, is indistinguishable from a fresh one, and this list exists
|
|
9
|
+
precisely so that nothing has to be re-run to be re-read.
|
|
10
|
+
|
|
11
|
+
Runs still in flight appear in the same list, above the stored ones, marked and
|
|
12
|
+
updating in place. They belong here rather than on a screen of their own for the
|
|
13
|
+
same reason the stored ones do: the question on opening holt is "what is going
|
|
14
|
+
on with the things I care about", and an answer split across two screens is one
|
|
15
|
+
you have to go looking for. The gutter is two columns wide on every row so the
|
|
16
|
+
two kinds line up under each other instead of drifting apart.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
from textual.widgets import ListItem, ListView
|
|
25
|
+
|
|
26
|
+
from holt.report import VERDICT_HEADLINES, Verdict
|
|
27
|
+
from holt.tui import store, theme
|
|
28
|
+
from holt.tui.visual import Line
|
|
29
|
+
from holt.tui.widgets.scrolling import KeepsHighlightVisible
|
|
30
|
+
|
|
31
|
+
#: Every row starts with this many columns, so a running row and a stored one
|
|
32
|
+
#: put their repository names in the same place.
|
|
33
|
+
GUTTER = 2
|
|
34
|
+
|
|
35
|
+
#: The mark on a run in flight. A filled dot rather than a spinner: the row is
|
|
36
|
+
#: one line in a list, and a list of spinners is a light show.
|
|
37
|
+
RUNNING_MARK = "●"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
#: Wide enough for the longest verdict the engine actually has, computed rather
|
|
41
|
+
#: than guessed. It was hardcoded to 22, and "Not enough evidence to say" is 26,
|
|
42
|
+
#: so that row rendered as "Not enough evidence to say8 hours ago".
|
|
43
|
+
HEADLINE_WIDTH = max(len(h) for h in VERDICT_HEADLINES.values()) + 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def headline(verdict: Verdict) -> str:
|
|
47
|
+
"""The engine's own words for a verdict, not a second vocabulary.
|
|
48
|
+
|
|
49
|
+
`holt.report` owns these. A verdict this build has not seen falls back to
|
|
50
|
+
its raw value rather than raising, so the list keeps working across an
|
|
51
|
+
engine that grows a fourth answer.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
return VERDICT_HEADLINES[verdict]
|
|
55
|
+
except (KeyError, TypeError):
|
|
56
|
+
return str(getattr(verdict, "value", verdict)).replace("_", " ")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RecentRow(ListItem):
|
|
60
|
+
def __init__(self, entry: Any, width_hint: int = 78, **kwargs) -> None:
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
self.entry = entry
|
|
63
|
+
self.width_hint = width_hint
|
|
64
|
+
|
|
65
|
+
def compose(self):
|
|
66
|
+
entry = self.entry
|
|
67
|
+
verdict = entry.assessment.verdict
|
|
68
|
+
value = getattr(verdict, "value", str(verdict))
|
|
69
|
+
|
|
70
|
+
row = Text()
|
|
71
|
+
row.append(" " * GUTTER)
|
|
72
|
+
row.append(f"{_repo(entry.repo):<32}", style="")
|
|
73
|
+
row.append(
|
|
74
|
+
f"{headline(verdict):<{HEADLINE_WIDTH}}",
|
|
75
|
+
style=theme.verdict_colour(value),
|
|
76
|
+
)
|
|
77
|
+
row.append(f"{store.describe_age(entry.age_seconds):<14}", style=theme.FAINT)
|
|
78
|
+
row.append(entry.mode, style=theme.FAINT)
|
|
79
|
+
yield Line(row)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _repo(repo: str, width: int = 31) -> str:
|
|
83
|
+
"""Elide the owner first, then the name. The name is what you recognise.
|
|
84
|
+
|
|
85
|
+
`Sistema-de-certificacion-academica/Sistema-de-certificacion-academica`
|
|
86
|
+
truncated from the right is all owner and no name, which is the one part
|
|
87
|
+
that distinguishes it from its neighbours.
|
|
88
|
+
"""
|
|
89
|
+
if len(repo) <= width:
|
|
90
|
+
return repo
|
|
91
|
+
owner, sep, name = repo.partition("/")
|
|
92
|
+
if not sep:
|
|
93
|
+
return repo[: width - 1] + "…"
|
|
94
|
+
if len(name) + 2 <= width:
|
|
95
|
+
return f"…/{name}"
|
|
96
|
+
return "…/" + name[: width - 3] + "…"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RunningRow(ListItem):
|
|
100
|
+
"""A run in flight: what it is doing, for how long, and what it has spent.
|
|
101
|
+
|
|
102
|
+
Redrawn in place rather than rebuilt, because a list that reconstructs
|
|
103
|
+
itself twice a second loses the highlight under the reader's cursor and
|
|
104
|
+
restarts every reveal animation on the rows below.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(self, session: Any, **kwargs) -> None:
|
|
108
|
+
super().__init__(**kwargs)
|
|
109
|
+
self.session = session
|
|
110
|
+
|
|
111
|
+
def compose(self):
|
|
112
|
+
yield Line(self.text(), classes="running-row")
|
|
113
|
+
|
|
114
|
+
def refresh_row(self) -> None:
|
|
115
|
+
try:
|
|
116
|
+
self.query_one(Line).update(self.text())
|
|
117
|
+
except Exception: # noqa: BLE001 - a row mid-unmount is not an error
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
def text(self) -> Text:
|
|
121
|
+
session = self.session
|
|
122
|
+
row = Text()
|
|
123
|
+
row.append(f"{RUNNING_MARK} ", style=theme.RAIL)
|
|
124
|
+
row.append(f"{_repo(session.options.repo):<32}", style="")
|
|
125
|
+
row.append(f"{status(session):<{HEADLINE_WIDTH}}", style=theme.DIM)
|
|
126
|
+
row.append(f"{elapsed(session.duration):<14}", style=theme.FAINT)
|
|
127
|
+
row.append(session.options.mode, style=theme.FAINT)
|
|
128
|
+
if session.cost_usd:
|
|
129
|
+
row.append(f" ${session.cost_usd:.4f}", style=theme.FAINT)
|
|
130
|
+
return row
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def status(session: Any) -> str:
|
|
134
|
+
"""What the run is doing, in the words the stage list already uses.
|
|
135
|
+
|
|
136
|
+
A stop that has been asked for but not yet landed says `stopping`, never
|
|
137
|
+
`stopped`: the worker is interrupted at its next model call, and a row that
|
|
138
|
+
claimed otherwise would be lying for as long as that call takes.
|
|
139
|
+
"""
|
|
140
|
+
if session.stopping:
|
|
141
|
+
return "stopping…"
|
|
142
|
+
stage = getattr(session, "stage", "")
|
|
143
|
+
return f"running · {stage}" if stage else "starting"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def elapsed(seconds: float) -> str:
|
|
147
|
+
minutes, whole = divmod(int(seconds), 60)
|
|
148
|
+
return f"{minutes}:{whole:02d}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RecentList(KeepsHighlightVisible, ListView):
|
|
152
|
+
def __init__(self, entries: list, **kwargs) -> None:
|
|
153
|
+
super().__init__(*[RecentRow(e) for e in entries], **kwargs)
|
|
154
|
+
self.entries = entries
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def selected(self) -> Any | None:
|
|
158
|
+
"""The stored assessment under the cursor, if that is what is there."""
|
|
159
|
+
return getattr(self.highlighted_child, "entry", None)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def selected_session(self) -> Any | None:
|
|
163
|
+
"""The run under the cursor, if the cursor is on a running row."""
|
|
164
|
+
return getattr(self.highlighted_child, "session", None)
|
|
165
|
+
|
|
166
|
+
def running_rows(self) -> list["RunningRow"]:
|
|
167
|
+
return [row for row in self.children if isinstance(row, RunningRow)]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Keeping the highlighted row where somebody can see it.
|
|
2
|
+
|
|
3
|
+
Every list in the interface is laid out the same way: a `ListView` at
|
|
4
|
+
`height: auto` inside an ancestor that scrolls. That is deliberate — the list
|
|
5
|
+
should be as tall as it has rows, and the page around it should scroll — but it
|
|
6
|
+
breaks the scrolling Textual gives a `ListView` for free. A `ListView` scrolls
|
|
7
|
+
*itself* to keep its cursor visible, and one sized to its own content has
|
|
8
|
+
nothing to scroll. So the highlight moved and the view did not: the cursor
|
|
9
|
+
walked off the bottom of the pane and kept going, invisibly.
|
|
10
|
+
|
|
11
|
+
It was worst on the finder, where the nine rejected candidates sit below the
|
|
12
|
+
sixteen survivors. Rejections are the interesting result, the screen says so in
|
|
13
|
+
its own docstring, and they were unreachable by keyboard — twenty presses of ↓
|
|
14
|
+
left the list index at 20 and the scroll offset at 0. The only way to read them
|
|
15
|
+
was a mouse wheel, in an interface whose whole claim is that it does not need
|
|
16
|
+
one.
|
|
17
|
+
|
|
18
|
+
The fix is to scroll the *ancestor*, which is what `scroll_visible` does. Mixed
|
|
19
|
+
in rather than repeated, because all three lists have the layout and so would
|
|
20
|
+
all three have the bug.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from textual.widgets import ListView
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KeepsHighlightVisible:
|
|
29
|
+
"""Scroll the highlighted row into view in whatever ancestor scrolls.
|
|
30
|
+
|
|
31
|
+
Mixed in *before* `ListView` so this handler runs on the widget itself.
|
|
32
|
+
Motion is off: the highlight is being driven by a key repeat, and animating
|
|
33
|
+
each step means the view lags several rows behind the cursor.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
37
|
+
if event.item is not None:
|
|
38
|
+
event.item.scroll_visible(animate=False)
|