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_form.py ADDED
@@ -0,0 +1,586 @@
1
+ """The run form as Textual widgets: one FormPlan → one pokeable screen.
2
+
3
+ Shared by the TUI workbench (full screen) and the CLI's inline mini-form (M6): same
4
+ widgets, same validation, same keys — one flow, two frames. All logic stays in flows;
5
+ this module renders and collects.
6
+
7
+ Field widgets by kind:
8
+ - bool → Checkbox
9
+ - choice → horizontal RadioSet (←/→ or click)
10
+ - secret → password Input (+ "reads $NAME" note when an env source is set)
11
+ - anything else → Input, with live token-expansion preview and glob match count
12
+ - degraded → Input + "leave empty for the script's own default" hint
13
+
14
+ Type hints render in muted text and only turn loud on a validation error.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING, override
21
+
22
+ from rich.markup import escape
23
+ from textual import on
24
+ from textual.app import ComposeResult
25
+ from textual.binding import Binding
26
+ from textual.containers import Horizontal, Vertical, VerticalScroll
27
+ from textual.screen import ModalScreen, Screen
28
+ from textual.widgets import Checkbox, Input, Label, OptionList, RadioButton, RadioSet, Static
29
+ from textual.widgets.option_list import Option
30
+
31
+ from . import argstate, flows, tokens, tui_footer
32
+ from .i18n import gettext
33
+
34
+ if TYPE_CHECKING:
35
+ from .models import Entry
36
+
37
+ # The submit result: (raw field values, extra passthrough args) or None on cancel.
38
+ FormResult = tuple[dict[str, str], list[str]] | None
39
+
40
+ _EXTRA_KEY = "__extra_args__"
41
+
42
+
43
+ def _type_label(kind: str) -> str:
44
+ """The dim type hint next to a field label, translated at render time. The msgids MUST be
45
+ gettext() string literals here, not a dict of labels fed to gettext(kind): a dict lookup is
46
+ invisible to Babel's extractor, so the strings never reach the catalog and silently fall
47
+ back to English in every locale. (That is the exact bug scripts/i18n_coverage.py's
48
+ dynamic-gettext check now guards against.)"""
49
+ return {
50
+ "int": gettext("whole number"),
51
+ "float": gettext("number"),
52
+ "str": gettext("text"),
53
+ "bool": gettext("on/off"),
54
+ }.get(kind, kind)
55
+
56
+
57
+ def _degraded_notice(reason: str) -> str:
58
+ """The honest line for a whole-parser degrade (the form still offers the
59
+ extra-arguments escape field)."""
60
+ if reason == "subparsers":
61
+ return gettext(
62
+ "This script has subcommands skit can't model — type everything into the "
63
+ "extra-arguments field."
64
+ )
65
+ return gettext(
66
+ "skit couldn't read this script's argument declarations — type everything into "
67
+ "the extra-arguments field."
68
+ )
69
+
70
+
71
+ class FieldRow(Vertical):
72
+ """One form field: label row, control, help/feedback lines."""
73
+
74
+ DEFAULT_CSS = """
75
+ FieldRow { height: auto; margin: 0 0 1 0; max-width: 100; }
76
+ FieldRow .field-label { color: $foreground; }
77
+ FieldRow .field-help { color: $text-muted; }
78
+ FieldRow .field-error { color: $error; }
79
+ FieldRow .field-preview { color: $text-muted; }
80
+ /* Feedback lines materialize only when they have something to say. As permanent
81
+ empty Statics they cost 2 blank rows per field, which is what stretched the form
82
+ into a sparse, oddly-gapped page. */
83
+ FieldRow .field-preview { display: none; }
84
+ FieldRow .field-error { display: none; }
85
+ FieldRow Input { width: 1fr; }
86
+ FieldRow RadioSet { layout: horizontal; height: auto; border: none; padding: 0; }
87
+ FieldRow RadioSet:focus { border: none; }
88
+ /* Textual gives each RadioButton width:1fr, which scatters two choices across the
89
+ whole terminal row; options belong side by side, reading left to right. */
90
+ FieldRow RadioSet > RadioButton { width: auto; margin: 0 3 0 0; }
91
+ /* A bare Checkbox wears a tall border around a lone glyph — a cryptic floating
92
+ box. Borderless, with an on/off word as its label, it reads as the toggle it is. */
93
+ FieldRow Checkbox { border: none; padding: 0; }
94
+ FieldRow Checkbox:focus { border: none; }
95
+ """
96
+
97
+ def __init__(self, field: flows.FormField, prefill: str) -> None:
98
+ # Field keys are identifiers (argparse dests, [tool.skit] names, placeholders),
99
+ # so they are valid Textual ids; the ▾ link targets the row through this id.
100
+ super().__init__(id=f"fr-{field.key}")
101
+ self.field: flows.FormField = field
102
+ self._prefill: str = prefill
103
+
104
+ @property
105
+ def insertable(self) -> bool:
106
+ """Whether the ▾ token menu applies: free-text-ish and not a secret (secret
107
+ values skip token expansion by design)."""
108
+ return not self.field.secret and self.field.kind not in ("bool", "choice")
109
+
110
+ @override
111
+ def compose(self) -> ComposeResult:
112
+ f = self.field
113
+ pieces = [escape(f.label)]
114
+ if f.required:
115
+ pieces.append(f"[$accent]{gettext('required')}[/]")
116
+ if f.kind in ("int", "float", "bool"):
117
+ pieces.append(f"[dim]{_type_label(f.kind)}[/dim]")
118
+ if f.secret:
119
+ pieces.append(f"[dim]🔒 {gettext('never saved to disk')}[/dim]")
120
+ if self.insertable:
121
+ pieces.append(
122
+ f"[$accent @click=screen.insert_token('{f.key}')]▾ {gettext('insert')}[/]"
123
+ )
124
+ yield Static(" ".join(pieces), classes="field-label", markup=True)
125
+ yield from self._compose_control()
126
+ if f.help:
127
+ yield Static(escape(f.help), classes="field-help")
128
+ if f.degraded:
129
+ yield Static(
130
+ gettext("Leave empty to use the script's own default."), classes="field-help"
131
+ )
132
+ if f.secret and f.env_source:
133
+ yield Static(
134
+ gettext("Leave empty to read it from the environment variable %(env)s.")
135
+ % {"env": escape(f.env_source)},
136
+ classes="field-help",
137
+ )
138
+ yield Static("", classes="field-preview")
139
+ yield Static("", classes="field-error")
140
+
141
+ def _compose_control(self) -> ComposeResult:
142
+ f = self.field
143
+ if f.kind == "bool":
144
+ on = self._prefill.strip().lower() in ("true", "1", "yes")
145
+ yield Checkbox(gettext("on") if on else gettext("off"), value=on)
146
+ elif f.kind == "choice" and f.choices:
147
+ with RadioSet():
148
+ for choice in f.choices:
149
+ yield RadioButton(escape(choice), value=(choice == self._prefill))
150
+ else:
151
+ yield Input(value=self._prefill, password=f.secret)
152
+
153
+ @property
154
+ def value(self) -> str:
155
+ f = self.field
156
+ if f.kind == "bool":
157
+ return "true" if self.query_one(Checkbox).value else "false"
158
+ if f.kind == "choice" and f.choices:
159
+ pressed = self.query_one(RadioSet).pressed_index
160
+ return f.choices[pressed] if 0 <= pressed < len(f.choices) else ""
161
+ return self.query_one(Input).value
162
+
163
+ def set_value(self, value: str) -> None:
164
+ f = self.field
165
+ if f.kind == "bool":
166
+ self.query_one(Checkbox).value = value.strip().lower() in ("true", "1", "yes")
167
+ elif f.kind == "choice" and f.choices:
168
+ if value in f.choices:
169
+ buttons = list(self.query(RadioButton))
170
+ buttons[f.choices.index(value)].value = True
171
+ else:
172
+ self.query_one(Input).value = value
173
+
174
+ def show_error(self, message: str | None) -> None:
175
+ error = self.query_one(".field-error", Static)
176
+ error.update(escape(message) if message else "")
177
+ error.display = bool(message)
178
+
179
+ @on(Checkbox.Changed)
180
+ def _bool_word(self, event: Checkbox.Changed) -> None:
181
+ """The label tracks the state (on/off), btop-style — an unchecked box labeled
182
+ "on" would read as a lie."""
183
+ event.checkbox.label = gettext("on") if event.value else gettext("off")
184
+
185
+ @on(Input.Changed)
186
+ def _live_feedback(self, event: Input.Changed) -> None:
187
+ """Token-expansion preview and glob match count, refreshed as the user types."""
188
+ f = self.field
189
+ preview = self.query_one(".field-preview", Static)
190
+ value = event.value
191
+ if f.secret or not value:
192
+ preview.update("")
193
+ preview.display = False
194
+ return
195
+ lines: list[str] = []
196
+ if tokens.has_tokens(value):
197
+ expanded, error = tokens.preview(value, cwd=Path.cwd())
198
+ lines.append(f"→ {escape(error if error else expanded)}")
199
+ count = flows.glob_feedback(value, Path.cwd())
200
+ if count is not None:
201
+ lines.append(
202
+ gettext("✓ matches %(count)s file(s)") % {"count": count}
203
+ if count
204
+ else gettext("⚠ matches no files yet")
205
+ )
206
+ preview.update(" ".join(lines))
207
+ preview.display = bool(lines)
208
+ self.show_error(None)
209
+
210
+
211
+ class PresetNameModal(ModalScreen[str | None]):
212
+ """Ctrl+S: name (or overwrite) a preset from the current form values."""
213
+
214
+ AUTO_FOCUS = "Input" # type immediately; without this, Enter lands nowhere
215
+ BINDINGS = [Binding("escape", "cancel", gettext("Cancel"))]
216
+ DEFAULT_CSS = """
217
+ PresetNameModal { align: center middle; }
218
+ PresetNameModal > Vertical {
219
+ border: round $accent; padding: 1 2; width: 60; height: auto;
220
+ background: $background;
221
+ }
222
+ """
223
+
224
+ def __init__(self, existing: set[str]) -> None:
225
+ super().__init__()
226
+ self._existing: set[str] = existing
227
+
228
+ @override
229
+ def compose(self) -> ComposeResult:
230
+ with Vertical():
231
+ yield Label(gettext("Save as preset"))
232
+ yield Input(placeholder=gettext("Preset name"))
233
+ yield Static("", id="preset-hint", markup=True)
234
+ yield Static(
235
+ tui_footer.bar(
236
+ tui_footer.chip("screen.save_name", "Enter", gettext("Save")),
237
+ tui_footer.chip("screen.cancel", "Esc", gettext("Cancel")),
238
+ ),
239
+ markup=True,
240
+ )
241
+
242
+ @on(Input.Changed)
243
+ def _overwrite_hint(self, event: Input.Changed) -> None:
244
+ hint = self.query_one("#preset-hint", Static)
245
+ if event.value.strip() in self._existing:
246
+ hint.update(
247
+ f"[yellow]{gettext('This overwrites the existing preset %(name)s.') % {'name': escape(event.value.strip())}}[/yellow]"
248
+ )
249
+ else:
250
+ hint.update("")
251
+
252
+ @on(Input.Submitted)
253
+ def _save(self, event: Input.Submitted) -> None:
254
+ name = event.value.strip()
255
+ if name:
256
+ self.dismiss(name)
257
+
258
+ def action_save_name(self) -> None:
259
+ """Click twin of Enter: save under the typed name (a blank name stays put)."""
260
+ name = self.query_one(Input).value.strip()
261
+ if name:
262
+ self.dismiss(name)
263
+
264
+ def action_cancel(self) -> None:
265
+ self.dismiss(None)
266
+
267
+
268
+ class TokenMenuModal(ModalScreen[str | None]):
269
+ """The ▾insert menu: run-time value tokens, spelled out so users learn the syntax
270
+ passively (the same philosophy as showing the assembled command). The first two
271
+ entries ARE the design's intent-vs-frozen fork: "wherever I run from" ({cwd}) sits
272
+ right next to "this directory, as a fixed path"."""
273
+
274
+ AUTO_FOCUS = "OptionList"
275
+ BINDINGS = [Binding("escape", "cancel", gettext("Cancel"))]
276
+ DEFAULT_CSS = """
277
+ TokenMenuModal { align: center middle; }
278
+ TokenMenuModal > Vertical { border: round $accent; padding: 1 2; width: 64;
279
+ height: auto; background: $background; }
280
+ TokenMenuModal OptionList { height: auto; border: none; }
281
+ TokenMenuModal Static { width: auto; margin: 1 0 0 0; }
282
+ """
283
+
284
+ _ENV_SENTINEL = "__env__"
285
+
286
+ @override
287
+ def compose(self) -> ComposeResult:
288
+ entries: list[tuple[str, str, str]] = [
289
+ ("{cwd}", gettext("Directory at run time (changes with where you run)"), "{cwd}"),
290
+ (str(Path.cwd()), gettext("This directory, as a fixed path"), str(Path.cwd())),
291
+ ("{today}", gettext("Today's date"), "{today}"),
292
+ ("{now}", gettext("Current time"), "{now}"),
293
+ ("~", gettext("Home directory"), "~"),
294
+ ]
295
+ options = [
296
+ Option(f"{escape(label)} [dim]{escape(shown)}[/dim]", id=insert)
297
+ for shown, label, insert in entries
298
+ ]
299
+ options.append(
300
+ Option(
301
+ f"{gettext('Environment variable…')} [dim]{{env:NAME}}[/dim]",
302
+ id=self._ENV_SENTINEL,
303
+ )
304
+ )
305
+ with Vertical():
306
+ yield Label(gettext("Insert a run-time value"))
307
+ yield OptionList(*options)
308
+ yield Static(
309
+ tui_footer.bar(tui_footer.chip("screen.cancel", "Esc", gettext("Cancel"))),
310
+ markup=True,
311
+ )
312
+
313
+ @on(OptionList.OptionSelected)
314
+ def _picked(self, event: OptionList.OptionSelected) -> None:
315
+ if event.option.id == self._ENV_SENTINEL:
316
+
317
+ def _env_done(token: str | None) -> None:
318
+ self.dismiss(token)
319
+
320
+ self.app.push_screen(EnvPickerModal(), _env_done)
321
+ return
322
+ self.dismiss(str(event.option.id))
323
+
324
+ def action_cancel(self) -> None:
325
+ self.dismiss(None)
326
+
327
+
328
+ class EnvPickerModal(ModalScreen[str | None]):
329
+ """Pick an environment variable by name (type to filter os.environ)."""
330
+
331
+ AUTO_FOCUS = "Input"
332
+ BINDINGS = [Binding("escape", "cancel", gettext("Cancel"))]
333
+ DEFAULT_CSS = """
334
+ EnvPickerModal { align: center middle; }
335
+ EnvPickerModal > Vertical { border: round $accent; padding: 1 2; width: 64;
336
+ height: auto; max-height: 80%; background: $background; }
337
+ EnvPickerModal OptionList { border: none; max-height: 12; }
338
+ EnvPickerModal Static { width: auto; margin: 1 0 0 0; }
339
+ """
340
+
341
+ @override
342
+ def compose(self) -> ComposeResult:
343
+ with Vertical():
344
+ yield Label(gettext("Environment variable"))
345
+ yield Input(placeholder=gettext("type to filter…"))
346
+ yield OptionList(*self._options(""))
347
+ yield Static(
348
+ tui_footer.bar(tui_footer.chip("screen.cancel", "Esc", gettext("Cancel"))),
349
+ markup=True,
350
+ )
351
+
352
+ def _options(self, needle: str) -> list[Option]:
353
+ import os
354
+
355
+ names = sorted(n for n in os.environ if needle.lower() in n.lower())
356
+ return [Option(escape(n), id=n) for n in names[:200]]
357
+
358
+ @on(Input.Changed)
359
+ def _filter(self, event: Input.Changed) -> None:
360
+ option_list = self.query_one(OptionList)
361
+ option_list.clear_options()
362
+ option_list.add_options(self._options(event.value.strip()))
363
+
364
+ @on(Input.Submitted)
365
+ def _typed(self, event: Input.Submitted) -> None:
366
+ # A name typed in full is accepted even if it isn't set YET — the token resolves
367
+ # at run time, and assembly reports a missing variable by name.
368
+ name = event.value.strip()
369
+ if name.isidentifier():
370
+ self.dismiss("{env:" + name + "}")
371
+
372
+ @on(OptionList.OptionSelected)
373
+ def _picked(self, event: OptionList.OptionSelected) -> None:
374
+ self.dismiss("{env:" + str(event.option.id) + "}")
375
+
376
+ def action_cancel(self) -> None:
377
+ self.dismiss(None)
378
+
379
+
380
+ class RunFormScreen(Screen[FormResult]):
381
+ """The full-size run form (title answers "where am I": Run <name>)."""
382
+
383
+ BINDINGS = [
384
+ Binding("escape", "cancel", gettext("Cancel")),
385
+ Binding("ctrl+s", "save_preset", gettext("Save as preset")),
386
+ # Enter runs the form from ANY field. Without priority, a focused Checkbox/RadioSet
387
+ # swallows Enter for its own toggle/select and the footer's "Enter Run" silently does
388
+ # nothing (and an inline flag/choice-only form has no Input at all, so Enter could
389
+ # never submit). priority makes the screen intercept Enter first; Space still toggles
390
+ # a checkbox. Ctrl+R stays as the explicit muscle-memory chord.
391
+ Binding("enter", "submit", gettext("Run"), priority=True, show=False),
392
+ Binding("ctrl+r", "submit", gettext("Run"), priority=True),
393
+ Binding("ctrl+t", "insert_token", gettext("Insert value")),
394
+ ]
395
+ DEFAULT_CSS = """
396
+ RunFormScreen { background: $background; }
397
+ /* btop grammar: the form is one rounded panel with the "Run <name>" title ON the
398
+ border (proc-box maroon — this is the panel where things run). The border lives
399
+ on an inner container, not the Screen: a bordered Screen shifts its coordinate
400
+ space and clicks on the bottom-docked footer land "outside" the screen. */
401
+ RunFormScreen #form-panel {
402
+ border: round $skit-box-maroon;
403
+ border-title-color: ansi_bright_white;
404
+ border-title-style: bold;
405
+ }
406
+ RunFormScreen #drift-banner, RunFormScreen #degraded-notice { color: $warning; padding: 0 1; }
407
+ RunFormScreen #preset-row { height: auto; padding: 0 1; }
408
+ /* Widgets default to width:1fr; in a Horizontal that lets the "Preset:" caption
409
+ swallow the whole row and push the chips (or the empty-state hint) clean off the
410
+ screen. Everything in this row hugs its content. */
411
+ RunFormScreen #preset-row Static { width: auto; margin: 0 1 0 0; }
412
+ RunFormScreen #preset-row RadioSet { width: auto; }
413
+ /* The empty-state hint is a long sentence: let it take the row's remaining width and
414
+ wrap, rather than width:auto (its full content width) which overflows a narrow form.
415
+ Two ids outrank the `#preset-row Static` width:auto rule above. */
416
+ RunFormScreen #preset-row #preset-empty { color: $text-muted; width: 1fr; height: auto; }
417
+ RunFormScreen #preset-row RadioSet { layout: horizontal; height: auto; border: none; }
418
+ RunFormScreen #preset-row RadioSet > RadioButton { width: auto; margin: 0 2 0 0; }
419
+ RunFormScreen #form-body { padding: 0 1; }
420
+ RunFormScreen #form-keys { dock: bottom; height: 1; color: $text-muted; padding: 0 1; }
421
+
422
+ /* Inline mode (the CLI's `skit run` mini-window): an inline Screen is sized to its
423
+ content height, but the VerticalScroll body defaults to height:1fr, which collapses
424
+ to a single row in an auto-height parent — the whole form flattens to a 3-line stub
425
+ and the docked footer gets clipped. Give the panel and body an explicit auto height
426
+ so the screen measures its true content, and cap the window so a tall form scrolls
427
+ inside it (footer stays docked and visible) instead of taking over the terminal. In
428
+ full-screen mode the body keeps its 1fr fill and the footer pins to the bottom. */
429
+ RunFormScreen:inline { height: auto; max-height: 80%; }
430
+ RunFormScreen:inline #form-panel { height: auto; }
431
+ RunFormScreen:inline #form-body { height: auto; }
432
+ """
433
+
434
+ def __init__(
435
+ self,
436
+ entry: Entry,
437
+ plan: flows.FormPlan,
438
+ prefill: dict[str, str],
439
+ include_extra: bool = True,
440
+ ) -> None:
441
+ super().__init__()
442
+ self._entry: Entry = entry
443
+ self._plan: flows.FormPlan = plan
444
+ self._prefill: dict[str, str] = prefill
445
+ # The inline (CLI) frame hides the extra-args row: argv already owns passthrough
446
+ # args there, and two sources for the same thing would fight.
447
+ self._include_extra: bool = include_extra
448
+ self._presets: dict[str, dict[str, str]] = argstate.load_state(entry.slug)["presets"]
449
+
450
+ def on_mount(self) -> None:
451
+ self.query_one("#form-panel").border_title = gettext("Run %(name)s") % {
452
+ "name": escape(self._entry.meta.name)
453
+ }
454
+
455
+ @override
456
+ def compose(self) -> ComposeResult:
457
+ with Vertical(id="form-panel"):
458
+ if self._plan.drift_lines:
459
+ yield Static(
460
+ "\n".join(escape(line) for line in self._plan.drift_lines), id="drift-banner"
461
+ )
462
+ if self._plan.degraded_reason:
463
+ yield Static(_degraded_notice(self._plan.degraded_reason), id="degraded-notice")
464
+ with Horizontal(id="preset-row"):
465
+ yield Static(gettext("Preset:"), markup=False)
466
+ if self._presets:
467
+ with RadioSet(id="preset-set"):
468
+ yield RadioButton(gettext("last values"), value=True)
469
+ for name in sorted(self._presets):
470
+ yield RadioButton(escape(name))
471
+ else:
472
+ # Empty state teaches the Ctrl+S affordance precisely when the user
473
+ # has no presets and most needs to learn it (spec §2).
474
+ yield Static(
475
+ gettext("none yet — fill the form and press Ctrl+S to save one"),
476
+ id="preset-empty",
477
+ )
478
+ with VerticalScroll(id="form-body"):
479
+ for f in self._plan.fields:
480
+ yield FieldRow(f, self._prefill.get(f.key, ""))
481
+ if self._include_extra:
482
+ extra_field = flows.FormField(
483
+ key=_EXTRA_KEY,
484
+ label=gettext("Extra arguments (passed to the script as-is)"),
485
+ source="flag",
486
+ )
487
+ last_extra = argstate.load_state(self._entry.slug)["extra_args"]
488
+ # shlex.join, because collect() shlex.split()s: an argument that
489
+ # contains spaces must survive the round trip as ONE argument.
490
+ import shlex
491
+
492
+ yield FieldRow(extra_field, shlex.join(last_extra))
493
+ yield Static(
494
+ tui_footer.bar(
495
+ tui_footer.chip("screen.submit", "Enter", gettext("Run")),
496
+ tui_footer.chip("screen.insert_token", "Ctrl+T", gettext("Insert value")),
497
+ tui_footer.chip("screen.save_preset", "Ctrl+S", gettext("Save as preset")),
498
+ tui_footer.chip("screen.cancel", "Esc", gettext("Cancel")),
499
+ ),
500
+ id="form-keys",
501
+ markup=True,
502
+ )
503
+
504
+ @on(RadioSet.Changed, "#preset-set")
505
+ def _apply_preset(self, event: RadioSet.Changed) -> None:
506
+ """Chip switch: overlay the whole preset onto the fields ("last values" restores)."""
507
+ index = event.radio_set.pressed_index
508
+ names = sorted(self._presets)
509
+ chosen = (
510
+ self._prefill if index == 0 else {**self._prefill, **self._presets[names[index - 1]]}
511
+ )
512
+ for row in self.query(FieldRow):
513
+ if row.field.key != _EXTRA_KEY:
514
+ row.set_value(chosen.get(row.field.key, ""))
515
+
516
+ def _rows(self) -> list[FieldRow]:
517
+ return [row for row in self.query(FieldRow) if row.field.key != _EXTRA_KEY]
518
+
519
+ def collect(self) -> tuple[dict[str, str], list[str]]:
520
+ import shlex
521
+
522
+ values = {row.field.key: row.value for row in self._rows()}
523
+ extra_row = next((row for row in self.query(FieldRow) if row.field.key == _EXTRA_KEY), None)
524
+ if extra_row is None:
525
+ return values, []
526
+ try:
527
+ extra = shlex.split(extra_row.value)
528
+ except ValueError:
529
+ extra = [extra_row.value] if extra_row.value else []
530
+ return values, extra
531
+
532
+ def action_submit(self) -> None:
533
+ values, extra = self.collect()
534
+ errors = flows.validate(self._plan, values)
535
+ if errors:
536
+ for row in self._rows():
537
+ row.show_error(errors.get(row.field.key))
538
+ return
539
+ self.dismiss((values, extra))
540
+
541
+ def action_cancel(self) -> None:
542
+ self.dismiss(None)
543
+
544
+ def action_insert_token(self, key: str = "") -> None:
545
+ """Ctrl+T (or the ▾ link on a field): insert a run-time value token at the
546
+ cursor of the focused text field. Secrets are excluded — their values skip
547
+ token expansion by design."""
548
+ if key:
549
+ row = self.query_one(f"#fr-{key}", FieldRow)
550
+ else:
551
+ focused = self.focused
552
+ if not isinstance(focused, Input):
553
+ return
554
+ row = next((a for a in focused.ancestors if isinstance(a, FieldRow)), None)
555
+ if row is None:
556
+ return
557
+ if not row.insertable:
558
+ return
559
+ target = row.query_one(Input)
560
+ target.focus()
561
+
562
+ def _insert(token: str | None) -> None:
563
+ if token:
564
+ target.insert_text_at_cursor(token)
565
+ target.focus()
566
+
567
+ self.app.push_screen(TokenMenuModal(), _insert)
568
+
569
+ def action_save_preset(self) -> None:
570
+ values, _extra = self.collect()
571
+
572
+ def _named(name: str | None) -> None:
573
+ if not name:
574
+ return
575
+ argstate.save_preset(
576
+ self._entry.slug,
577
+ name,
578
+ {k: v for k, v in values.items() if v},
579
+ secret_names=self._plan.secret_names,
580
+ )
581
+ self._presets = argstate.load_state(self._entry.slug)["presets"]
582
+ self.notify(
583
+ gettext('Preset "%(preset)s" saved.') % {"preset": name}, severity="information"
584
+ )
585
+
586
+ self.app.push_screen(PresetNameModal(set(self._presets)), _named)