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/__init__.py +3 -0
- skit/analyzer.py +410 -0
- skit/argspec.py +528 -0
- skit/argstate.py +166 -0
- skit/atomic.py +101 -0
- skit/cli.py +1621 -0
- skit/config.py +255 -0
- skit/editor.py +77 -0
- skit/flows.py +596 -0
- skit/i18n.py +324 -0
- skit/inlineform.py +61 -0
- skit/launcher.py +326 -0
- skit/locales/zh_CN/LC_MESSAGES/skit.mo +0 -0
- skit/locales/zh_TW/LC_MESSAGES/skit.mo +0 -0
- skit/metawriter.py +267 -0
- skit/models.py +154 -0
- skit/paths.py +44 -0
- skit/pep723.py +292 -0
- skit/promptform.py +67 -0
- skit/reconcile.py +321 -0
- skit/shim.py +386 -0
- skit/store.py +566 -0
- skit/theme.py +123 -0
- skit/tokens.py +112 -0
- skit/tui.py +774 -0
- skit/tui_add.py +361 -0
- skit/tui_footer.py +38 -0
- skit/tui_form.py +586 -0
- skit/tui_health.py +150 -0
- skit/tui_prefs.py +175 -0
- skit/tui_settings.py +374 -0
- skit/uvman.py +288 -0
- skit_cli-0.0.1.dist-info/METADATA +199 -0
- skit_cli-0.0.1.dist-info/RECORD +37 -0
- skit_cli-0.0.1.dist-info/WHEEL +4 -0
- skit_cli-0.0.1.dist-info/entry_points.txt +3 -0
- skit_cli-0.0.1.dist-info/licenses/LICENSE +21 -0
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
|