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
24 changes: 24 additions & 0 deletions keel/commands/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from __future__ import annotations

import sys
import time
from collections.abc import Callable
from dataclasses import dataclass
Expand Down Expand Up @@ -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 ----------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
55 changes: 55 additions & 0 deletions tests/commands/test_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
ScreenLine,
_freshness_style,
_paint,
_stdio_is_interactive,
_style_attrs,
build_screen,
render_plain,
Expand Down Expand Up @@ -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)
Loading