From a52a780a17395e0dc62ae9f6959dbcf79a7ef433 Mon Sep 17 00:00:00 2001 From: Sean N Date: Thu, 21 May 2026 11:16:49 +0200 Subject: [PATCH 1/2] Making sure the TUI sticks around after completion or until dismissed with Esc, Q or ctrl+c --- src/ntask/_cli.py | 15 ++++++---- src/ntask/_render/tui.py | 22 +++++++++++--- tests/test_render_tui.py | 62 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/ntask/_cli.py b/src/ntask/_cli.py index 2c993e9..1b088ec 100644 --- a/src/ntask/_cli.py +++ b/src/ntask/_cli.py @@ -406,17 +406,20 @@ async def _runner() -> int: def _run_executor() -> None: try: - anyio.run(executor.run, [target], {target: kwargs}) + result = anyio.run(executor.run, [target], {target: kwargs}) + renderer.summary( + ran=len(result.ran), cached=len(result.cached), + failed=len(result.failed), skipped=len(result.skipped), + ) except BaseException as exc: exc_holder[0] = exc - finally: - # Always exit the app so main thread's app.run() unblocks. - if tui_app.is_running: - tui_app.call_from_thread(tui_app.exit) + renderer.announce_error(f"{type(exc).__name__}: {exc}") + # The TUI stays mounted after the executor finishes so the user can + # actually read the final DAG state and summary; q/esc/ctrl+c dismiss. bg_thread = threading.Thread(target=_run_executor, daemon=False) bg_thread.start() - tui_app.run() # blocks on main thread until app.exit() + tui_app.run() # blocks until the user dismisses the TUI bg_thread.join() if renderer.final_summary: print(renderer.final_summary) diff --git a/src/ntask/_render/tui.py b/src/ntask/_render/tui.py index 64290ae..5581a3e 100644 --- a/src/ntask/_render/tui.py +++ b/src/ntask/_render/tui.py @@ -39,7 +39,11 @@ class _DAGApp(App[None]): #dag-tree { margin: 1 2; } #footer { dock: bottom; height: 1; padding: 0 2; color: $text-muted; } """ - BINDINGS: ClassVar[list[BindingType]] = [("ctrl+c", "quit", "Quit")] + BINDINGS: ClassVar[list[BindingType]] = [ + ("ctrl+c", "quit", "Quit"), + ("q", "quit", "Quit"), + ("escape", "quit", "Quit"), + ] TITLE = "ntask" def __init__(self, logs_dir: Path | None = None) -> None: @@ -142,9 +146,18 @@ def start(self, *, graph: Graph, logs_dir: Path) -> None: self._app.call_from_thread(self._app.build_tree, graph) def stop(self) -> None: - """Signal the app to exit. The CLI owns thread-join and summary print.""" + """No-op: the TUI persists until the user dismisses it via q/esc/ctrl+c. + + The CLI is responsible for joining the executor thread and re-raising + any held exception after ``app.run()`` returns. + """ + return + + def announce_error(self, message: str) -> None: + """Show an error message in the footer with the dismiss hint.""" + text = f"error: {message} · press q/esc to quit" if self._app.is_running: - self._app.call_from_thread(self._app.exit) + self._app.call_from_thread(self._app.update_summary, text) @property def final_summary(self) -> str | None: @@ -187,5 +200,6 @@ def summary( f" · logs: {self._logs_dir}" ) self._final_summary = text + footer = f"{text} · press q/esc to quit" if self._app.is_running: - self._app.call_from_thread(self._app.update_summary, text) + self._app.call_from_thread(self._app.update_summary, footer) diff --git a/tests/test_render_tui.py b/tests/test_render_tui.py index 84cb6fe..6cd7ab1 100644 --- a/tests/test_render_tui.py +++ b/tests/test_render_tui.py @@ -194,3 +194,65 @@ def test_tui_renderer_summary_stored_for_caller(tmp_path: Path): assert r.final_summary is not None assert "2 ran" in r.final_summary assert "1 cached" in r.final_summary + + +def test_tui_renderer_summary_shows_quit_hint_in_footer(tmp_path: Path): + """The footer is sticky on completion — user must press q/esc to dismiss.""" + from ntask._render.tui import TUIRenderer, _DAGApp + app = _DAGApp(logs_dir=tmp_path) + r = TUIRenderer(app=app) + t = _spawn_app(app) + try: + g = Graph(nodes=["build"], edges=[]) + r.start(graph=g, logs_dir=tmp_path) + r.summary(ran=1, cached=0, failed=0, skipped=0) + import time + time.sleep(0.2) + footer = app.query_one("#footer") + assert "press q/esc to quit" in str(footer.content) + finally: + _teardown_app(app, t) + + +def test_tui_renderer_stop_is_noop(tmp_path: Path): + """stop() must not close the app — the CLI relies on the user dismissing.""" + from ntask._render.tui import TUIRenderer, _DAGApp + app = _DAGApp(logs_dir=tmp_path) + r = TUIRenderer(app=app) + t = _spawn_app(app) + try: + r.stop() + import time + time.sleep(0.2) + assert app.is_running, "stop() must not exit the app" + finally: + _teardown_app(app, t) + + +def test_tui_renderer_announce_error_updates_footer(tmp_path: Path): + from ntask._render.tui import TUIRenderer, _DAGApp + app = _DAGApp(logs_dir=tmp_path) + r = TUIRenderer(app=app) + t = _spawn_app(app) + try: + g = Graph(nodes=["build"], edges=[]) + r.start(graph=g, logs_dir=tmp_path) + r.announce_error("RuntimeError: boom") + import time + time.sleep(0.2) + footer = app.query_one("#footer") + text = str(footer.content) + assert "RuntimeError: boom" in text + assert "press q/esc to quit" in text + finally: + _teardown_app(app, t) + + +def test_tui_app_quit_bindings_include_q_and_escape(): + """q and escape must trigger the quit action so users can dismiss the TUI.""" + from ntask._render.tui import _DAGApp + # BINDINGS entries are (key, action, description) tuples. + keys = {b[0] for b in _DAGApp.BINDINGS} + assert "q" in keys + assert "escape" in keys + assert "ctrl+c" in keys From d036ac40500c7058fb868ba02ef762eb7d52630f Mon Sep 17 00:00:00 2001 From: Sean N Date: Thu, 21 May 2026 11:17:47 +0200 Subject: [PATCH 2/2] Bumping the package version --- pyproject.toml | 2 +- src/ntask/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 29b1d0b..a2193ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ntask" -version = "1.0.0" +version = "1.1.0" description = "A Python-native task runner with content-hash caching and DAG execution." authors = [{name = "Sean Nieuwoudt", email = "sean@underwulf.com"}] license = {text = "BSD-3-Clause"} diff --git a/src/ntask/__init__.py b/src/ntask/__init__.py index c55478e..f3815b3 100644 --- a/src/ntask/__init__.py +++ b/src/ntask/__init__.py @@ -5,7 +5,7 @@ from ._shell import ShellResult, shell from ._task import cached, group, task -__version__ = "1.0.0" +__version__ = "1.1.0" __all__ = [ "CycleError", "DiscoveryError",