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/report.py ADDED
@@ -0,0 +1,157 @@
1
+ """The shared output shape.
2
+
3
+ The baseline solution and the full pipeline must produce the same thing, or the
4
+ comparison is not the same task. Both emit an Assessment; only the method of
5
+ arriving at one differs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime
12
+ from enum import Enum
13
+
14
+
15
+ class Verdict(str, Enum):
16
+ """Deliberately three-valued.
17
+
18
+ A repository nobody has tried to contribute to is not the same as one that
19
+ turns contributors away, and flattening them would hide the distinction the
20
+ whole project is about. Saying so is a valid answer, not a failure.
21
+ """
22
+
23
+ VIABLE = "viable"
24
+ NOT_VIABLE = "not_viable"
25
+ INSUFFICIENT_EVIDENCE = "insufficient_evidence"
26
+
27
+
28
+ # The verdict word alone tells a reader almost nothing. These say what it means
29
+ # for the decision they are actually making.
30
+ VERDICT_HEADLINES = {
31
+ Verdict.VIABLE: "Worth your time",
32
+ Verdict.NOT_VIABLE: "Not worth your time",
33
+ Verdict.INSUFFICIENT_EVIDENCE: "Not enough evidence to say",
34
+ }
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Claim:
39
+ """A statement with the evidence id that backs it.
40
+
41
+ Stage D drops any claim whose id does not resolve. A claim without a
42
+ resolvable id never reaches the reader.
43
+ """
44
+
45
+ text: str
46
+ evidence_id: str | None = None
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class EntryPoint:
51
+ """One suggested place to start, with the issue it points at.
52
+
53
+ Carried separately from `Claim` because it is a *suggestion* rather than a
54
+ statement of fact, and the two must not be rendered as if they had the same
55
+ standing. Claims are verified against evidence; this is a ranking whose
56
+ measured precision is printed next to it.
57
+ """
58
+
59
+ evidence_id: str
60
+ first_step: str
61
+ why: str = ""
62
+
63
+
64
+ @dataclass(slots=True)
65
+ class Assessment:
66
+ repo: str
67
+ verdict: Verdict
68
+ summary: str
69
+ claims: list[Claim] = field(default_factory=list)
70
+ method: str = "holt"
71
+ replayed: bool = False
72
+ entry_points: list[EntryPoint] = field(default_factory=list)
73
+ # Added, never replacing: `summary` still holds the prose. A reader deciding
74
+ # where to spend a week needs the answer before the reasoning, and the rules
75
+ # are printed because "which rule fired" is a question a chat answer cannot
76
+ # be asked.
77
+ bottom_line: str = ""
78
+ limits: str = ""
79
+ rules: list[str] = field(default_factory=list)
80
+ contributor_days: int = 7
81
+ # Rendered markdown lines from `holt.agent.landing`. Arithmetic over the file
82
+ # lists we already crawl; the model never sees or writes this section.
83
+ landing: list[str] = field(default_factory=list)
84
+ # The date evidence was cut at. Stated in the output because a reader cannot
85
+ # otherwise tell a quiet repository from one whose recent months were excluded.
86
+ as_of: datetime | None = None
87
+ # How many claims were removed before the reader saw them, by either check
88
+ # in Stage D. Carried so the report can tell two situations apart that look
89
+ # identical on the page: a run that found nothing to say, and a run whose
90
+ # every statement was thrown out. `psf/requests` was the second one, and it
91
+ # read like the first.
92
+ dropped_claims: int = 0
93
+ # The models that actually answered, in first-use order. Printed because the
94
+ # model-written sections degrade with the model behind them while the counts
95
+ # and the verdict do not, and a report that does not name its model leaves a
96
+ # reader unable to tell those two halves apart. On a replay these are the ids
97
+ # from the recording.
98
+ models: list[str] = field(default_factory=list)
99
+
100
+ def render(self) -> str:
101
+ lines = [f"# {self.repo}", ""]
102
+ if self.replayed:
103
+ lines += [
104
+ "> Replaying recorded model output. No model was called for this run.",
105
+ "",
106
+ ]
107
+ budget = f"for a contributor with {self.contributor_days} day"
108
+ budget += "" if self.contributor_days == 1 else "s"
109
+ lines += [f"**{VERDICT_HEADLINES[self.verdict]}** — {budget}.", ""]
110
+ if self.as_of:
111
+ lines += [f"*Evidence up to {self.as_of.date().isoformat()}.*", ""]
112
+ if not self.claims and self.dropped_claims:
113
+ # Above the prose, not below it. A reader who is told at the bottom
114
+ # of the page that nothing on it was backed has already read it.
115
+ lines += [
116
+ f"> **No claim in this report survived verification.** All "
117
+ f"{self.dropped_claims} were dropped: the evidence cited did not "
118
+ "resolve, or the thread did not say what the claim said. The "
119
+ "verdict and the counts below are computed without a model and "
120
+ "still stand; nothing written in prose here is backed by a "
121
+ "record you can check.",
122
+ "",
123
+ ]
124
+ if self.bottom_line:
125
+ lines += [self.bottom_line, ""]
126
+ if self.summary:
127
+ lines += ["## What the evidence shows", "", self.summary, ""]
128
+ if self.rules:
129
+ # The deterministic part, shown rather than described. `verdict.py`
130
+ # decided this and the prose above could not have changed it.
131
+ lines += ["## What decided it", ""]
132
+ lines += [f"- {rule}" for rule in self.rules]
133
+ lines.append("")
134
+ if self.limits:
135
+ lines += ["## What could not be determined", "", self.limits, ""]
136
+ if self.landing:
137
+ lines += self.landing + [""]
138
+ if self.claims:
139
+ lines += ["## Evidence", ""]
140
+ for claim in self.claims:
141
+ where = f" — `{claim.evidence_id}`" if claim.evidence_id else ""
142
+ lines.append(f"- {claim.text}{where}")
143
+ if self.entry_points:
144
+ # The disclaimer is emitted by the renderer, not by the caller, so
145
+ # there is no code path that prints a ranking without the number that
146
+ # says how well it works. A test holds this.
147
+ from holt.agent.entry import DISCLAIMER
148
+
149
+ lines += ["", "## Where to start", "", DISCLAIMER, ""]
150
+ for point in self.entry_points:
151
+ lines.append(f"- **{point.first_step}** — `{point.evidence_id}`")
152
+ if point.why:
153
+ lines.append(f" {point.why}")
154
+ lines += ["", f"*{self.method}*"]
155
+ if self.models:
156
+ lines += [f"*Model output from {', '.join(self.models)}.*"]
157
+ return "\n".join(lines).rstrip() + "\n"
holt/tui/__init__.py ADDED
File without changes
holt/tui/animation.py ADDED
@@ -0,0 +1,84 @@
1
+ """Motion, kept to the two things it is actually good at.
2
+
3
+ The rule this module exists to enforce: **animation reports state, or it does
4
+ not run.** A spinner means work is happening right now and you cannot know how
5
+ long it will take. A reveal means this line is new since you last looked. There
6
+ is nothing here that decorates, pulses, bounces or draws attention to something
7
+ that has not changed.
8
+
9
+ That rules out the obvious temptation, a progress bar. The number of threads a
10
+ stage will read is not known until it has read them, so a bar would have to
11
+ invent a denominator, and a bar that fills at a rate unrelated to the work is a
12
+ lie told smoothly.
13
+
14
+ Everything can be switched off in one place. `HOLT_TUI_NO_ANIMATION=1` disables
15
+ motion for people who do not want it, for terminals that render it badly, and
16
+ for the tests — which assert on text and should not race a fade.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+
23
+ #: Braille dots. Eight frames, all the same width, no baseline jitter as it
24
+ #: turns — the reason this shape is used everywhere rather than an ASCII
25
+ #: pinwheel, which visibly wobbles.
26
+ SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧"
27
+
28
+ #: Fast enough to read as continuous, slow enough not to strobe.
29
+ SPINNER_INTERVAL = 0.08
30
+
31
+ #: A new line arrives at 40% opacity and comes up to full. Short: this is meant
32
+ #: to be noticed peripherally, not watched.
33
+ REVEAL_DURATION = 0.18
34
+ REVEAL_FROM = 0.4
35
+
36
+ #: Recent rows come in one after another rather than all at once, which makes a
37
+ #: list read as a list. Capped so a long history does not turn into a slow
38
+ #: cascade the user waits through.
39
+ STAGGER_STEP = 0.03
40
+ STAGGER_MAX = 0.18
41
+
42
+
43
+ def enabled() -> bool:
44
+ return os.environ.get("HOLT_TUI_NO_ANIMATION", "") in ("", "0", "false", "False")
45
+
46
+
47
+ def frame(tick: int) -> str:
48
+ return SPINNER_FRAMES[tick % len(SPINNER_FRAMES)]
49
+
50
+
51
+ def reveal(widget, delay: float = 0.0) -> None:
52
+ """Bring a freshly mounted widget up from dim.
53
+
54
+ Silent about failure on purpose. Motion is the least important thing on the
55
+ screen; if a widget is gone before its animation starts, that is not worth
56
+ an exception in an interface whose job is to render an assessment.
57
+ """
58
+ if not enabled():
59
+ return
60
+ try:
61
+ widget.styles.opacity = REVEAL_FROM
62
+ widget.styles.animate(
63
+ "opacity", value=1.0, duration=REVEAL_DURATION, delay=delay
64
+ )
65
+ except Exception: # noqa: BLE001 - never let decoration break a screen
66
+ try:
67
+ widget.styles.opacity = 1.0
68
+ except Exception: # noqa: BLE001
69
+ pass
70
+
71
+
72
+ def stagger(index: int) -> float:
73
+ """The delay for the nth item in a revealed list."""
74
+ return min(index * STAGGER_STEP, STAGGER_MAX)
75
+
76
+
77
+ def elapsed(seconds: float) -> str:
78
+ """A running clock that never claims more precision than it has."""
79
+ if seconds < 10:
80
+ return f"{seconds:.1f}s"
81
+ if seconds < 60:
82
+ return f"{seconds:.0f}s"
83
+ minutes, rest = divmod(int(seconds), 60)
84
+ return f"{minutes}m{rest:02d}s"
holt/tui/app.py ADDED
@@ -0,0 +1,294 @@
1
+ """The application shell.
2
+
3
+ It owns five things and nothing else: the screen registry, the global
4
+ keybindings, the store of past assessments, the runs currently in flight, and
5
+ the session being looked at. It contains no rendering and no engine knowledge,
6
+ so a new view is a new file plus one line in `holt.tui.screens.REGISTRY`.
7
+
8
+ Navigation is deliberately shallow. Home holds everything you have; a run or a
9
+ stored result is one push deep; the evidence behind a claim is two. There is no
10
+ state you can get into where the way back is unclear, because every screen binds
11
+ escape to the thing above it.
12
+
13
+ **A run belongs to the app, not to the screen watching it.** The pump that
14
+ drains every in-flight session lives here and ticks whether or not anyone is
15
+ looking, so walking away from a run leaves it running. It used to live on the
16
+ live screen, which meant popping that screen stopped the only thing consuming
17
+ the run's events: the worker thread carried on spending, its result was never
18
+ absorbed, and the assessment it produced was silently thrown away. A run now
19
+ ends for exactly one reason — it finished, it failed, or someone stopped it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from textual.app import App
25
+ from textual.await_complete import AwaitComplete
26
+ from textual.screen import Screen
27
+
28
+ from holt.cli import normalise
29
+ from holt.tui import mascot, store, theme
30
+ from holt.tui.commands import HoltCommands
31
+ from holt.tui.screens import REGISTRY
32
+ from holt.tui.screens.assessment import AssessmentScreen
33
+ from holt.tui.screens.confirm import ConfirmScreen
34
+ from holt.tui.screens.inspector import InspectorScreen
35
+ from holt.tui.screens.live import LiveScreen
36
+ from holt.tui.session import RunOptions, Session
37
+
38
+ #: How often the app absorbs what the running sessions have emitted. The same
39
+ #: interval the live screen redraws on: the screen renders what this has already
40
+ #: collected, so a slower pump here would show as a laggy stream.
41
+ PUMP_SECONDS = 0.05
42
+
43
+
44
+ class HoltApp(App):
45
+ CSS = theme.CSS
46
+ SCREENS = REGISTRY
47
+ TITLE = "holt"
48
+
49
+ #: One provider, not a union with `App.COMMANDS`. It yields holt's commands
50
+ #: and then the framework's, so the order is written down rather than
51
+ #: decided by set iteration and a race between concurrent providers.
52
+ COMMANDS = {HoltCommands}
53
+
54
+ BINDINGS = [
55
+ ("ctrl+c", "quit", "quit"),
56
+ # `q` quits every screen that has no text box on it. Home does have
57
+ # one, and the input takes the key as a character, so a bare `q` never
58
+ # arrives there — which left the front screen as the only one in the
59
+ # app with no visible way out. `ctrl+q` works everywhere, including
60
+ # while you are typing, and being app-level it is on every footer.
61
+ ("ctrl+q", "quit", "quit"),
62
+ ]
63
+
64
+ def __init__(
65
+ self,
66
+ options: RunOptions | None = None,
67
+ assessments: store.Store | None = None,
68
+ ) -> None:
69
+ super().__init__()
70
+ #: Chosen once, at launch, and worn only by the cat and the masthead.
71
+ #: The colours that mean something -- verdict, evidence, drops -- are
72
+ #: fixed in `theme`, so a different accent each session changes how the
73
+ #: interface looks and never what it says.
74
+ self.accent = mascot.pick_accent()
75
+ self.store = assessments or store.Store()
76
+ #: Set when the interface was launched with a repository to assess.
77
+ #: Without one it opens on home, which is the ordinary case.
78
+ self.initial = options
79
+ self.session: Session | None = None
80
+ #: Runs in flight, keyed by repository. One run per repository: asking
81
+ #: for one that is already running reattaches to it rather than paying
82
+ #: twice for the same answer.
83
+ self.runs: dict[str, Session] = {}
84
+
85
+ def on_mount(self) -> None:
86
+ self.set_interval(PUMP_SECONDS, self.pump)
87
+ self.push_screen("home")
88
+ if self.initial is not None:
89
+ self.start_run(self.initial)
90
+
91
+ # ─── the pump ───────────────────────────────────────────────────────────
92
+
93
+ def pump(self) -> None:
94
+ """Absorb what every in-flight run has emitted, and keep its result.
95
+
96
+ Runs regardless of which screen is up. A session that has produced an
97
+ outcome is stored and unregistered here — never by a screen, because a
98
+ screen that has been popped cannot store anything.
99
+ """
100
+ for repo, session in list(self.runs.items()):
101
+ session.drain()
102
+ if session.finished:
103
+ self.remember(session)
104
+ self.runs.pop(repo, None)
105
+
106
+ @property
107
+ def in_flight(self) -> list[Session]:
108
+ """Runs that are still working, newest last."""
109
+ return [s for s in self.runs.values() if not s.finished]
110
+
111
+ def running_for(self, repo: str) -> Session | None:
112
+ return self.runs.get(normalise(repo))
113
+
114
+ # ─── navigation ─────────────────────────────────────────────────────────
115
+
116
+ def push_screen(self, screen, *args, **kwargs):
117
+ """Open a screen — or go back to it, when it is already open.
118
+
119
+ A screen opened by name is the *installed instance*: `push_screen(
120
+ "discover")` hands back the same object every time. Pushing one that is
121
+ already in the stack therefore puts a single `Screen` at two depths at
122
+ once, and that is not merely untidy. Every screen here is
123
+ `background: transparent`, so Textual renders the whole stack beneath
124
+ the current screen as its background — and a screen that appears twice
125
+ in that stack renders itself inside itself until Python runs out of
126
+ stack. The interface died with a `RecursionError` out of the
127
+ compositor, from a path as ordinary as ctrl+f, ctrl+o, and then "find a
128
+ repository" off the palette.
129
+
130
+ Going back to it is also what the key meant. `go_home` already pops
131
+ rather than pushing a second home, for the same reason: one screen, one
132
+ place in the stack.
133
+ """
134
+ open_already = self._open_already(screen)
135
+ if open_already is None:
136
+ return super().push_screen(screen, *args, **kwargs)
137
+ return self._return_to(open_already)
138
+
139
+ def _open_already(self, screen) -> Screen | None:
140
+ """The instance this push would open, if the stack already holds it.
141
+
142
+ Identity, not type. Two `AssessmentScreen`s are two reports and both
143
+ belong on the stack; what cannot happen is one object appearing twice.
144
+ """
145
+ if isinstance(screen, str):
146
+ try:
147
+ screen = self.get_screen(screen)
148
+ except KeyError:
149
+ return None
150
+ if not isinstance(screen, Screen):
151
+ return None
152
+ return screen if any(open_ is screen for open_ in self.screen_stack) else None
153
+
154
+ def _return_to(self, screen: Screen) -> AwaitComplete:
155
+ """Pop back down to a screen already open. A no-op if it is on top."""
156
+ popped: AwaitComplete | None = None
157
+ while self.screen is not screen and len(self.screen_stack) > 1:
158
+ popped = self.pop_screen()
159
+ return popped if popped is not None else AwaitComplete()
160
+
161
+ def start_run(self, options: RunOptions) -> None:
162
+ """Assess a repository, and watch it happen.
163
+
164
+ Reattaches if that repository is already being assessed. Two runs of the
165
+ same repository would produce two answers to one question, and on live
166
+ they would be paid for separately.
167
+ """
168
+ repo = normalise(options.repo)
169
+ existing = self.runs.get(repo)
170
+ if existing is not None:
171
+ self.watch_run(existing)
172
+ return
173
+ session = Session(options)
174
+ self.runs[repo] = session
175
+ self.session = session
176
+ session.start()
177
+ self.push_screen(LiveScreen())
178
+
179
+ def watch_run(self, session: Session) -> None:
180
+ """Reopen the live view on a run already in progress.
181
+
182
+ The screen rebuilds from the session's log, so a run joined half way
183
+ through shows everything it has done, not only what happens next.
184
+ """
185
+ self.session = session
186
+ self.push_screen(LiveScreen())
187
+
188
+ def open_stored(self, entry) -> None:
189
+ """Open an assessment that has already been produced. Nothing runs."""
190
+ self.session = Session.restored(entry)
191
+ self.push_screen(AssessmentScreen())
192
+
193
+ def show_assessment(self) -> None:
194
+ if self.session is not None and self.session.assessment is not None:
195
+ self.push_screen(AssessmentScreen())
196
+
197
+ def inspect(self, evidence_id: str) -> None:
198
+ """Resolve an id and show the record behind it.
199
+
200
+ The lookup goes through the session's own evidence — the provider the
201
+ run used, or, for an assessment reopened out of the store, the fixture
202
+ that run read, opened again. Either way the interface cannot show a
203
+ reader something Stage D did not have access to.
204
+
205
+ When there is nothing to look an id up in at all, the screen says that
206
+ and why, rather than claiming the id does not resolve — a very
207
+ different statement, and only one of them would be true.
208
+ """
209
+ session = self.session
210
+ record = session.resolve(evidence_id) if session else None
211
+ note = session.evidence_note if session else ""
212
+ self.push_screen(
213
+ InspectorScreen(
214
+ evidence_id,
215
+ record,
216
+ resolvable=not note,
217
+ note=note,
218
+ provenance=session.evidence_provenance if session else "",
219
+ )
220
+ )
221
+
222
+ def go_home(self) -> None:
223
+ """Back to the list, however deep the current screen is.
224
+
225
+ Leaves any run alone. Escape means "stop looking at this", never "stop
226
+ doing this" — stopping has its own key and its own confirmation.
227
+ """
228
+ # Home sits at index 1, above Textual's own base screen. Popping to
229
+ # exactly that leaves home on top however deep the stack got.
230
+ while len(self.screen_stack) > 2:
231
+ self.pop_screen()
232
+ home = self.screen
233
+ if hasattr(home, "refresh_entries"):
234
+ # Coroutine: scheduled rather than awaited, because the caller is a
235
+ # key binding and the list catching up a frame later is invisible.
236
+ home.call_later(home.refresh_entries)
237
+
238
+ # ─── stopping ───────────────────────────────────────────────────────────
239
+
240
+ def confirm_stop(self, session: Session) -> None:
241
+ """Ask before stopping. A stopped run keeps nothing it had computed."""
242
+ if session is None or session.finished:
243
+ return
244
+ self.push_screen(
245
+ ConfirmScreen(
246
+ f"stop {session.options.repo}?",
247
+ _spent(session),
248
+ yes="y stop",
249
+ no="n keep running",
250
+ ),
251
+ lambda confirmed: self._stop(session, bool(confirmed)),
252
+ )
253
+
254
+ def _stop(self, session: Session, confirmed: bool) -> None:
255
+ if confirmed:
256
+ session.cancel()
257
+
258
+ def action_quit(self) -> None:
259
+ """Quit, but never silently discard a run that is still working."""
260
+ running = self.in_flight
261
+ if not running:
262
+ self.exit()
263
+ return
264
+ detail = " ".join(f"{s.options.repo} {_spent(s)}" for s in running)
265
+ self.push_screen(
266
+ ConfirmScreen(
267
+ f"{len(running)} run{'s' if len(running) > 1 else ''} still in flight",
268
+ f"{detail}\nquitting stops {'them' if len(running) > 1 else 'it'}.",
269
+ yes="y quit",
270
+ no="n go back",
271
+ ),
272
+ lambda confirmed: self.exit() if confirmed else None,
273
+ )
274
+
275
+ # ─── persistence ────────────────────────────────────────────────────────
276
+
277
+ def remember(self, session: Session) -> None:
278
+ """Keep a finished assessment so the next launch opens on it."""
279
+ entry = session.to_entry()
280
+ if entry is not None:
281
+ self.store.save(entry)
282
+
283
+
284
+ def _spent(session: Session) -> str:
285
+ """Elapsed, and money if any was actually spent."""
286
+ minutes, seconds = divmod(int(session.duration), 60)
287
+ text = f"{minutes}:{seconds:02d}"
288
+ if session.cost_usd:
289
+ text += f" ${session.cost_usd:.4f}"
290
+ return text
291
+
292
+
293
+ def run(options: RunOptions | None = None) -> None:
294
+ HoltApp(options).run()
holt/tui/clipboard.py ADDED
@@ -0,0 +1,89 @@
1
+ """Getting the report out of the terminal and into somewhere it can be used.
2
+
3
+ The assessment is markdown before it is anything else — `Assessment.render()`
4
+ writes the file that `holt assess` produces — so what leaves here is that exact
5
+ text, evidence ids and all. Copying a re-typeset version of a report would mean
6
+ the thing you paste into an issue is not the thing the tool committed.
7
+
8
+ Copying from a terminal is genuinely uncertain, and this module refuses to
9
+ pretend otherwise. Two mechanisms, tried together:
10
+
11
+ * **A local clipboard tool** (`wl-copy`, `pbcopy`, `xclip`, …). When one runs
12
+ successfully the text is definitely on the clipboard, and we can say so.
13
+ * **OSC 52**, the escape sequence that asks the terminal emulator itself to take
14
+ the text. It is the only thing that works over ssh or inside tmux without a
15
+ local display, and it is also *unacknowledged* — the terminal may honour it,
16
+ ignore it, or have it switched off, and nothing comes back either way.
17
+
18
+ So the caller is handed a sentence describing what actually happened rather than
19
+ a boolean, and that sentence is shown to the user. "Copied" when a tool
20
+ confirmed it; "asked your terminal to copy it" when only OSC 52 was available.
21
+ Claiming success we cannot observe is the one thing not on offer.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import shutil
28
+ import subprocess
29
+
30
+ #: How long a clipboard tool may take before we give up on it and try the next.
31
+ #: Generous for a local process, short enough that a wedged one does not hold
32
+ #: the interface while somebody waits for a keypress to do something.
33
+ TIMEOUT_S = 2.0
34
+
35
+
36
+ #: name, argv, and the environment variable that has to be set for the tool to
37
+ #: have anything to talk to. `wl-copy` outside a Wayland session and `xclip`
38
+ #: with no `DISPLAY` both exist on a lot of machines and fail on all of them;
39
+ #: skipping those is faster than timing them out.
40
+ COMMANDS: tuple[tuple[str, tuple[str, ...], str], ...] = (
41
+ ("pbcopy", ("pbcopy",), ""),
42
+ ("wl-copy", ("wl-copy",), "WAYLAND_DISPLAY"),
43
+ ("xclip", ("xclip", "-selection", "clipboard"), "DISPLAY"),
44
+ ("xsel", ("xsel", "--clipboard", "--input"), "DISPLAY"),
45
+ ("clip.exe", ("clip.exe",), ""),
46
+ )
47
+
48
+
49
+ def native(text: str) -> str:
50
+ """Write to the system clipboard. Returns the tool that did it, or `""`."""
51
+ for name, argv, needs in COMMANDS:
52
+ if needs and not os.environ.get(needs):
53
+ continue
54
+ if shutil.which(argv[0]) is None:
55
+ continue
56
+ try:
57
+ subprocess.run(
58
+ argv,
59
+ input=text.encode(),
60
+ timeout=TIMEOUT_S,
61
+ check=True,
62
+ stdout=subprocess.DEVNULL,
63
+ stderr=subprocess.DEVNULL,
64
+ )
65
+ except (OSError, subprocess.SubprocessError):
66
+ continue
67
+ return name
68
+ return ""
69
+
70
+
71
+ def copy(app, text: str) -> str:
72
+ """Put `text` on the clipboard, and say in words what actually happened.
73
+
74
+ Both mechanisms are used, not one as a fallback for the other. A local tool
75
+ reports success but only reaches the machine holt is running on; OSC 52
76
+ reports nothing but is the one that reaches the terminal you are sitting in
77
+ front of when that is somewhere else.
78
+ """
79
+ tool = native(text)
80
+ try:
81
+ app.copy_to_clipboard(text)
82
+ except Exception: # noqa: BLE001 - never let a copy attempt close the report
83
+ pass
84
+ if tool:
85
+ return f"Copied as markdown ({tool})."
86
+ return (
87
+ "Asked your terminal to copy it as markdown (OSC 52). "
88
+ "Not every terminal honours that, and none of them say."
89
+ )