diff --git a/keel/commands/tui.py b/keel/commands/tui.py index 120659b2..4a53fd54 100644 --- a/keel/commands/tui.py +++ b/keel/commands/tui.py @@ -22,6 +22,7 @@ from __future__ import annotations +import sys import time from collections.abc import Callable from dataclasses import dataclass @@ -336,6 +337,23 @@ def _loop(stdscr: Any) -> None: curses.wrapper(_loop) except KeyboardInterrupt: pass + except curses.error as exc: + # `curses.wrapper` can raise before the loop even runs -- e.g. `cbreak() returned ERR` + # when stdin/stdout is not a real, controlling terminal (a captured pipe, a harness that + # only fakes a TTY). The `_stdio_is_interactive` pre-check in `tui_cmd` catches the common + # case up front; this is the belt-and-braces for a TTY that passes `isatty()` yet still + # can't be put into cbreak mode. Turn the raw traceback into a clean, actionable message. + raise click.ClickException( + f"keel tui could not start a terminal UI ({exc}). It needs a real interactive " + "terminal; run it directly in one, or use `keel tui --once` for a one-shot snapshot." + ) from exc + + +def _stdio_is_interactive() -> bool: + """True only when BOTH stdin and stdout are real TTYs -- curses needs to read keypresses AND + own the screen, so either one being a pipe/redirect means the full-screen loop cannot run. + Kept as its own function so tests can patch it (and so the check reads as one intent).""" + return sys.stdin.isatty() and sys.stdout.isatty() # -- the command ---------------------------------------------------------------------------- @@ -384,4 +402,10 @@ def open_state() -> tuple[Repository, Config]: click.echo(DISCLAIMER) return + if not _stdio_is_interactive(): + raise click.ClickException( + "keel tui needs an interactive terminal (a real TTY on both stdin and stdout). " + "Run it directly in a terminal, or use `keel tui --once` for a one-shot snapshot." + ) + run_live(open_state, now_fn, interval) diff --git a/tests/commands/test_tui.py b/tests/commands/test_tui.py index 4b4e30ce..7129e9c9 100644 --- a/tests/commands/test_tui.py +++ b/tests/commands/test_tui.py @@ -31,6 +31,7 @@ ScreenLine, _freshness_style, _paint, + _stdio_is_interactive, _style_attrs, build_screen, render_plain, @@ -595,3 +596,57 @@ def test_tui_negative_interval_is_rejected(tmp_path, valid_config_path) -> None: ) assert result.exit_code != 0 + + +# -- interactive-terminal guard (no curses under CliRunner / pipes) ---------------------------- + + +def test_stdio_is_interactive_requires_both_tty(monkeypatch: pytest.MonkeyPatch) -> None: + """True only when BOTH stdin and stdout are TTYs -- either being a pipe means the full-screen + loop cannot run.""" + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + assert _stdio_is_interactive() is True + + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + assert _stdio_is_interactive() is False + + +def test_tui_without_once_needs_interactive_terminal(tmp_path, valid_config_path) -> None: + """`keel tui` (live) under CliRunner -- stdin/stdout are not TTYs -- must fail with a clean, + actionable message pointing at `--once`, NOT enter curses and dump a traceback.""" + db_path = tmp_path / "keel.db" + _repo_at(db_path).set_state("kill_switch", False) + + result = CliRunner().invoke( + cli, ["--db", str(db_path), "--config", str(valid_config_path), "tui"] + ) + + assert result.exit_code != 0 + assert "interactive terminal" in result.output.lower() + assert "--once" in result.output + + +def test_run_live_wraps_curses_error_as_clickexception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Belt-and-braces: if `curses.wrapper` itself raises `curses.error` (e.g. `cbreak() + returned ERR` on a TTY that passes `isatty()` but can't be put into cbreak mode), `run_live` + turns it into a `click.ClickException` with a helpful message rather than a raw traceback.""" + import click + + fake_curses = _fake_curses() + + def _raise_wrapper(_fn: Any) -> None: + raise fake_curses.error("cbreak() returned ERR") + + fake_curses.wrapper = _raise_wrapper + monkeypatch.setitem(sys.modules, "curses", fake_curses) + + def _must_not_open() -> Any: + raise AssertionError("open_state must not be called -- wrapper raised first") + + with pytest.raises(click.ClickException) as excinfo: + run_live(_must_not_open, lambda: 0, 5.0) + + assert "--once" in str(excinfo.value)