skit-cli 0.0.1__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.
skit/tui.py ADDED
@@ -0,0 +1,774 @@
1
+ """The TUI workbench (Textual): skit's home surface.
2
+
3
+ Library screen: search + list + detail pane, two-row footer
4
+ with every action always visible (design assumption: most TUI users never press ?),
5
+ recency sort, contextual r-rerun, lazy drift check on selection. Presentation only —
6
+ all logic goes through the headless store/flows/launcher layers.
7
+
8
+ Keys: Enter run · r rerun (after a first run) · p script settings · e edit script ·
9
+ Del remove · a add script · s presets · , preferences · D health check · / search ·
10
+ double Ctrl+C / Esc quit.
11
+
12
+ Focus model: the TABLE owns the keyboard by default, so every advertised single-letter
13
+ key actually fires; `/` (or a click) enters the search box, where letters type as text
14
+ and Esc returns to the table. Type-to-search-with-permanent-focus was abandoned after
15
+ review: an always-focused Input consumes printable keys, which silently killed every
16
+ single-letter action the footer advertised.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import time
23
+ from datetime import UTC, datetime
24
+ from pathlib import Path
25
+ from typing import override
26
+
27
+ from rich.markup import escape
28
+ from rich.text import Text
29
+ from textual import events, on
30
+ from textual.app import App, ComposeResult
31
+ from textual.binding import Binding
32
+ from textual.containers import Horizontal, Vertical, VerticalScroll
33
+ from textual.screen import ModalScreen
34
+ from textual.widgets import DataTable, Input, Label, Static
35
+
36
+ from . import argstate, editor, flows, launcher, models, store, theme, tui_footer
37
+ from .i18n import gettext, ngettext
38
+ from .models import Entry
39
+ from .theme import CLAUDE_THEME
40
+ from .tui_form import FormResult, RunFormScreen
41
+
42
+ # Glyphs are locale-independent; the labels are translated at render time in _kind_badge.
43
+ # The labels must be gettext() literals there (not values fed to gettext(kind)) or Babel
44
+ # can't extract them — see scripts/i18n_coverage.py's dynamic-gettext check.
45
+ _KIND_GLYPHS = {"python": "⬡", "exe": "▶", "command": "$"}
46
+
47
+
48
+ def _kind_badge(kind: str) -> tuple[str, str]:
49
+ label = {
50
+ "python": gettext("Python"),
51
+ "exe": gettext("Program"),
52
+ "command": gettext("Command"),
53
+ }.get(kind, kind)
54
+ return _KIND_GLYPHS.get(kind, "?"), label
55
+
56
+
57
+ def _fuzzy_match(query: str, text: str) -> bool:
58
+ """Subsequence fuzzy match (case-insensitive)."""
59
+ q = query.lower()
60
+ haystack = text.lower()
61
+ pos = 0 # pragma: no mutate — find(ch, None) == find(ch, 0)
62
+ for ch in q:
63
+ pos = haystack.find(ch, pos)
64
+ if pos == -1:
65
+ return False
66
+ pos += 1
67
+ return True
68
+
69
+
70
+ def _activity_key(entry: Entry) -> str:
71
+ """Recency sort key: last run or added time, whichever is newer (a fresh add must
72
+ surface even though it has never run)."""
73
+ last = argstate.load_state(entry.slug)["last_run"]
74
+ return max(str(last.get("at", "")), entry.meta.added_at or "")
75
+
76
+
77
+ def _relative_time(iso: str) -> str:
78
+ try:
79
+ then = datetime.fromisoformat(iso)
80
+ except ValueError:
81
+ return iso
82
+ delta = datetime.now(UTC) - then
83
+ seconds = int(delta.total_seconds())
84
+ if seconds < 90:
85
+ return gettext("just now")
86
+ if seconds < 5400:
87
+ return gettext("%(minutes)s min ago") % {"minutes": seconds // 60}
88
+ if seconds < 129600:
89
+ return gettext("%(hours)s h ago") % {"hours": seconds // 3600}
90
+ return gettext("%(days)s d ago") % {"days": seconds // 86400}
91
+
92
+
93
+ class ConfirmRemove(ModalScreen[bool]):
94
+ """Removal modal: verb keys, and the reassurance that carries the A5 promise."""
95
+
96
+ BINDINGS = [
97
+ Binding("y", "confirm", gettext("Remove")),
98
+ Binding("escape,n", "cancel", gettext("Keep")),
99
+ ]
100
+ DEFAULT_CSS = """
101
+ ConfirmRemove { align: center middle; }
102
+ #confirm-box {
103
+ border: round $skit-box-maroon; padding: 1 2; width: auto; height: auto;
104
+ background: $background;
105
+ }
106
+ /* In an auto-width box a 1fr Static collapses to zero columns — every modal child
107
+ must hug its content for the box to measure anything at all. */
108
+ #confirm-box Static { width: auto; }
109
+ #confirm-box > Static:last-of-type { margin: 1 0 0 0; }
110
+ """
111
+
112
+ def __init__(self, entry: Entry) -> None:
113
+ super().__init__()
114
+ self._entry: Entry = entry
115
+
116
+ @override
117
+ def compose(self) -> ComposeResult:
118
+ lines = [Label(gettext('Remove "%(name)s"?') % {"name": escape(self._entry.meta.name)})]
119
+ if self._entry.meta.kind != "command":
120
+ lines.append(Static(f"[dim]{gettext('Your original file will not be deleted.')}[/dim]"))
121
+ # The verb line IS the button row: y/Esc stay advertised, and each chip is
122
+ # clickable — modals must not be the one place that suddenly demands keys.
123
+ lines.append(
124
+ Static(
125
+ tui_footer.bar(
126
+ tui_footer.chip("screen.confirm", "y", gettext("Remove")),
127
+ tui_footer.chip("screen.cancel", "Esc", gettext("Keep")),
128
+ ),
129
+ markup=True,
130
+ )
131
+ )
132
+ yield Vertical(*lines, id="confirm-box")
133
+
134
+ def action_confirm(self) -> None:
135
+ self.dismiss(True)
136
+
137
+ def action_cancel(self) -> None:
138
+ self.dismiss(False)
139
+
140
+
141
+ class HelpScreen(ModalScreen[None]):
142
+ """? overlay. Everything here is ALSO in the footer — this is a reminder, never the
143
+ only path to a feature (discoverability assumption in the UX spec)."""
144
+
145
+ BINDINGS = [Binding("escape,question_mark", "dismiss_help", gettext("Close"))]
146
+ DEFAULT_CSS = """
147
+ HelpScreen { align: center middle; }
148
+ #help-box { border: round $accent; padding: 1 2; width: auto; height: auto;
149
+ background: $background; }
150
+ /* Same zero-width trap as the confirm box: 1fr Statics inside an auto box measure
151
+ as nothing, which rendered the ? overlay as a tiny empty square. */
152
+ #help-box Static { width: auto; }
153
+ #help-box > Static:last-of-type { margin: 1 0 0 0; }
154
+ """
155
+
156
+ @override
157
+ def compose(self) -> ComposeResult:
158
+ rows = [
159
+ ("Enter", gettext("Run")),
160
+ ("r", gettext("Rerun with last values")),
161
+ ("p", gettext("Script settings")),
162
+ ("s", gettext("Presets")),
163
+ ("e", gettext("Edit script")),
164
+ ("Del", gettext("Remove")),
165
+ ("a", gettext("Add script")),
166
+ (",", gettext("Preferences")),
167
+ ("D", gettext("Health check")),
168
+ ("Ctrl+C Ctrl+C / Esc", gettext("Quit")),
169
+ ]
170
+ body = "\n".join(f"[$accent]{k:>16}[/] {escape(v)}" for k, v in rows)
171
+ yield Vertical(
172
+ Static(body, markup=True),
173
+ Static(
174
+ tui_footer.bar(tui_footer.chip("screen.dismiss_help", "Esc", gettext("Close"))),
175
+ markup=True,
176
+ ),
177
+ id="help-box",
178
+ )
179
+
180
+ def action_dismiss_help(self) -> None:
181
+ self.dismiss(None)
182
+
183
+
184
+ class MenuApp(App[int]):
185
+ """The Library. Exit code 0 on a clean quit."""
186
+
187
+ TITLE = "skit · " + gettext("Library")
188
+ ENABLE_COMMAND_PALETTE = False
189
+ CSS = (
190
+ theme.CHROME_CSS
191
+ + """
192
+ #search { dock: top; }
193
+ #main { height: 1fr; }
194
+ /* btop grammar: each panel is a rounded box with its own muted border tint and its
195
+ title ON the border (list green, detail indigo — the cpu/net pairing). */
196
+ #entry-table { width: 3fr; border: round $skit-box-green; border-title-color: ansi_bright_white; }
197
+ #detail { width: 2fr; border: round $skit-box-indigo; border-title-color: ansi_bright_white;
198
+ padding: 0 1; }
199
+ /* One docked container holds the whole footer. Docking each row separately makes
200
+ every dock:bottom widget land on the SAME bottom line (dock does not stack), so
201
+ the two key rows end up hidden behind the status line — the footer looks empty.
202
+ Stacking them inside a single auto-height docked Vertical is what actually shows
203
+ all three rows. */
204
+ #footer { dock: bottom; height: auto; }
205
+ #status { height: 1; color: $text-muted; padding: 0 1; }
206
+ #keys-local { height: 1; padding: 0 1; }
207
+ #keys-global { height: 1; padding: 0 1; }
208
+ """
209
+ )
210
+ BINDINGS = [
211
+ Binding("ctrl+c", "ctrl_c_quit", gettext("Quit"), priority=True),
212
+ Binding("escape", "back_or_quit", gettext("Quit"), show=False),
213
+ # "delete" is forward-delete (fn+Delete on a Mac); the key most users press to
214
+ # delete — the big ⌫ above Return — sends backspace, which Textual names
215
+ # "backspace". Bind both so the footer's advertised "Del" actually fires. This is
216
+ # safe next to the search box: a focused Input owns backspace for its own
217
+ # delete-left (closer in the focus chain than this non-priority app binding), so
218
+ # backspace only reaches "remove" when the table has focus.
219
+ Binding("delete,backspace", "remove", gettext("Remove")),
220
+ Binding("ctrl+e", "edit", gettext("Edit script")),
221
+ Binding("e", "edit", gettext("Edit script"), show=False),
222
+ Binding("enter", "run", gettext("Run")),
223
+ Binding("r", "rerun", gettext("Rerun"), show=False),
224
+ Binding("p", "settings", gettext("Script settings"), show=False),
225
+ Binding("s", "presets", gettext("Presets"), show=False),
226
+ Binding("a", "add", gettext("Add script"), show=False),
227
+ Binding("comma", "preferences", gettext("Preferences"), show=False),
228
+ Binding("D", "health", gettext("Health check"), show=False),
229
+ Binding("question_mark", "help", gettext("Help"), show=False),
230
+ Binding("slash", "focus_search", gettext("Search"), show=False),
231
+ # priority: Textual's built-in Tab focus-nav would otherwise win. The Library's
232
+ # focus model moves with / (search) and Esc (back to the table), not Tab, so Tab is
233
+ # free to mean "toggle the detail pane" as the spec asks.
234
+ Binding("tab", "toggle_detail", gettext("Detail pane"), show=False, priority=True),
235
+ ]
236
+ CTRL_C_WINDOW = 2.0
237
+ # Below this terminal width the detail pane auto-collapses to give the list the whole
238
+ # row (spec §1); Tab pins it open/closed regardless.
239
+ MIN_DETAIL_WIDTH = 80
240
+
241
+ def __init__(self) -> None:
242
+ super().__init__()
243
+ self._entries: list[Entry] = []
244
+ self._visible: list[Entry] = []
245
+ self._ctrl_c_at: float = 0.0
246
+ self._drift_cache: dict[str, tuple[float, bool]] = {} # slug -> (mtime, has_drift)
247
+ self._detail_manual: bool | None = None # None = auto by width; else the user's Tab choice
248
+
249
+ @override
250
+ def get_css_variables(self) -> dict[str, str]:
251
+ # The first stylesheet parse runs before on_mount activates the theme; without
252
+ # the $skit-box-* merge that parse dies on an unresolved variable.
253
+ return {**super().get_css_variables(), **theme.BOX_VARIABLES}
254
+
255
+ def on_mount(self) -> None:
256
+ self.register_theme(CLAUDE_THEME)
257
+ self.theme = "skit-claude"
258
+ table = self.query_one(DataTable)
259
+ table.add_columns(gettext("Name"), gettext("Kind"), " ")
260
+ table.border_title = gettext("Scripts")
261
+ self.query_one("#detail").border_title = gettext("Detail pane")
262
+ self._reload()
263
+ # The table owns the keyboard: that's what makes the advertised single-letter
264
+ # keys real. `/` moves into the search box.
265
+ table.focus()
266
+
267
+ @override
268
+ def compose(self) -> ComposeResult:
269
+ yield Input(placeholder=gettext("/ to search names and descriptions…"), id="search")
270
+ with Horizontal(id="main"):
271
+ yield DataTable(cursor_type="row", zebra_stripes=False, id="entry-table")
272
+ yield VerticalScroll(Static("", id="detail-body", markup=True), id="detail")
273
+ with Vertical(id="footer"):
274
+ yield Static("", id="keys-local", markup=True)
275
+ yield Static("", id="keys-global", markup=True)
276
+ yield Static("", id="status")
277
+
278
+ # ------------------------------------------------------------------ data
279
+
280
+ def _reload(self) -> None:
281
+ self._entries = sorted(store.list_entries(), key=_activity_key, reverse=True)
282
+ self._apply_filter(self.query_one("#search", Input).value)
283
+
284
+ def _apply_filter(self, query: str) -> None:
285
+ table = self.query_one(DataTable)
286
+ table.clear()
287
+ self._visible = [
288
+ e
289
+ for e in self._entries
290
+ if not query or _fuzzy_match(query, f"{e.meta.name} {e.meta.description}")
291
+ ]
292
+ for e in self._visible:
293
+ glyph, kind_label = _kind_badge(e.meta.kind)
294
+ if e.meta.kind == "python" and e.meta.mode == "reference":
295
+ # reference: links the original, never copied (spec §1). kind-gated:
296
+ # command templates also carry mode="reference" in their meta, but there
297
+ # is no linked file to point an arrow at.
298
+ kind_label = f"{kind_label} ↗"
299
+ health = "⚠" if launcher.target_missing(e) else ""
300
+ table.add_row(escape(e.meta.name), f"{glyph} {kind_label}", health, key=e.slug)
301
+ self._refresh_status()
302
+ self._refresh_detail()
303
+ self._refresh_footer()
304
+
305
+ def _selected(self) -> Entry | None:
306
+ table = self.query_one(DataTable)
307
+ if not self._visible:
308
+ return None
309
+ if 0 <= table.cursor_row < len(self._visible): # pragma: no mutate — self-clamps cursor
310
+ return self._visible[table.cursor_row]
311
+ return None # pragma: no cover — Textual clamps cursor_coordinate
312
+
313
+ # ---------------------------------------------------------------- render
314
+
315
+ def _refresh_status(self, message: str = "") -> None:
316
+ status = self.query_one("#status", Static)
317
+ if message:
318
+ status.update(message)
319
+ return
320
+ if not self._entries:
321
+ status.update(gettext("Your scripts will appear here."))
322
+ return
323
+ status.update(
324
+ ngettext(
325
+ "%(shown)s/%(total)s script", "%(shown)s/%(total)s scripts", len(self._entries)
326
+ )
327
+ % {"shown": len(self._visible), "total": len(self._entries)}
328
+ )
329
+
330
+ def _refresh_footer(self) -> None:
331
+ entry = self._selected()
332
+ local: list[str] = []
333
+ if entry is not None:
334
+ local.append(tui_footer.chip("app.run", "Enter", gettext("Run")))
335
+ if argstate.load_state(entry.slug)["last_run"]:
336
+ local.append(tui_footer.chip("app.rerun", "r", gettext("Rerun")))
337
+ local.append(tui_footer.chip("app.settings", "p", gettext("Script settings")))
338
+ local.append(tui_footer.chip("app.edit", "e", gettext("Edit script")))
339
+ local.append(tui_footer.chip("app.remove", "Del", gettext("Remove")))
340
+ globals_row = [
341
+ tui_footer.chip("app.add", "a", gettext("Add script")),
342
+ tui_footer.chip("app.presets", "s", gettext("Presets")),
343
+ tui_footer.chip("app.focus_search", "/", gettext("Search")),
344
+ tui_footer.chip("app.preferences", ",", gettext("Preferences")),
345
+ tui_footer.chip("app.health", "D", gettext("Health check")),
346
+ tui_footer.chip("app.help", "?", gettext("Help")),
347
+ ]
348
+ self.query_one("#keys-local", Static).update(tui_footer.bar(*local))
349
+ self.query_one("#keys-global", Static).update(tui_footer.bar(*globals_row))
350
+
351
+ def _has_drift(self, entry: Entry) -> bool:
352
+ """Drift is the expensive check (read + reconcile): lazy, per-selection, mtime-cached."""
353
+ if entry.meta.kind != "python" or not entry.script_path.exists():
354
+ return False
355
+ mtime = entry.script_path.stat().st_mtime
356
+ cached = self._drift_cache.get(entry.slug)
357
+ if cached is not None and cached[0] == mtime:
358
+ return cached[1]
359
+ plan = flows.plan_for_entry(entry)
360
+ drift = bool(plan.drift_lines)
361
+ self._drift_cache[entry.slug] = (mtime, drift)
362
+ return drift
363
+
364
+ def _refresh_detail(self) -> None:
365
+ body = self.query_one("#detail-body", Static)
366
+ entry = self._selected()
367
+ if entry is None:
368
+ if not self._entries:
369
+ body.update(
370
+ "\n".join(
371
+ (
372
+ f"[bold]{gettext('Your scripts will appear here.')}[/bold]",
373
+ "",
374
+ gettext("Press a to add the first one,"),
375
+ gettext("or run: skit add <path> in a terminal."),
376
+ )
377
+ )
378
+ )
379
+ else:
380
+ body.update("")
381
+ return
382
+ body.update("\n".join(self._detail_lines(entry)))
383
+
384
+ def _detail_lines(self, entry: Entry) -> list[str]:
385
+ glyph, kind_label = _kind_badge(entry.meta.kind)
386
+ lines = [f"[bold $accent]{escape(entry.meta.name)}[/]", f"{glyph} {kind_label}"]
387
+ if entry.meta.kind == "python":
388
+ if entry.meta.mode == "copy":
389
+ lines.append(
390
+ f"[dim]✓ {gettext('The copy is kept by skit; your original file is never modified.')}[/dim]"
391
+ )
392
+ else:
393
+ lines.append(
394
+ f"[dim]↗ {gettext('Linked to the original: %(path)s') % {'path': escape(entry.meta.source)}}[/dim]"
395
+ )
396
+ if entry.meta.kind == "command":
397
+ lines.append(f"[dim]{escape(entry.meta.template)}[/dim]")
398
+ lines.append("")
399
+ lines.append(
400
+ escape(entry.meta.description)
401
+ if entry.meta.description
402
+ else f"[dim]{gettext('(no description — add one in Script settings)')}[/dim]"
403
+ )
404
+ lines.append("")
405
+ lines.extend(self._detail_state_lines(entry))
406
+ return lines
407
+
408
+ def _detail_state_lines(self, entry: Entry) -> list[str]:
409
+ lines: list[str] = []
410
+ state = argstate.load_state(entry.slug)
411
+ plan = flows.plan_for_entry(entry)
412
+ if plan.fields:
413
+ shown: list[str] = []
414
+ for f in plan.fields[:6]:
415
+ if f.secret:
416
+ shown.append(f"{escape(f.key)}=•••🔒")
417
+ else:
418
+ value = state["values"].get(f.key, f.default)
419
+ shown.append(f"{escape(f.key)}={escape(value)}" if value else escape(f.key))
420
+ more = "" if len(plan.fields) <= 6 else " …"
421
+ lines.append(gettext("Parameters %(summary)s") % {"summary": " ".join(shown) + more})
422
+ if state["presets"]:
423
+ lines.append(
424
+ gettext("Presets %(names)s")
425
+ % {"names": " · ".join(escape(p) for p in sorted(state["presets"]))}
426
+ )
427
+ if entry.meta.dependencies:
428
+ lines.append(
429
+ gettext("Depends on %(deps)s")
430
+ % {"deps": ", ".join(escape(d) for d in entry.meta.dependencies)}
431
+ )
432
+ last = state["last_run"]
433
+ if last:
434
+ outcome = (
435
+ f"[green]✓ {gettext('finished')}[/green]"
436
+ if last.get("exit") == 0
437
+ else f"[yellow]✗ {gettext('failed (code %(code)s)') % {'code': last.get('exit')}}[/yellow]"
438
+ )
439
+ lines.append(
440
+ gettext("Last run %(when)s · %(outcome)s")
441
+ % {"when": _relative_time(str(last.get("at", ""))), "outcome": outcome}
442
+ )
443
+ else:
444
+ lines.append(f"[dim]{gettext('Not run yet')}[/dim]")
445
+ marker = launcher.missing_marker(entry)
446
+ if marker:
447
+ lines.append(f"[yellow]{escape(marker)}[/yellow]")
448
+ elif self._has_drift(entry):
449
+ lines.append(
450
+ f"[yellow]⚠ {gettext('The script changed — skit checks the form against it before every run.')}[/yellow]"
451
+ )
452
+ return lines
453
+
454
+ # ---------------------------------------------------------------- events
455
+
456
+ @on(Input.Changed, "#search")
457
+ def _on_search(self, event: Input.Changed) -> None:
458
+ self._apply_filter(event.value)
459
+
460
+ @on(Input.Submitted, "#search")
461
+ def _on_search_submitted(self, event: Input.Submitted) -> None:
462
+ # Enter in the search box runs the top match (and returns focus to the table,
463
+ # so the follow-up keys — r, p, e — work immediately).
464
+ self.query_one(DataTable).focus()
465
+ self.action_run()
466
+
467
+ @on(DataTable.RowHighlighted)
468
+ def _on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
469
+ self._refresh_detail()
470
+ self._refresh_footer()
471
+
472
+ @on(DataTable.RowSelected)
473
+ def _on_row_selected(self, event: DataTable.RowSelected) -> None:
474
+ self.action_run()
475
+
476
+ def on_key(self, event: events.Key) -> None:
477
+ # While searching, Up/Down still drive the table (browse results as you type).
478
+ search = self.query_one("#search", Input)
479
+ if event.key in ("up", "down") and self.focused is search:
480
+ table = self.query_one(DataTable)
481
+ table.action_cursor_up() if event.key == "up" else table.action_cursor_down()
482
+ event.stop()
483
+
484
+ _LIBRARY_ACTIONS = (
485
+ "run",
486
+ "remove",
487
+ "edit",
488
+ "rerun",
489
+ "settings",
490
+ "presets",
491
+ "add",
492
+ "preferences",
493
+ "health",
494
+ "help",
495
+ "focus_search",
496
+ "toggle_detail",
497
+ )
498
+
499
+ @override
500
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
501
+ """Library keys act only on the Library: keys that bubble out of a pushed
502
+ screen's own widgets must not trigger surprise actions underneath it."""
503
+ return not (action in self._LIBRARY_ACTIONS and len(self.screen_stack) > 1)
504
+
505
+ def action_focus_search(self) -> None:
506
+ self.query_one("#search", Input).focus()
507
+
508
+ def action_back_or_quit(self) -> None:
509
+ """Esc in the search box returns to the table; Esc on the table quits."""
510
+ search = self.query_one("#search", Input)
511
+ if self.focused is search:
512
+ self.query_one(DataTable).focus()
513
+ return
514
+ self.exit(0)
515
+
516
+ def action_ctrl_c_quit(self) -> None:
517
+ now = time.monotonic()
518
+ if now - self._ctrl_c_at <= self.CTRL_C_WINDOW:
519
+ self.exit(0)
520
+ return
521
+ self._ctrl_c_at = now
522
+ self.notify(gettext("Press Ctrl+C again to quit"), timeout=self.CTRL_C_WINDOW)
523
+
524
+ # ------------------------------------------------------------------- run
525
+
526
+ def action_run(self) -> None:
527
+ if len(self.screen_stack) > 1:
528
+ return
529
+ entry = self._selected()
530
+ if entry is None:
531
+ return
532
+ try:
533
+ launcher.preflight(entry)
534
+ except launcher.LaunchError as exc:
535
+ self._refresh_status(gettext("Error: %(error)s") % {"error": escape(str(exc))})
536
+ return
537
+ plan = flows.plan_for_entry(entry)
538
+ if not plan.fields and not plan.degraded_reason:
539
+ self._execute(entry, plan, {}, argstate.load_state(entry.slug)["extra_args"])
540
+ return
541
+ prefill = flows.prefill(plan, entry.slug)
542
+
543
+ def _submitted(result: FormResult) -> None:
544
+ if result is None:
545
+ return
546
+ values, extra = result
547
+ self._execute(entry, plan, values, extra, show_drift=False)
548
+
549
+ self.push_screen(RunFormScreen(entry, plan, prefill), _submitted)
550
+
551
+ def action_rerun(self) -> None:
552
+ """r: skip the form, rerun with the last values — but never skip the checks."""
553
+ entry = self._selected()
554
+ if entry is None:
555
+ return
556
+ if not argstate.load_state(entry.slug)["last_run"]:
557
+ self._refresh_status(
558
+ gettext("%(name)s hasn't run yet — press Enter to fill the form first.")
559
+ % {"name": escape(entry.meta.name)}
560
+ )
561
+ return
562
+ try:
563
+ launcher.preflight(entry)
564
+ except launcher.LaunchError as exc:
565
+ self._refresh_status(gettext("Error: %(error)s") % {"error": escape(str(exc))})
566
+ return
567
+ plan = flows.plan_for_entry(entry)
568
+ prefill = flows.prefill(plan, entry.slug)
569
+ if flows.validate(plan, prefill):
570
+ # The last values no longer satisfy the form (e.g. a new required field):
571
+ # fall back to the form rather than assembling a broken command.
572
+ self.action_run()
573
+ return
574
+ self._execute(entry, plan, prefill, argstate.load_state(entry.slug)["extra_args"])
575
+
576
+ def _execute(
577
+ self,
578
+ entry: Entry,
579
+ plan: flows.FormPlan,
580
+ values: dict[str, str],
581
+ extra: list[str],
582
+ *,
583
+ show_drift: bool = True,
584
+ ) -> None:
585
+ """Suspend, deliver (inject/flags/template), pass the terminal through, record.
586
+
587
+ show_drift=False when the form was just shown (its banner already said it)."""
588
+ try:
589
+ asm = flows.assemble(plan, values, list(extra), cwd=Path.cwd())
590
+ except flows.FormError as exc:
591
+ self._refresh_status(gettext("Error: %(error)s") % {"error": escape(str(exc))})
592
+ return
593
+ with self.suspend():
594
+ print(f"\n── {gettext('Run %(name)s') % {'name': entry.meta.name}} ──\n", flush=True)
595
+ if show_drift:
596
+ for line in plan.drift_lines:
597
+ print(line, flush=True)
598
+ # The shared delivery pipeline: inject, transparency, run, cleanup. The TUI
599
+ # just prints what it emits (bare, inside the suspend) and shows a banner.
600
+ outcome = flows.execute(entry, plan, asm, emit=lambda line: print(line, flush=True))
601
+ if outcome.code is None:
602
+ print(gettext("Error: %(error)s") % {"error": outcome.message}, flush=True)
603
+ print(f"\n{self._run_banner(outcome)}", flush=True)
604
+ with contextlib.suppress(EOFError):
605
+ input()
606
+ code = outcome.code
607
+ if code is None:
608
+ # The script never ran: recording it would light up r-rerun and stamp a
609
+ # "last run" that never happened.
610
+ self._reload()
611
+ self._refresh_status(
612
+ gettext("Last: %(name)s ✗ couldn't launch") % {"name": escape(entry.meta.name)}
613
+ )
614
+ return
615
+ flows.save_after_run(entry.slug, plan, values, list(extra), code, at=models.now_iso())
616
+ self._reload()
617
+ status = (
618
+ gettext("Last: %(name)s ✓ finished")
619
+ if code == 0
620
+ else gettext("Last: %(name)s ✗ failed (code %(code)s)")
621
+ )
622
+ self._refresh_status(status % {"name": escape(entry.meta.name), "code": code})
623
+
624
+ @staticmethod
625
+ def _run_banner(outcome: flows.RunOutcome) -> str:
626
+ if outcome.code == 0:
627
+ return gettext("✓ finished — press Enter to return")
628
+ if outcome.launched:
629
+ return gettext("✗ failed (code %(code)s) — press Enter to return") % {
630
+ "code": outcome.code
631
+ }
632
+ return gettext("✗ couldn't launch — press Enter to return")
633
+
634
+ # --------------------------------------------------------------- actions
635
+
636
+ def action_edit(self) -> None:
637
+ if len(self.screen_stack) > 1:
638
+ return
639
+ entry = self._selected()
640
+ if entry is None:
641
+ return
642
+ target = self._editable_source(entry)
643
+ if target is None or not target.exists():
644
+ self._refresh_status(
645
+ gettext("%(name)s: no editable script source (only Python scripts have one).")
646
+ % {"name": escape(entry.meta.name)}
647
+ )
648
+ return
649
+ with self.suspend():
650
+ try:
651
+ editor.open_in_editor(target)
652
+ except editor.EditorError as exc:
653
+ print(str(exc), flush=True)
654
+ with contextlib.suppress(EOFError):
655
+ input(gettext("Press Enter to return"))
656
+ self._drift_cache.pop(entry.slug, None)
657
+ self._reload()
658
+ self._refresh_status(gettext("Edited %(name)s.") % {"name": escape(entry.meta.name)})
659
+
660
+ def _editable_source(self, entry: Entry) -> Path | None:
661
+ if entry.meta.kind != "python":
662
+ return None
663
+ if entry.meta.mode == "reference":
664
+ return Path(entry.meta.source)
665
+ return entry.dir / "script.py"
666
+
667
+ def action_remove(self) -> None:
668
+ if len(self.screen_stack) > 1:
669
+ return
670
+ entry = self._selected()
671
+ if entry is None:
672
+ return
673
+
674
+ def _done(confirmed: bool | None) -> None:
675
+ if confirmed:
676
+ store.remove(entry.slug)
677
+ self._reload()
678
+
679
+ self.push_screen(ConfirmRemove(entry), _done)
680
+
681
+ def action_add(self) -> None:
682
+ from .tui_add import AddSourceScreen
683
+
684
+ def _added(slug: str | None) -> None:
685
+ self._reload()
686
+ if slug:
687
+ self._select_slug(slug)
688
+ self._refresh_status(gettext("✓ added"))
689
+
690
+ self.push_screen(AddSourceScreen(), _added)
691
+
692
+ def action_settings(self, section: str = "") -> None:
693
+ entry = self._selected()
694
+ if entry is None:
695
+ return
696
+ from .tui_settings import ScriptSettingsScreen
697
+
698
+ def _closed(_changed: bool | None) -> None:
699
+ self._drift_cache.pop(entry.slug, None)
700
+ self._reload()
701
+
702
+ self.push_screen(ScriptSettingsScreen(entry, initial_section=section), _closed)
703
+
704
+ def action_presets(self) -> None:
705
+ self.action_settings(section="presets")
706
+
707
+ def action_preferences(self) -> None:
708
+ from .tui_prefs import PreferencesScreen
709
+
710
+ def _applied(_result: object) -> None:
711
+ self._retranslate_chrome() # a language change must hit the chrome, not just rows
712
+ self._reload()
713
+
714
+ self.push_screen(PreferencesScreen(), _applied)
715
+
716
+ def action_health(self) -> None:
717
+ from .tui_health import HealthScreen
718
+
719
+ def _jump(slug: str | None) -> None:
720
+ self._reload()
721
+ if slug:
722
+ self._select_slug(slug)
723
+
724
+ self.push_screen(HealthScreen(), _jump)
725
+
726
+ def action_help(self) -> None:
727
+ self.push_screen(HelpScreen())
728
+
729
+ def _select_slug(self, slug: str) -> None:
730
+ for i, e in enumerate(self._visible):
731
+ if e.slug == slug:
732
+ self.query_one(DataTable).move_cursor(row=i)
733
+ break
734
+ self._refresh_detail()
735
+ self._refresh_footer()
736
+
737
+ # ------------------------------------------------------------- detail pane
738
+
739
+ def on_resize(self, event: events.Resize) -> None:
740
+ """Narrow terminals give the whole row to the list (spec §1). Auto-collapse only
741
+ while the user hasn't pinned the pane with Tab."""
742
+ if self._detail_manual is None:
743
+ self._set_detail_visible(event.size.width >= self.MIN_DETAIL_WIDTH)
744
+
745
+ def action_toggle_detail(self) -> None:
746
+ """Tab: show/hide the detail pane and pin that choice against auto-collapse."""
747
+ self._detail_manual = not self.query_one("#detail").display
748
+ self._set_detail_visible(self._detail_manual)
749
+
750
+ def _set_detail_visible(self, visible: bool) -> None:
751
+ self.query_one("#detail").display = visible
752
+
753
+ # ------------------------------------------------------------- language
754
+
755
+ def _retranslate_chrome(self) -> None:
756
+ """Re-translate the static chrome that compose/on_mount set once, so a language
757
+ change in Preferences applies on the spot (spec §6) rather than only to the rows
758
+ _reload rebuilds: the window title, the search placeholder, the column headers."""
759
+ self.title = "skit · " + gettext("Library")
760
+ self.query_one("#search", Input).placeholder = gettext(
761
+ "/ to search names and descriptions…"
762
+ )
763
+ headers = [gettext("Name"), gettext("Kind"), " "]
764
+ for column, label in zip(self.query_one(DataTable).ordered_columns, headers, strict=False):
765
+ column.label = Text(label)
766
+ self.query_one(DataTable).border_title = gettext("Scripts")
767
+ self.query_one("#detail").border_title = gettext("Detail pane")
768
+ self.query_one(DataTable).refresh()
769
+
770
+
771
+ def run_menu() -> int:
772
+ app = MenuApp()
773
+ result = app.run()
774
+ return result if isinstance(result, int) else 0