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