From b7d69063627586856436c92f878d2eda11a5d108 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:36 +1000 Subject: [PATCH 1/3] feat: add apps extension pack --- README.md | 1 + dev/gui.py | 93 ++++++++++++++++++++++++----- packs/apps.man | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 packs/apps.man diff --git a/README.md b/README.md index d397d3b..573fa6d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ From a local checkout, use the shorter equivalents: ./run.sh man disk ./run.sh clean ./run.sh fetch --plain +./run.sh apps snapshot timer --seconds 1500 --elapsed 60 --json ./run.sh games list --json ./run.sh science snapshot orbit --json ./run.sh screensaver snapshot rain --seed 1 --json diff --git a/dev/gui.py b/dev/gui.py index 2800f2e..ed6f3a3 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -193,6 +193,7 @@ class RunSelection: script: ScriptSpec flags: tuple[str, ...] working_directory: Path + routing_tokens: tuple[str, ...] = () @dataclass(frozen=True) @@ -1112,6 +1113,32 @@ def selected_arguments(self) -> tuple[str, ...] | None: ) return tuple(arguments) + def routing_tokens(self) -> tuple[str, ...]: + tokens: list[str] = [] + selected = self.controller_values() + for index, parameter in enumerate(self.script.parameters): + if not conditions_match(parameter.when, selected): + continue + widget = self.query_one(f"#parameter-{index}", Input | Select) + value = ( + widget.value.strip() + if isinstance(widget, Input) + else "" + if widget.value is Select.NULL + else str(widget.value) + ) + if value and parameter.choices: + tokens.append(value) + if value and parameter.flag is not None: + tokens.append(parameter.flag) + tokens.extend( + option.flag + for index, option in enumerate(self.script.options) + if conditions_match(option.when, selected) + and self.query_one(f"#option-{index}", Checkbox).value + ) + return tuple(dict.fromkeys(tokens)) + def working_directory(self) -> Path | None: value = self.query_one("#cwd", Input).value.strip() if not value: @@ -1142,9 +1169,9 @@ def update_step(self) -> None: self.query_one("#wizard-next", Button).label = ( ( "Preview" - if self.script.needs_apply(arguments or ()) + if self.script.needs_apply(self.routing_tokens()) else "Show command" - if self.script.needs_direct_terminal(arguments or ()) + if self.script.needs_direct_terminal(self.routing_tokens()) else "Run" ) if self.step == 2 @@ -1184,14 +1211,16 @@ def advance(self) -> None: and parameter.warning ) caution = "\n".join(f"- {warning}" for warning in warnings) - needs_apply = self.script.needs_apply(arguments) + routing_tokens = self.routing_tokens() + needs_apply = self.script.needs_apply(routing_tokens) flow = ( "This runs a read-only preview first, then shows the apply " "command for a direct interactive terminal." - if needs_apply and self.script.needs_direct_terminal(arguments) + if needs_apply + and self.script.needs_direct_terminal(routing_tokens) else "This interactive tool needs the terminal. Confirm to show the " "exact command to run." - if self.script.needs_direct_terminal(arguments) + if self.script.needs_direct_terminal(routing_tokens) else "This runs a read-only preview first. Apply repeats " "discovery with the same settings, so targets may change " "between runs. A successful preview must be followed by `CLEAN`." @@ -1210,7 +1239,14 @@ def advance(self) -> None: arguments = self.selected_arguments() if arguments is not None: - self.dismiss(RunSelection(self.script, arguments, directory)) + self.dismiss( + RunSelection( + self.script, + arguments, + directory, + self.routing_tokens(), + ) + ) class ConfirmApply(ModalScreen[bool]): @@ -2140,9 +2176,9 @@ async def run_once( @work(group="script", exclusive=True) async def run_selection(self, selection: RunSelection) -> None: try: - needs_apply = selection.script.needs_apply(selection.flags) + needs_apply = selection.script.needs_apply(selection.routing_tokens) if ( - selection.script.needs_direct_terminal(selection.flags) + selection.script.needs_direct_terminal(selection.routing_tokens) and not needs_apply ): command = display_command( @@ -2165,7 +2201,7 @@ async def run_selection(self, selection: RunSelection) -> None: return assert selection.script.apply_flag is not None assert selection.script.yes_flag is not None - if selection.script.needs_direct_terminal(selection.flags): + if selection.script.needs_direct_terminal(selection.routing_tokens): direct_flags = (*selection.flags, selection.script.apply_flag) command = display_command(command_for(selection.script, direct_flags)) self.query_one("#activity-heading", Label).update( @@ -2308,8 +2344,8 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: "privilege": "user", "applyFlag": "--apply", "yesFlag": "--yes", - "applyWhen": ["fix"], - "directWhen": ["fix"], + "applyWhen": ["fix", "--query"], + "directWhen": ["--force"], "findingExitCodes": [1], "launchable": False, "parameters": [ @@ -2339,11 +2375,18 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: ], }, ], + "options": [ + { + "flag": "--force", + "label": "Force", + "when": {"action": ["fix"]}, + } + ], } help_path = script_path.with_name("probe.py.man") probe_markdown = ( - "# Probe\n\n`action`, `--query`, `format`, `--apply`, and `--yes` " - "exercise typed parameters.\n" + "# Probe\n\n`action`, `--query`, `format`, `--force`, `--apply`, " + "and `--yes` exercise typed parameters.\n" ) help_path.write_text( f"---\n{json.dumps(metadata)}\n---\n{probe_markdown}", @@ -2356,7 +2399,17 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: assert not probe.launchable assert not probe.needs_apply(("show",)) assert probe.needs_apply(("fix",)) - assert probe.needs_direct_terminal(("fix",)) + assert probe.needs_apply(("--query",)) + assert probe.needs_direct_terminal(("--force",)) + assert not probe.needs_direct_terminal(("fix",)) + safe_selection = RunSelection( + probe, + ("show", "--query", "fix", "text"), + contract_root, + ("show", "text"), + ) + assert not probe.needs_apply(safe_selection.routing_tokens) + assert not probe.needs_direct_terminal(safe_selection.routing_tokens) assert probe.has_findings(1) and not probe.has_findings(2) assert not conditions_match( probe.parameters[1].when, @@ -2483,16 +2536,24 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: assert isinstance(conditional_wizard, RunWizard) assert conditional_wizard.query_one("#parameter-2", Select).value is Select.NULL hidden_query = conditional_wizard.query_one("#parameter-1", Input) - hidden_query.value = "ignored" + hidden_query.value = "fix" assert not conditional_wizard.query_one("#parameter-row-1").display assert conditional_wizard.selected_arguments() == ("show",) + assert conditional_wizard.routing_tokens() == ("show",) conditional_wizard.query_one("#parameter-0", Select).value = "fix" await pilot.pause() assert conditional_wizard.query_one("#parameter-row-1").display assert conditional_wizard.selected_arguments() == ( "fix", "--query", - "ignored", + "fix", + ) + assert conditional_wizard.routing_tokens() == ("fix", "--query") + conditional_wizard.query_one("#option-0", Checkbox).value = True + assert conditional_wizard.routing_tokens() == ( + "fix", + "--query", + "--force", ) await pilot.press("escape") await pilot.pause() diff --git a/packs/apps.man b/packs/apps.man new file mode 100644 index 0000000..8f8d634 --- /dev/null +++ b/packs/apps.man @@ -0,0 +1,158 @@ +--- +{ + "summary": "Read text, inspect tables, present slides, paint text canvases, and run timers locally.", + "platform": "any", + "privilege": "user", + "executable": "apps", + "directWhen": ["open"], + "parameters": [ + { + "name": "action", + "label": "Action", + "required": true, + "default": "snapshot", + "choices": [ + {"value": "snapshot", "label": "Print a snapshot"}, + {"value": "open", "label": "Open interactive app"} + ] + }, + { + "name": "app", + "label": "App", + "required": true, + "default": "read", + "choices": [ + {"value": "read", "label": "Text reader"}, + {"value": "table", "label": "CSV or TSV table"}, + {"value": "slides", "label": "Markdown slides"}, + {"value": "paint", "label": "Text canvas"}, + {"value": "timer", "label": "Countdown timer"} + ] + }, + { + "name": "input", + "label": "Input file", + "required": true, + "placeholder": "notes.md, data.csv, or talk.md", + "when": {"app": ["read", "table", "slides"]} + }, + { + "name": "canvas", + "label": "Existing text canvas", + "placeholder": "optional drawing.txt", + "when": {"app": ["paint"]} + }, + { + "name": "find", + "label": "Find text", + "flag": "--find", + "placeholder": "case-insensitive query", + "when": {"app": ["read"]} + }, + { + "name": "delimiter", + "label": "Table delimiter", + "flag": "--delimiter", + "choices": [ + {"value": "comma", "label": "Comma"}, + {"value": "tab", "label": "Tab"} + ], + "when": {"app": ["table"]} + }, + { + "name": "limit", + "label": "Snapshot rows or lines", + "flag": "--limit", + "placeholder": "1 to 1000, default 20", + "when": {"action": ["snapshot"], "app": ["read", "table"]} + }, + { + "name": "page", + "label": "Slide page", + "flag": "--page", + "placeholder": "1 to 512, default 1", + "when": {"app": ["slides"]} + }, + { + "name": "width", + "label": "New canvas width", + "flag": "--width", + "placeholder": "1 to 240, default 40", + "when": {"app": ["paint"]} + }, + { + "name": "height", + "label": "New canvas height", + "flag": "--height", + "placeholder": "1 to 80, default 16", + "when": {"app": ["paint"]} + }, + { + "name": "output", + "label": "New paint output", + "flag": "--output", + "placeholder": "new drawing.txt", + "when": {"action": ["open"], "app": ["paint"]} + }, + { + "name": "seconds", + "label": "Timer seconds", + "flag": "--seconds", + "required": true, + "placeholder": "1 to 86400", + "when": {"app": ["timer"]} + }, + { + "name": "elapsed", + "label": "Elapsed seconds", + "flag": "--elapsed", + "placeholder": "0 to timer duration", + "when": {"action": ["snapshot"], "app": ["timer"]} + }, + { + "name": "label", + "label": "Timer label", + "flag": "--label", + "placeholder": "focus", + "when": {"app": ["timer"]} + }, + { + "name": "format", + "label": "Snapshot format", + "required": true, + "default": "--plain", + "choices": [ + {"value": "--plain", "label": "Plain text"}, + {"value": "--json", "label": "JSON"} + ], + "when": {"action": ["snapshot"]} + } + ] +} +--- +# Apps + +`apps` is the native local terminal workbench from +[`keys-i/tools`](https://github.com/keys-i/tools). It owns five focused modes: +`read` searches UTF-8 text, `table` parses quoted CSV or TSV, `slides` presents +Markdown pages separated by `---`, `paint` edits a plain-text canvas, and +`timer` runs a monotonic countdown. + +The `action` selects `snapshot` or `open`; `app` selects the mode. Supply an +`input` for read, table, or slides, or an optional paint `canvas`. Reader search +uses `--find`; tables accept `--delimiter`; read/table snapshots accept +`--limit`; slides accept `--page`. A new paint canvas accepts `--width` and +`--height`; do not set them with an existing canvas. Interactive paint accepts +`--output` and refuses to replace an existing file. Timers require `--seconds` +and accept snapshot `--elapsed` plus `--label`. Choose the snapshot `format` as +plain text or JSON. + +All input is opened once and capped at 32 MiB. Tables stop at 100,000 rows, 256 +columns, and two million cells. Paint saves a completed temporary file through +an atomic no-overwrite link. There is no network, TLS, browser, PDF codec, +Wayland compositor, PTY server, plugin, Python process, shell, or upstream +executable. + +Keyboard controls are shown in every mode. `q`, Escape, or Ctrl-C exits and the +terminal is restored. This pack contains only the typed UI contract; the +prepared `keys-tools` PyPI package has not been published. From a4fbba8a0a51403222c4494c59850662a989136d Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:11:22 +1000 Subject: [PATCH 2/3] style: format GUI routing --- dev/gui.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dev/gui.py b/dev/gui.py index ed6f3a3..0313bbc 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -1165,7 +1165,6 @@ def update_step(self) -> None: "wizard-review", )[self.step] self.query_one("#wizard-back", Button).disabled = self.step == 0 - arguments = self.selected_arguments() if self.step == 2 else () self.query_one("#wizard-next", Button).label = ( ( "Preview" @@ -1216,8 +1215,7 @@ def advance(self) -> None: flow = ( "This runs a read-only preview first, then shows the apply " "command for a direct interactive terminal." - if needs_apply - and self.script.needs_direct_terminal(routing_tokens) + if needs_apply and self.script.needs_direct_terminal(routing_tokens) else "This interactive tool needs the terminal. Confirm to show the " "exact command to run." if self.script.needs_direct_terminal(routing_tokens) From 214a09225654724092cde3b37b97036a0f982c83 Mon Sep 17 00:00:00 2001 From: Keys <70819367+keys-i@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:05:07 +1000 Subject: [PATCH 3/3] test: wait for help scroll animation --- dev/gui.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev/gui.py b/dev/gui.py index 0313bbc..aad8aa9 100755 --- a/dev/gui.py +++ b/dev/gui.py @@ -2498,14 +2498,14 @@ def layout_snapshot(app: ScriptsApp) -> dict[str, tuple[int, int, int, int]]: assert compact.query_one("#details-tabs", TabbedContent).active == "help-pane" assert compact.focused is help_scroll await pilot.press("end") - await pilot.pause() + await pilot.wait_for_scheduled_animations() assert help_scroll.scroll_y > 0 help_scroll.scroll_home(animate=False) await pilot.pause() help_scroll.post_message( MouseScrollDown(help_scroll, 1, 1, 0, 1, 0, False, False, False) ) - await pilot.pause() + await pilot.wait_for_scheduled_animations() assert help_scroll.scroll_y > 0 await pilot.press("f3") assert compact.query_one("#details-tabs", TabbedContent).active == "files-pane"