Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion src/ntask/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 9 additions & 6 deletions src/ntask/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 18 additions & 4 deletions src/ntask/_render/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
62 changes: 62 additions & 0 deletions tests/test_render_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading