holt-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. holt/__init__.py +0 -0
  2. holt/agent/__init__.py +0 -0
  3. holt/agent/entry.py +86 -0
  4. holt/agent/findings.py +49 -0
  5. holt/agent/landing.py +154 -0
  6. holt/agent/pipeline.py +244 -0
  7. holt/agent/progression.py +408 -0
  8. holt/agent/signals.py +220 -0
  9. holt/agent/stages.py +533 -0
  10. holt/agent/verdict.py +226 -0
  11. holt/agent/verify.py +140 -0
  12. holt/baseline.py +89 -0
  13. holt/baseline_matched.py +116 -0
  14. holt/cli.py +616 -0
  15. holt/discover.py +497 -0
  16. holt/evidence/__init__.py +3 -0
  17. holt/evidence/fixtures.py +154 -0
  18. holt/evidence/github_graphql.py +538 -0
  19. holt/evidence/provider.py +77 -0
  20. holt/evidence/redact.py +79 -0
  21. holt/issues.py +41 -0
  22. holt/model.py +516 -0
  23. holt/profile.py +126 -0
  24. holt/report.py +157 -0
  25. holt/tui/__init__.py +0 -0
  26. holt/tui/animation.py +84 -0
  27. holt/tui/app.py +294 -0
  28. holt/tui/clipboard.py +89 -0
  29. holt/tui/commands.py +134 -0
  30. holt/tui/discovery.py +305 -0
  31. holt/tui/env.py +49 -0
  32. holt/tui/events.py +245 -0
  33. holt/tui/mascot.py +121 -0
  34. holt/tui/models.py +590 -0
  35. holt/tui/observe.py +297 -0
  36. holt/tui/screens/__init__.py +35 -0
  37. holt/tui/screens/assessment.py +337 -0
  38. holt/tui/screens/confirm.py +62 -0
  39. holt/tui/screens/discover.py +444 -0
  40. holt/tui/screens/home.py +519 -0
  41. holt/tui/screens/inspector.py +106 -0
  42. holt/tui/screens/live.py +335 -0
  43. holt/tui/screens/models.py +393 -0
  44. holt/tui/screens/next_steps.py +425 -0
  45. holt/tui/screens/profile.py +129 -0
  46. holt/tui/session.py +711 -0
  47. holt/tui/store.py +458 -0
  48. holt/tui/theme.py +479 -0
  49. holt/tui/visual.py +33 -0
  50. holt/tui/widgets/__init__.py +0 -0
  51. holt/tui/widgets/candidates.py +78 -0
  52. holt/tui/widgets/claims.py +59 -0
  53. holt/tui/widgets/disclosure.py +121 -0
  54. holt/tui/widgets/evidence.py +121 -0
  55. holt/tui/widgets/masthead.py +122 -0
  56. holt/tui/widgets/recent.py +167 -0
  57. holt/tui/widgets/scrolling.py +38 -0
  58. holt/tui/widgets/stages.py +232 -0
  59. holt/types.py +48 -0
  60. holt_cli-0.1.0.dist-info/METADATA +198 -0
  61. holt_cli-0.1.0.dist-info/RECORD +65 -0
  62. holt_cli-0.1.0.dist-info/WHEEL +4 -0
  63. holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
  64. holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  65. holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/tui/observe.py ADDED
@@ -0,0 +1,297 @@
1
+ """Watching a run without changing it.
2
+
3
+ `pipeline.analyze` takes a provider and a model client and knows nothing about
4
+ being observed. Rather than thread a callback through the engine — which would
5
+ put a UI concern in the reproduction path and give the eval harness a parameter
6
+ it has no use for — these two classes *wrap* the objects the pipeline is handed.
7
+
8
+ Both are pure delegates. They forward every attribute to the object they wrap,
9
+ add no behaviour to it, and never mutate what passes through. Removing them from
10
+ the call gives the byte-identical run the CLI performs, which is what keeps
11
+ `holt tui` from becoming a second way of running a stage.
12
+
13
+ The one place this file mirrors the engine is `_findings_from`, which reads a
14
+ stage's structured response so the live view can show a claim appear *before*
15
+ Stage D decides whether it survives. That mirroring is deliberate and pinned by
16
+ `tests/test_tui_observe.py`, which replays real recorded trajectories and fails
17
+ if a key this file expects has stopped being produced. A silent UI is worse than
18
+ a loud test.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ from collections.abc import Callable
25
+ from typing import Any
26
+
27
+ from holt.agent.stages import normalise_citation
28
+ from holt.tui import events
29
+
30
+ Emit = Callable[[events.Event], None]
31
+
32
+ #: Asked before every model call and every evidence read. Returns True once the
33
+ #: reader has stopped the run.
34
+ Cancelled = Callable[[], bool]
35
+
36
+
37
+ class RunCancelled(Exception):
38
+ """Raised out of a wrapper when the reader has stopped the run.
39
+
40
+ A Python thread cannot be killed, so stopping is cooperative: the run is
41
+ interrupted at the next point it asks for something. These wrappers already
42
+ sit at every one of those points — a stage cannot call a model or read
43
+ evidence without passing through them — so the engine needs no cancellation
44
+ parameter and `holt.agent` stays unaware that an interface exists.
45
+
46
+ The cost of that choice is latency, not correctness: a stop lands when the
47
+ call in flight returns, which is instant on a replay and up to a model
48
+ timeout on a live run. The interface says `stopping` until then rather than
49
+ claiming the run has already ended.
50
+ """
51
+
52
+
53
+ class ObservingModel:
54
+ """A `ModelClient` that narrates its own calls.
55
+
56
+ Wraps rather than subclasses, so it works over `OpenAIModel`, `ReplayModel`,
57
+ or anything else satisfying the protocol, without knowing which it has.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ inner: Any,
63
+ emit: Emit,
64
+ repo: str = "",
65
+ cancelled: Cancelled | None = None,
66
+ ) -> None:
67
+ self._inner = inner
68
+ self._emit = emit
69
+ self._repo = repo
70
+ self._cancelled = cancelled or (lambda: False)
71
+
72
+ def __getattr__(self, name: str) -> Any:
73
+ # `replayed`, `usage`, and anything the client grows later.
74
+ return getattr(self._inner, name)
75
+
76
+ def complete(self, *, label: str, system: str, prompt: str, schema: dict) -> dict:
77
+ # Checked *before* the call and before `StageStarted`, so a stopped run
78
+ # neither pays for a stage nor shows one beginning. Raising inside the
79
+ # try below would additionally emit `RunFailed`, reporting a deliberate
80
+ # stop as a defect.
81
+ if self._cancelled():
82
+ raise RunCancelled(label)
83
+ model_name = _model_name(self._inner, label)
84
+ self._emit(events.StageStarted(stage=label, model=model_name))
85
+ started = time.monotonic()
86
+ try:
87
+ result = self._inner.complete(
88
+ label=label, system=system, prompt=prompt, schema=schema
89
+ )
90
+ except Exception as exc: # noqa: BLE001 - reported, then re-raised unchanged
91
+ self._emit(events.RunFailed(error=f"{label}: {type(exc).__name__}: {exc}"))
92
+ raise
93
+ elapsed = time.monotonic() - started
94
+
95
+ self._emit(events.ToolResponse(stage=label, payload=result))
96
+ usage = getattr(self._inner, "usage", None)
97
+ if usage is not None:
98
+ self._emit(
99
+ events.UsageUpdated(
100
+ input_tokens=usage.input_tokens,
101
+ output_tokens=usage.output_tokens,
102
+ cost_usd=usage.cost_usd,
103
+ )
104
+ )
105
+ for finding in _findings_from(label, result, self._repo):
106
+ self._emit(finding)
107
+ self._emit(
108
+ events.StageFinished(
109
+ stage=label, seconds=elapsed, summary=_summarise(label, result)
110
+ )
111
+ )
112
+ return result
113
+
114
+
115
+ class ObservingProvider:
116
+ """An `EvidenceProvider` that reports what was read and what resolved.
117
+
118
+ Stage D's drop is not a special hook: it is `resolve()` returning `None`.
119
+ Watching resolution is therefore enough to see the pipeline's central
120
+ moment, with nothing added to the engine to expose it.
121
+ """
122
+
123
+ def __init__(self, inner: Any, emit: Emit, cancelled: Cancelled | None = None) -> None:
124
+ self._inner = inner
125
+ self._emit = emit
126
+ self._cancelled = cancelled or (lambda: False)
127
+
128
+ def __getattr__(self, name: str) -> Any:
129
+ return getattr(self._inner, name)
130
+
131
+ @property
132
+ def inner(self) -> Any:
133
+ """The provider being watched, for a read that must not be narrated.
134
+
135
+ One caller: `Session.evidence_for_storage`, which looks the report's own
136
+ ids up again after the run has finished so the records can be stored
137
+ with it. Those reads are not part of the run, and going through
138
+ `resolve` below would append them to the trace — a stage D that appears
139
+ to have resolved the same ids twice.
140
+ """
141
+ return self._inner
142
+
143
+ def fetch(self, request: str, /, **params: object) -> list:
144
+ if self._cancelled():
145
+ raise RunCancelled(f"fetch {request}")
146
+ records = self._inner.fetch(request, **params)
147
+ window = getattr(getattr(self._inner, "window", None), "value", "")
148
+ self._emit(
149
+ events.EvidenceLoaded(
150
+ count=len(records),
151
+ window=window,
152
+ cutoff=getattr(self._inner, "cutoff", None),
153
+ )
154
+ )
155
+ return records
156
+
157
+ def resolve(self, evidence_id: str):
158
+ if self._cancelled():
159
+ raise RunCancelled(f"resolve {evidence_id}")
160
+ record = self._inner.resolve(evidence_id)
161
+ self._emit(
162
+ events.EvidenceResolved(evidence_id=evidence_id, resolved=record is not None)
163
+ )
164
+ return record
165
+
166
+
167
+ # ─── reading a stage response ───────────────────────────────────────────────
168
+
169
+
170
+ def _model_name(inner: Any, label: str) -> str:
171
+ try:
172
+ from holt.model import model_for
173
+
174
+ return model_for(label)
175
+ except Exception: # noqa: BLE001 - a display string is never worth raising over
176
+ return ""
177
+
178
+
179
+ def _summarise(label: str, result: dict) -> str:
180
+ """The one-line status that sits on the stage's own row."""
181
+ if label == "classify":
182
+ return str(result.get("repo_kind", ""))
183
+ if label == "opportunity":
184
+ return str(result.get("onboarding", ""))
185
+ if label == "outcomes":
186
+ threads = result.get("threads", []) or []
187
+ posture = result.get("posture", "")
188
+ return f"{posture} · {len(threads)} threads read" if posture else ""
189
+ if label == "narrate":
190
+ # `narrate` returns three sections now, not one `summary`. The count
191
+ # that matters to a watcher is how much prose came back.
192
+ written = sum(
193
+ len(result.get(key, ""))
194
+ for key in (
195
+ "bottom_line",
196
+ "what_the_evidence_shows",
197
+ "what_could_not_be_determined",
198
+ )
199
+ )
200
+ return f"{written} characters"
201
+ if label == "pathfinder":
202
+ issues = result.get("ranked", result.get("issues", [])) or []
203
+ return f"{len(issues)} issues ranked"
204
+ return ""
205
+
206
+
207
+ def _findings_from(label: str, result: dict, repo: str) -> list[events.FindingEmitted]:
208
+ """The claims a stage is about to record, read off its structured response.
209
+
210
+ Mirrors `holt.agent.stages`. Every lookup is tolerant: a missing key yields
211
+ fewer events, never an exception, because a run must not fail because
212
+ something was watching it.
213
+ """
214
+ out: list[events.FindingEmitted] = []
215
+
216
+ def cite(raw) -> tuple[str, ...]:
217
+ try:
218
+ return tuple(normalise_citation(repo, e) for e in (raw or ()))
219
+ except Exception: # noqa: BLE001
220
+ return tuple(str(e) for e in (raw or ()))
221
+
222
+ if label == "classify":
223
+ if "repo_kind" in result:
224
+ out.append(
225
+ events.FindingEmitted(
226
+ stage=label,
227
+ field="repo_kind",
228
+ value=result["repo_kind"],
229
+ evidence_ids=cite(result.get("evidence_ids")),
230
+ note=result.get("rationale", ""),
231
+ )
232
+ )
233
+ flags = [f for f in result.get("governance_flags", []) or [] if f != "none"]
234
+ if flags:
235
+ out.append(
236
+ events.FindingEmitted(
237
+ stage=label,
238
+ field="governance_flags",
239
+ value=flags,
240
+ evidence_ids=cite(result.get("evidence_ids")),
241
+ )
242
+ )
243
+
244
+ elif label == "opportunity":
245
+ if "onboarding" in result:
246
+ out.append(
247
+ events.FindingEmitted(
248
+ stage=label,
249
+ field="onboarding",
250
+ value=result["onboarding"],
251
+ evidence_ids=cite(result.get("evidence_ids")),
252
+ note=result.get("rationale", ""),
253
+ )
254
+ )
255
+
256
+ elif label == "outcomes":
257
+ threads = result.get("threads", []) or []
258
+ if "posture" in result:
259
+ out.append(
260
+ events.FindingEmitted(
261
+ stage=label,
262
+ field="outsider_posture",
263
+ value=result["posture"],
264
+ evidence_ids=cite([t.get("pr_id") for t in threads]),
265
+ note=result.get("posture_rationale", ""),
266
+ )
267
+ )
268
+ for entry in threads:
269
+ out.append(
270
+ events.FindingEmitted(
271
+ stage=label,
272
+ field="thread_outcome",
273
+ value={
274
+ "outcome": entry.get("outcome", ""),
275
+ "signal": entry.get("signal", ""),
276
+ "quote": entry.get("quote", ""),
277
+ },
278
+ evidence_ids=cite([entry.get("pr_id")]),
279
+ )
280
+ )
281
+
282
+ return out
283
+
284
+
285
+ #: The response keys the live view reads, by stage. `tests/test_tui_observe.py`
286
+ #: replays recorded trajectories and asserts each one still appears, so a change
287
+ #: to a stage's schema breaks a test rather than emptying a panel.
288
+ EXPECTED_KEYS: dict[str, tuple[str, ...]] = {
289
+ "classify": ("repo_kind", "rationale", "evidence_ids"),
290
+ "opportunity": ("onboarding", "rationale", "evidence_ids"),
291
+ "outcomes": ("posture", "posture_rationale", "threads"),
292
+ "narrate": (
293
+ "bottom_line",
294
+ "what_the_evidence_shows",
295
+ "what_could_not_be_determined",
296
+ ),
297
+ }
@@ -0,0 +1,35 @@
1
+ """Screen registry.
2
+
3
+ Adding a screen is a new file in this directory and one line in `REGISTRY`.
4
+ `app.py` reads this dict and never learns the names of individual screens, so
5
+ there is no switch statement to grow and no place where adding a view means
6
+ editing a view that already works.
7
+
8
+ Screens that need constructor arguments — the inspector takes an evidence id —
9
+ are pushed as instances instead, so they are deliberately not registered here.
10
+ Only screens that can be opened by name belong in this dict.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from textual.screen import Screen
16
+
17
+ from holt.tui.screens.discover import DiscoverScreen
18
+ from holt.tui.screens.home import HomeScreen
19
+ from holt.tui.screens.models import ModelsScreen
20
+ from holt.tui.screens.profile import ProfileScreen
21
+
22
+ REGISTRY: dict[str, type[Screen]] = {
23
+ "home": HomeScreen,
24
+ "discover": DiscoverScreen,
25
+ "profile": ProfileScreen,
26
+ "models": ModelsScreen,
27
+ }
28
+
29
+ __all__ = [
30
+ "REGISTRY",
31
+ "DiscoverScreen",
32
+ "HomeScreen",
33
+ "ModelsScreen",
34
+ "ProfileScreen",
35
+ ]
@@ -0,0 +1,337 @@
1
+ """The finished report, navigable.
2
+
3
+ The order is the engine's own: the answer, then the one-paragraph reason, then
4
+ what the evidence showed, then which rule decided it, then what could not be
5
+ determined, then where work actually landed, then the claims. That order is not
6
+ a layout preference — `Assessment.render()` uses it, and a reader who has seen
7
+ the markdown should find the same argument in the same sequence here.
8
+
9
+ Two things this screen refuses to do:
10
+
11
+ * **Bury what could not be determined.** It is a section with a heading, in the
12
+ same type as everything else, in the position the engine puts it. A tool that
13
+ hides its own limits into a footnote is doing something different from one
14
+ that states them.
15
+ * **Let a stored assessment pass as a fresh one.** If this came out of the
16
+ store, the age is in the chrome and re-running is one key.
17
+
18
+ Every field beyond `repo`, `verdict`, `summary` and `claims` is read through
19
+ `getattr` with a default. Those four are the contract; the rest the engine has
20
+ added over time, and a screen that hard-required them would break the next time
21
+ one moved.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import textwrap
27
+
28
+ from rich.text import Text
29
+ from textual.app import ComposeResult
30
+ from textual.containers import Horizontal, VerticalScroll
31
+ from textual.screen import Screen
32
+ from textual.widgets import Footer
33
+
34
+ from holt.report import VERDICT_HEADLINES
35
+ from holt.tui import animation, clipboard, mascot, store, theme
36
+ from holt.tui.visual import Line
37
+ from holt.tui.widgets.claims import ClaimList
38
+ from holt.tui.widgets.disclosure import EntryPointRow, MeasuredResult
39
+ from holt.tui.widgets.masthead import Cat
40
+
41
+
42
+ class AssessmentScreen(Screen):
43
+ BINDINGS = [
44
+ ("escape", "home", "home"),
45
+ ("enter", "inspect", "open evidence"),
46
+ ("c", "copy", "copy as markdown"),
47
+ ("ctrl+r", "rerun", "re-run"),
48
+ ("n", "next", "what next"),
49
+ ("t", "trace", "trace"),
50
+ ("q", "quit", "quit"),
51
+ ]
52
+
53
+ def compose(self) -> ComposeResult:
54
+ session = self.app.session
55
+ assessment = session.assessment
56
+ verdict = assessment.verdict
57
+
58
+ with Horizontal(id="chrome"):
59
+ yield Cat(
60
+ mascot.mood_for_verdict(verdict.value), id="cat", classes="chrome-cat"
61
+ )
62
+ yield Line("", id="chrome-left")
63
+ yield Line(self._provenance(), id="chrome-right")
64
+ yield Line("─" * 240, classes="rule")
65
+
66
+ with VerticalScroll(id="report"):
67
+ # The answer, in the engine's own words, at the size of a headline.
68
+ yield Line(
69
+ Text(_headline(verdict), style=theme.verdict_colour(verdict.value)),
70
+ id="verdict",
71
+ )
72
+ days = getattr(assessment, "contributor_days", 7)
73
+ yield Line(
74
+ Text(
75
+ f"for a contributor with {days} day{'' if days == 1 else 's'}",
76
+ style=theme.FAINT,
77
+ ),
78
+ id="verdict-budget",
79
+ )
80
+
81
+ bottom_line = getattr(assessment, "bottom_line", "")
82
+ if bottom_line:
83
+ yield Line(bottom_line, id="bottom-line")
84
+
85
+ if assessment.summary:
86
+ yield from _section("WHAT THE EVIDENCE SHOWS", assessment.summary)
87
+
88
+ rules = getattr(assessment, "rules", None)
89
+ if rules:
90
+ # The deterministic half, shown rather than described. This is
91
+ # what `verdict.py` decided; the prose above could not have
92
+ # changed it, and printing both is how you can tell.
93
+ yield Line(
94
+ Text("WHAT DECIDED IT", style=theme.DIM), classes="section-label"
95
+ )
96
+ for rule in rules:
97
+ yield Line(Text(_bullet(rule)), classes="rule-line")
98
+
99
+ limits = getattr(assessment, "limits", "")
100
+ if limits:
101
+ yield from _section("WHAT COULD NOT BE DETERMINED", limits)
102
+
103
+ landing = getattr(assessment, "landing", None)
104
+ if landing:
105
+ yield Line(
106
+ Text("WHERE WORK LANDED", style=theme.DIM), classes="section-label"
107
+ )
108
+ for line in landing:
109
+ # The engine emits a markdown heading because its primary
110
+ # output is a file. Here the section already has a label,
111
+ # and printing both says the same thing twice.
112
+ if line.strip() and not line.lstrip().startswith("#"):
113
+ yield Line(_plain(line), classes="landing-line")
114
+
115
+ count = len(assessment.claims)
116
+ binned = getattr(assessment, "dropped_claims", 0)
117
+ if count:
118
+ header = Text(
119
+ f"EVIDENCE {count} {'claim' if count == 1 else 'claims'}, "
120
+ "every one carrying an id that resolved and words the "
121
+ "thread actually said",
122
+ style=theme.DIM,
123
+ )
124
+ else:
125
+ # "0 claims, every one carrying an id that resolved" is a
126
+ # sentence about nothing. What a reader needs here is that the
127
+ # prose above them is standing on its own.
128
+ header = Text(
129
+ f"EVIDENCE none survived verification"
130
+ + (f" — all {binned} dropped" if binned else ""),
131
+ style=theme.DROP if binned else theme.DIM,
132
+ )
133
+ yield Line(header, classes="section-label")
134
+ if count:
135
+ # How to reach the list without a mouse, next to the list. ↑↓
136
+ # here scroll the report, which is what they should do while you
137
+ # are reading it; tab is what moves into the claims, and saying
138
+ # so is the difference between a list you can open and one you
139
+ # cannot. Omitted when nothing survived: there is nothing to
140
+ # step into, and the line above already says why.
141
+ yield Line(
142
+ Text(
143
+ "tab to step into them ↑↓ pick one "
144
+ "enter opens its record",
145
+ style=theme.FAINT,
146
+ ),
147
+ classes="field-note",
148
+ )
149
+ yield ClaimList(assessment.claims, repo=assessment.repo, id="claims")
150
+
151
+ # Only rendered when the engine supplied it. The ranking is opt-in
152
+ # now, and nothing about this screen assumes the field exists.
153
+ if getattr(assessment, "entry_points", None):
154
+ yield Line(
155
+ Text("WHERE TO START", style=theme.DIM), classes="section-label"
156
+ )
157
+ yield MeasuredResult()
158
+ for i, point in enumerate(assessment.entry_points, 1):
159
+ yield EntryPointRow(i, point)
160
+
161
+ yield Line(
162
+ Text(f"method {assessment.method}", style=theme.FAINT),
163
+ classes="section-label",
164
+ )
165
+ # The model-written sections are only as good as the model behind
166
+ # them, while the counts and the verdict are not model-derived at
167
+ # all. Naming the model is what lets a reader — or a screenshot —
168
+ # tell those two halves apart afterwards.
169
+ models = getattr(assessment, "models", None)
170
+ if models:
171
+ yield Line(
172
+ Text(f"model {', '.join(models)}", style=theme.FAINT),
173
+ classes="section-label",
174
+ )
175
+ yield Line("─" * 240, classes="rule")
176
+ # Hidden until a key has something to report. It sits below the rule
177
+ # rather than in the chrome because what it has to say — which
178
+ # clipboard mechanism ran, and whether that mechanism can be trusted —
179
+ # is a sentence, and the chrome is a column shared with the provenance.
180
+ yield Line("", id="report-notice", classes="field-note")
181
+ yield Footer()
182
+
183
+ def on_mount(self) -> None:
184
+ self.query_one("#report-notice", Line).display = False
185
+ for index, child in enumerate(self.query_one("#report", VerticalScroll).children):
186
+ animation.reveal(child, delay=animation.stagger(index))
187
+
188
+ def _provenance(self) -> Text:
189
+ """Where this result came from, and how old it is. Never omitted."""
190
+ session = self.app.session
191
+ text = Text()
192
+ text.append(f"{session.assessment.repo} ", style=theme.FAINT)
193
+ stored = session.restored_from
194
+ if stored is not None:
195
+ text.append(
196
+ f"{store.describe_age(stored.age_seconds)} · {stored.mode}",
197
+ style=theme.DIM,
198
+ )
199
+ text.append(" ctrl+r to re-run", style=theme.FAINT)
200
+ else:
201
+ text.append(session.options.mode, style=theme.FAINT)
202
+ if session.cost_usd:
203
+ text.append(f" ${session.cost_usd:.4f}", style=theme.FAINT)
204
+ return text
205
+
206
+ def _notice(self, message: str, tone: str = "") -> None:
207
+ """One line under the report, for what a key just did."""
208
+ widget = self.query_one("#report-notice", Line)
209
+ widget.update(Text(message, style=tone or theme.FAINT))
210
+ widget.display = bool(message)
211
+ animation.reveal(widget)
212
+
213
+ # ─── actions ────────────────────────────────────────────────────────────
214
+
215
+ def on_list_view_selected(self, event) -> None:
216
+ """Enter, when the claims themselves have focus.
217
+
218
+ The screen's own `enter` binding only fires while focus is elsewhere.
219
+ Once you tab into the list, `ListView` takes the key and posts this
220
+ instead — without a handler that is a list you can move around in and
221
+ never open.
222
+ """
223
+ claim = getattr(event.item, "claim", None)
224
+ if claim is not None and claim.evidence_id:
225
+ self.app.inspect(claim.evidence_id)
226
+
227
+ def action_inspect(self) -> None:
228
+ claim = self.query_one("#claims", ClaimList).selected
229
+ if claim is None or not claim.evidence_id:
230
+ return
231
+ self.app.inspect(claim.evidence_id)
232
+
233
+ def action_copy(self) -> None:
234
+ """The report as markdown, on the clipboard.
235
+
236
+ `Assessment.render()` — the same text `holt assess` writes to a file, so
237
+ what you paste into an issue is the artefact and not a transcription of
238
+ the screen. What actually reached the clipboard is reported, because
239
+ copying out of a terminal can silently fail.
240
+ """
241
+ markdown = self.app.session.assessment.render()
242
+ # Counted before the f-string: a backslash inside one is a syntax error
243
+ # on 3.11, which this project still supports.
244
+ lines = len(markdown.splitlines())
245
+ detail = clipboard.copy(self.app, markdown)
246
+ self._notice(f"{detail} {lines} lines.")
247
+
248
+ def action_rerun(self) -> None:
249
+ from holt.tui.session import RunOptions
250
+
251
+ session = self.app.session
252
+ self.app.start_run(
253
+ RunOptions(
254
+ repo=session.assessment.repo,
255
+ replay=session.options.replay,
256
+ live=session.options.live,
257
+ contributor_days=session.options.contributor_days,
258
+ )
259
+ )
260
+
261
+ def action_next(self) -> None:
262
+ """Where someone who has already landed work here might look next."""
263
+ from holt.tui.screens.next_steps import NextScreen
264
+
265
+ # The mode travels with it, so the ranking reads the evidence the
266
+ # report in front of you was actually built from.
267
+ session = self.app.session
268
+ self.app.push_screen(
269
+ NextScreen(session.assessment.repo, live=session.options.live)
270
+ )
271
+
272
+ def action_trace(self) -> None:
273
+ """The run behind this report: what it read, emitted and dropped.
274
+
275
+ Three cases, and the middle one is the reason this is not a one-liner.
276
+ The run may still be underneath this screen, in which case the trace is
277
+ the screen you came from and going back to it is the whole action. An
278
+ assessment reopened out of the store has no screen underneath — but it
279
+ does carry the run's own event stream, which is stored with it, so the
280
+ trace is rebuilt from that rather than being silently unavailable. An
281
+ assessment stored before traces were kept has neither, and says so
282
+ instead of answering a keypress with nothing at all.
283
+ """
284
+ from holt.tui.screens.live import LiveScreen, TraceScreen
285
+
286
+ stack = self.app.screen_stack
287
+ below = stack[-2] if len(stack) > 2 else None
288
+ if isinstance(below, LiveScreen):
289
+ self.app.pop_screen()
290
+ return
291
+ if self.app.session.log:
292
+ self.app.push_screen(TraceScreen())
293
+ return
294
+ self._notice(
295
+ "No trace was stored with this assessment — it was produced before "
296
+ "holt kept them. Re-run it (ctrl+r) to watch one."
297
+ )
298
+
299
+ def action_home(self) -> None:
300
+ self.app.go_home()
301
+
302
+ def action_quit(self) -> None:
303
+ self.app.exit()
304
+
305
+
306
+ def _headline(verdict) -> str:
307
+ try:
308
+ return VERDICT_HEADLINES[verdict]
309
+ except (KeyError, TypeError):
310
+ return str(getattr(verdict, "value", verdict)).replace("_", " ")
311
+
312
+
313
+ def _section(label: str, body: str):
314
+ yield Line(Text(label, style=theme.DIM), classes="section-label")
315
+ yield Line(body, classes="prose")
316
+
317
+
318
+ def _bullet(text: str, width: int = 88) -> str:
319
+ """A bullet whose continuation lines sit under the text, not the mark.
320
+
321
+ A wrapped rule that starts again in the mark column reads as two rules.
322
+ """
323
+ return textwrap.fill(
324
+ " ".join(text.split()),
325
+ width=width,
326
+ initial_indent="· ",
327
+ subsequent_indent=" ",
328
+ )
329
+
330
+
331
+ def _plain(line: str) -> Text:
332
+ """Markdown from `holt.agent.landing`, shown as text.
333
+
334
+ The engine emits markdown because its primary output is a file. Rendering
335
+ the asterisks here would be showing the reader the engine's plumbing.
336
+ """
337
+ return Text(line.replace("**", "").replace("`", ""))