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,425 @@
1
+ """Where someone who has already landed work here might look next.
2
+
3
+ Reached from a report, because it answers the question that comes *after*
4
+ "is this worth my time". It needs a login, and it needs that login to have merged
5
+ something in the evidence — without a merge there is no set of touched paths and
6
+ the ranking has nothing to work from. That is stated when it happens rather than
7
+ producing an empty list.
8
+
9
+ **It reads what the report read.** This screen used to fetch from the committed
10
+ fixtures and nothing else, whatever mode the run had been in. Assess a repository
11
+ live — which is what the finder now hands you — press `n`, and it answered "no
12
+ committed evidence, so there is nothing to rank from" about a repository GitHub
13
+ would have answered for immediately. So the evidence the report was built from
14
+ is tried first, and a miss is a miss about the repository rather than about
15
+ where we happened to look.
16
+
17
+ **A live report is not answered from fixtures behind your back.** The fix above
18
+ went one step too far: when GitHub had nothing, the ranking silently fell
19
+ through to a recording taken before the holdout and mentioned it in a sentence
20
+ under an order that was already on screen. The order looked live. So the
21
+ fallback in that direction is now something you press `ctrl+e` for, and until
22
+ you do, a live miss reads as a live miss. The fallback the other way — a
23
+ replayed report reaching GitHub — is untouched: you already chose the
24
+ recording, and that direction moves toward the real repository rather than away
25
+ from it.
26
+
27
+ **Reads are reused for ten minutes, and say when they were.** Trying three
28
+ logins against one repository was three round trips to GitHub for the same two
29
+ fetches. The same ten minutes a stored assessment stays reusable for, for the
30
+ same reason: long enough to cover the way this screen is actually used, short
31
+ enough that nobody mistakes it for live data. Every reuse carries its age.
32
+
33
+ **The read happens off the screen's own message pump.** Reading evidence takes
34
+ a thread and, on a live repository, a minute; awaiting it inside the submit
35
+ handler meant the screen answered no keys at all until it came back. It runs as
36
+ a worker, so the notice moves, escape still works, and pressing enter again
37
+ replaces the ranking in flight.
38
+
39
+ The ranking is `progression.path_overlap_rank`: deterministic, no model call.
40
+ Each row shows the tokens that put it there, so the order is inspectable rather
41
+ than asserted — the same standard the rest of the interface holds evidence to.
42
+ And it carries `NEXT_MEASUREMENT` whenever it shows an order, because a ranking
43
+ shown without the number saying how well it works is the overclaim this project
44
+ exists to avoid — `holt next` on the command line has always printed it.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import asyncio
50
+ import time
51
+ from typing import Any
52
+
53
+ from rich.text import Text
54
+ from textual.app import ComposeResult
55
+ from textual.binding import Binding
56
+ from textual.containers import Horizontal, Vertical, VerticalScroll
57
+ from textual.screen import Screen
58
+ from textual.widgets import Footer, Input
59
+
60
+ from holt.agent.progression import NEXT_MEASUREMENT
61
+ from holt.tui import animation, discovery, store, theme
62
+ from holt.tui.visual import Line
63
+ from holt.tui.widgets.evidence import cite
64
+
65
+ TOP = 10
66
+
67
+ #: How long a fetch is reused for. Deliberately the same window a stored
68
+ #: assessment is considered fresh for, and imported from there rather than
69
+ #: restated, so the interface has one answer to "how old is too old" instead of
70
+ #: two that can drift apart.
71
+ CACHE_SECONDS = store.DEFAULT_MAX_AGE_SECONDS
72
+
73
+ #: `(repo, kind, live) -> (read_at, records)`. Module level because the screen
74
+ #: is rebuilt every time `n` is pressed, so nothing on the instance survives to
75
+ #: the next visit — which is one of the two cases worth caching for.
76
+ _CACHE: dict[tuple[str, str, bool], tuple[float, Any]] = {}
77
+
78
+
79
+ def cache_clear() -> None:
80
+ """Forget every reused read. For tests, and for nothing else."""
81
+ _CACHE.clear()
82
+
83
+
84
+ class _NoEvidence(Exception):
85
+ """Nothing to read, from either source. Carries the sentence to show."""
86
+
87
+
88
+
89
+ class NextScreen(Screen):
90
+ BINDINGS = [
91
+ ("escape", "back", "back"),
92
+ # `ctrl+e`, not a bare letter: the login box holds focus the whole time
93
+ # a reader would want this, and a printable key never reaches the
94
+ # screen from inside an `Input` — it just types itself into the box.
95
+ Binding("ctrl+e", "use_committed", "use committed evidence"),
96
+ ("q", "quit", "quit"),
97
+ ]
98
+
99
+ def __init__(self, repo: str, live: bool = False, **kwargs) -> None:
100
+ super().__init__(**kwargs)
101
+ self.repo = repo
102
+ #: How the report this was opened from read its evidence. The ranking
103
+ #: starts from the same place rather than assuming a recording exists.
104
+ self.live = live
105
+ #: Set only by `ctrl+e`. Until it is, a live report is never answered
106
+ #: from the committed fixtures — see `_sources`.
107
+ self.use_committed = False
108
+ #: True while committed evidence is being *offered* rather than used,
109
+ #: which is the only time `ctrl+e` means anything. Drives the footer.
110
+ self._withheld = False
111
+ #: The last login asked about, so `ctrl+e` can re-ask it rather than
112
+ #: making the reader type it again.
113
+ self._login = ""
114
+
115
+ def compose(self) -> ComposeResult:
116
+ with Horizontal(id="chrome"):
117
+ yield Line("holt · what next", id="chrome-left")
118
+ yield Line(Text(self.repo, style=theme.FAINT), id="chrome-right")
119
+ yield Line("─" * 240, classes="rule")
120
+
121
+ with Vertical(id="next-body"):
122
+ yield Line(
123
+ Text(
124
+ "Whose next issues? A GitHub username — yours, or anyone "
125
+ "who has had work merged here.",
126
+ style=theme.DIM,
127
+ ),
128
+ classes="section-label",
129
+ )
130
+ yield Input(
131
+ placeholder="GitHub username, e.g. frenck — enter to rank",
132
+ id="login-input",
133
+ )
134
+ yield Line(
135
+ Text(
136
+ "Ranked by overlap with the files they have already had merged "
137
+ "here. Deterministic, and no model is called.",
138
+ style=theme.FAINT,
139
+ ),
140
+ id="next-notice",
141
+ )
142
+ # Filled in only when an order is on screen, and never omitted then.
143
+ yield Line("", id="next-measurement")
144
+ with VerticalScroll(id="next-results"):
145
+ yield Line("")
146
+ yield Footer()
147
+
148
+ def on_mount(self) -> None:
149
+ self.query_one("#login-input", Input).focus()
150
+
151
+ def _notice(self, message: str, tone: str = "") -> None:
152
+ self.query_one("#next-notice", Line).update(
153
+ Text(message, style=tone or theme.FAINT)
154
+ )
155
+
156
+ def _measurement(self, message: str = "") -> None:
157
+ self.query_one("#next-measurement", Line).update(
158
+ Text(message, style=theme.FAINT)
159
+ )
160
+
161
+ def _sources(self) -> list[bool]:
162
+ """Which evidence to try, best first. `True` is GitHub, `False` is disk.
163
+
164
+ The report was built from one of these and the ranking reads the same
165
+ thing first. What happens on a miss is not symmetric, and deliberately
166
+ so:
167
+
168
+ * A **replayed** report falls through to GitHub. You chose the
169
+ recording, and reaching past it goes toward the live repository.
170
+ * A **live** report does *not* fall through to the committed fixtures.
171
+ It used to, silently, and then said so in a sentence underneath a
172
+ ranking already on screen — an order built from evidence recorded
173
+ before the holdout, presented as though GitHub had answered. Reading
174
+ fixtures is a choice, so it takes `ctrl+e`, which sets
175
+ `use_committed` and asks again.
176
+
177
+ Live is dropped from the list without a token rather than attempted and
178
+ reported as a failure, which would name the wrong problem.
179
+ """
180
+ sources = [self.live]
181
+ other = not self.live
182
+ # `other` is truthy exactly when the fallback is *toward* GitHub, which
183
+ # needs no permission. The fixture direction needs `ctrl+e`.
184
+ if other or self.use_committed:
185
+ sources.append(other)
186
+ return [
187
+ mode for mode in sources if not mode or discovery.missing_token() is None
188
+ ]
189
+
190
+ async def _fetch(self, make_provider, kind: str):
191
+ """Evidence for this repo, from the first source that has it.
192
+
193
+ Returns the records, whether they came from GitHub, and how long ago
194
+ they were read — `None` when they were read just now. The age is
195
+ returned rather than swallowed because a reused read is shown with it;
196
+ a cache nobody can see the age of is a cache that lies.
197
+
198
+ The fetch runs off the event loop: a live read is a network round trip
199
+ and the interface must not freeze for the length of one.
200
+ """
201
+ from holt.tui import session as session_module
202
+
203
+ failures: list[str] = []
204
+ for live in self._sources():
205
+ hit = _cached(self.repo, kind, live)
206
+ if hit is not None:
207
+ age, records = hit
208
+ return records, live, age
209
+ self._notice("reading GitHub…" if live else "reading committed evidence…")
210
+ try:
211
+ records = await asyncio.to_thread(
212
+ lambda live=live: make_provider(live=live).fetch(self.repo)
213
+ )
214
+ except FileNotFoundError:
215
+ failures.append("nothing recorded")
216
+ continue
217
+ except Exception as exc: # noqa: BLE001 - reported, never a traceback
218
+ failures.append(session_module.readable(exc))
219
+ continue
220
+ _CACHE[(self.repo, kind, live)] = (time.time(), records)
221
+ return records, live, None
222
+ raise _NoEvidence(self._nothing_to_read(kind, failures))
223
+
224
+ def _nothing_to_read(self, missing: str, failures: list[str]) -> str:
225
+ """Why there is nothing, naming the thing that would fix it.
226
+
227
+ Sets `_withheld` when the committed fixtures are the thing not tried,
228
+ because that is the only state in which `ctrl+e` does anything, and the
229
+ footer should not advertise a key that would be a no-op.
230
+ """
231
+ token = discovery.missing_token()
232
+ if token:
233
+ return (
234
+ f"No committed {missing} for {self.repo}, and reading GitHub "
235
+ "needs GITHUB_TOKEN — export a token, or put it in .env, and "
236
+ "try again."
237
+ )
238
+ detail = f" ({failures[-1]})" if failures else ""
239
+ if self.live and not self.use_committed:
240
+ # Withheld, not missing. Said as an offer rather than done quietly,
241
+ # which is the whole point of the key existing.
242
+ self._withheld = True
243
+ self.refresh_bindings()
244
+ return (
245
+ f"GitHub has no {missing} for {self.repo}{detail}, so there is "
246
+ "nothing live to rank from. ctrl+e tries the committed evidence "
247
+ "instead — a recording from before the holdout, not this "
248
+ "repository as it is now."
249
+ )
250
+ return (
251
+ f"No {missing} for {self.repo}, from the recording or from "
252
+ f"GitHub{detail}, so there is nothing to rank from."
253
+ )
254
+
255
+ def on_input_submitted(self, event: Input.Submitted) -> None:
256
+ """Start the ranking. Deliberately not `async`, and deliberately not
257
+ awaited.
258
+
259
+ A message handler owns the screen's message pump for as long as it
260
+ runs, and this one runs for as long as the evidence takes to read —
261
+ which on a live repository is a minute of network. Awaiting it here
262
+ meant the screen took no keys at all in the meantime: no escape, no
263
+ typing, nothing. The work moves to a worker so the screen keeps
264
+ answering while it happens, and pressing enter again replaces the
265
+ ranking in flight rather than queueing a second one behind it.
266
+ """
267
+ login = event.value.strip().lstrip("@")
268
+ if not login:
269
+ return
270
+ self.run_worker(self._rank(login), group="rank", exclusive=True)
271
+
272
+ async def _rank(self, login: str) -> None:
273
+ from holt.agent import progression
274
+ from holt.agent.signals import build_threads
275
+ from holt.issues import open_at_cutoff
276
+ from holt.tui import session as session_module
277
+
278
+ results = self.query_one("#next-results", VerticalScroll)
279
+ await results.remove_children()
280
+ self._measurement()
281
+ self._login = login
282
+ # Cleared before every attempt: whether the fixtures are being withheld
283
+ # is a fact about this attempt, not a mode the screen stays in.
284
+ self._withheld = False
285
+ self.refresh_bindings()
286
+
287
+ try:
288
+ records, from_live, evidence_age = await self._fetch(
289
+ session_module._provider, "evidence"
290
+ )
291
+ except _NoEvidence as miss:
292
+ self._notice(str(miss), theme.DROP)
293
+ return
294
+
295
+ # Off the loop for the same reason the fetch is: on a large repository
296
+ # this is a second of work, and a second of dropped frames reads as the
297
+ # interface having stopped.
298
+ contributor = await asyncio.to_thread(
299
+ lambda: progression.history_for(login, build_threads(records))
300
+ )
301
+ if not contributor.merged_count:
302
+ # Said plainly, because an empty list would look like a ranking that
303
+ # found nothing rather than a question that cannot be asked yet.
304
+ self._notice(
305
+ f"{login} has no merged pull request here in this evidence, so "
306
+ "there are no touched paths to rank against. The assessment "
307
+ "answers the question that comes before this one.",
308
+ theme.DROP,
309
+ )
310
+ return
311
+
312
+ try:
313
+ issues, issues_live, issue_age = await self._fetch(
314
+ session_module._issue_provider, "issue evidence"
315
+ )
316
+ except _NoEvidence as miss:
317
+ self._notice(str(miss), theme.DROP)
318
+ return
319
+
320
+ candidates = open_at_cutoff(issues)
321
+ if not candidates:
322
+ self._notice("No issue in this evidence was open at the cutoff.",
323
+ theme.DROP)
324
+ return
325
+
326
+ ranked = await asyncio.to_thread(
327
+ progression.path_overlap_rank, contributor.files, candidates
328
+ )
329
+ read = "read live from GitHub" if from_live and issues_live else (
330
+ "read from committed evidence" if not (from_live or issues_live)
331
+ else "read from both live and committed evidence"
332
+ )
333
+ read += _reuse(evidence_age, issue_age)
334
+ self._notice(
335
+ f"{login} has {contributor.merged_count} merged "
336
+ f"{'PR' if contributor.merged_count == 1 else 'PRs'} here, touching "
337
+ f"{len(contributor.files)} files. "
338
+ f"{sum(1 for _k, toks in ranked if toks)} of {len(ranked)} open issues "
339
+ f"overlap those paths. {read}."
340
+ )
341
+ # Never an order without the number that says how well it works. The
342
+ # command line has always printed this; this screen is the second path
343
+ # that shows a ranking and it was not printing it.
344
+ self._measurement(NEXT_MEASUREMENT)
345
+
346
+ for index, (key, tokens) in enumerate(ranked[:TOP], 1):
347
+ record = candidates[key]
348
+ await results.mount(_row(index, key, record, tokens))
349
+ animation.reveal(results.children[-1], delay=animation.stagger(index))
350
+
351
+ def check_action(self, action: str, parameters) -> bool | None:
352
+ """Advertise `ctrl+e` only where it does something.
353
+
354
+ `None` hides a binding rather than disabling it, which is what a key
355
+ that is not currently a choice should be.
356
+ """
357
+ if action == "use_committed":
358
+ return True if self._withheld else None
359
+ return True
360
+
361
+ async def action_use_committed(self) -> None:
362
+ """Rank from the committed evidence after all, having been asked.
363
+
364
+ This is the entire gate. Everything about reading fixtures on a live
365
+ report happens because somebody pressed this, and the ranking that
366
+ comes back says which source it came from.
367
+ """
368
+ if not self._withheld or not self._login:
369
+ return
370
+ self.use_committed = True
371
+ await self._rank(self._login)
372
+
373
+ def action_back(self) -> None:
374
+ self.app.pop_screen()
375
+
376
+ def action_quit(self) -> None:
377
+ self.app.exit()
378
+
379
+
380
+ def _cached(repo: str, kind: str, live: bool) -> tuple[float, Any] | None:
381
+ """A read from within the window, and its age. Expired entries are dropped.
382
+
383
+ Dropped rather than left to rot: this dict lives for the process, and a
384
+ long session against many repositories would otherwise only ever grow.
385
+ """
386
+ entry = _CACHE.get((repo, kind, live))
387
+ if entry is None:
388
+ return None
389
+ read_at, records = entry
390
+ age = max(0.0, time.time() - read_at)
391
+ if age > CACHE_SECONDS:
392
+ del _CACHE[(repo, kind, live)]
393
+ return None
394
+ return age, records
395
+
396
+
397
+ def _reuse(*ages: float | None) -> str:
398
+ """How the sentence admits to a reused read, or "" when nothing was reused.
399
+
400
+ The oldest of them, because that is the age of the claim as a whole — the
401
+ freshest part of a mixed answer is not what it should be dated by.
402
+ """
403
+ known = [age for age in ages if age is not None]
404
+ if not known:
405
+ return ""
406
+ if len(known) < len(ages):
407
+ return f", partly reused from a read {store.describe_age(max(known))}"
408
+ return f", reused from a read {store.describe_age(max(known))}"
409
+
410
+
411
+ def _row(index: int, key: str, record, tokens: set[str]) -> Line:
412
+ title = str(record.payload.get("title", "")).strip()
413
+ text = Text()
414
+ text.append(f"{index} ", style=theme.FAINT)
415
+ text.append(title[:76] or key)
416
+ text.append("\n ")
417
+ text.append_text(cite(key, width=44))
418
+ if tokens:
419
+ # Why this row is where it is. An order without a reason is an
420
+ # assertion, and the rest of the interface does not make those.
421
+ text.append(" overlaps ", style=theme.FAINT)
422
+ text.append(", ".join(sorted(tokens)[:4]), style=theme.DIM)
423
+ else:
424
+ text.append(" no path overlap — listed by recency", style=theme.FAINT)
425
+ return Line(text, classes="next-row")
@@ -0,0 +1,129 @@
1
+ """What you want to work on, said once.
2
+
3
+ `holt discover` reads this, and on the command line it is a set of prompts you
4
+ answer in sequence. In a terminal interface a form is better: every field is
5
+ visible at once, you can see what is already stored, and changing one does not
6
+ mean re-answering the rest.
7
+
8
+ The file is `holt.profile`'s, at `holt.profile.config_path()`, in the same
9
+ format the command-line version writes. Editing here and editing there are the
10
+ same thing, so nobody has to know which one produced the file they have.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from rich.text import Text
16
+ from textual.app import ComposeResult
17
+ from textual.containers import Horizontal, Vertical
18
+ from textual.screen import Screen
19
+ from textual.widgets import Footer, Input
20
+
21
+ from holt.tui import animation, theme
22
+ from holt.tui.visual import Line
23
+
24
+ #: Field name, label, and the sentence that says what it is for. The order is
25
+ #: the order the questions are asked in on the command line.
26
+ FIELDS: tuple[tuple[str, str, str], ...] = (
27
+ ("languages", "languages", "comma separated — python, rust"),
28
+ ("topics", "topics", "GitHub topics — cli, database"),
29
+ ("contributions", "contributions", "docs, tests, ci, code"),
30
+ ("days", "days", "how many days you actually have"),
31
+ )
32
+
33
+
34
+ class ProfileScreen(Screen):
35
+ BINDINGS = [
36
+ ("ctrl+s", "save", "save"),
37
+ ("escape", "home", "home"),
38
+ ("q", "quit", "quit"),
39
+ ]
40
+
41
+ def compose(self) -> ComposeResult:
42
+ from holt import profile as profile_mod
43
+
44
+ stored = profile_mod.load()
45
+
46
+ with Horizontal(id="chrome"):
47
+ yield Line("holt · profile", id="chrome-left")
48
+ yield Line(
49
+ Text(str(profile_mod.config_path()), style=theme.FAINT),
50
+ id="chrome-right",
51
+ )
52
+ yield Line("─" * 240, classes="rule")
53
+
54
+ with Vertical(id="profile-body"):
55
+ yield Line(
56
+ Text(
57
+ "Said once, and read by discover. Nothing here is sent "
58
+ "anywhere; it only shapes which repositories are searched for.",
59
+ style=theme.FAINT,
60
+ ),
61
+ classes="section-label",
62
+ )
63
+ for name, label, note in FIELDS:
64
+ # The note is the placeholder and nothing else. Printing it as a
65
+ # caption as well said the same thing twice on every field.
66
+ yield Line(Text(label, style=theme.DIM), classes="section-label")
67
+ yield Input(
68
+ value=_value(stored, name),
69
+ placeholder=note,
70
+ id=f"profile-{name}",
71
+ )
72
+ yield Line("", id="profile-notice")
73
+ yield Footer()
74
+
75
+ def on_mount(self) -> None:
76
+ self.query_one("#profile-languages", Input).focus()
77
+ self._notice("ctrl+s saves esc goes back without saving")
78
+
79
+ def _notice(self, message: str, tone: str = "") -> None:
80
+ widget = self.query_one("#profile-notice", Line)
81
+ widget.update(Text(message, style=tone or theme.FAINT))
82
+ animation.reveal(widget)
83
+
84
+ # ─── actions ────────────────────────────────────────────────────────────
85
+
86
+ def action_save(self) -> None:
87
+ from holt import profile as profile_mod
88
+
89
+ values = {
90
+ name: self.query_one(f"#profile-{name}", Input).value.strip()
91
+ for name, _label, _note in FIELDS
92
+ }
93
+
94
+ days_text = values["days"]
95
+ if days_text and not days_text.isdigit():
96
+ self._notice(f"“{days_text}” is not a number of days.", theme.DROP)
97
+ return
98
+
99
+ built = profile_mod.Profile(
100
+ languages=_split(values["languages"]),
101
+ topics=_split(values["topics"]),
102
+ contributions=_split(values["contributions"]),
103
+ days=int(days_text) if days_text else profile_mod.DEFAULT_CONTRIBUTOR_DAYS,
104
+ )
105
+ try:
106
+ path = profile_mod.save(built)
107
+ except OSError as exc:
108
+ self._notice(f"Could not write the profile: {exc}", theme.DROP)
109
+ return
110
+ self._notice(f"Saved to {path}")
111
+
112
+ def action_home(self) -> None:
113
+ self.app.go_home()
114
+
115
+ def action_quit(self) -> None:
116
+ self.app.exit()
117
+
118
+
119
+ def _value(stored, name: str) -> str:
120
+ if stored is None:
121
+ return ""
122
+ value = getattr(stored, name, None)
123
+ if isinstance(value, list):
124
+ return ", ".join(value)
125
+ return "" if value is None else str(value)
126
+
127
+
128
+ def _split(text: str) -> list[str]:
129
+ return [part.strip() for part in text.split(",") if part.strip()]