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