diff --git a/Lucy.py b/Lucy.py index c3d966f..688ec31 100644 --- a/Lucy.py +++ b/Lucy.py @@ -10,6 +10,8 @@ except ImportError: # Windows: handled by windows_main curses = None +ESCAPE_DELAY_MS = 50 + MIN_TERM_HEIGHT = 15 MIN_TERM_WIDTH = 65 INSTALL_ENV = {"LUCY_PIXI_AUTO_UPGRADE": "1"} @@ -64,11 +66,11 @@ def confirm(prompt): def windows_main(): """Entry point on Windows, which has neither curses for the TUI nor tmux to drive. - install.py runs interactively so its pixi and MSVC prompts reach the user. + Lucy-Setup.exe is the supported installer; this covers running Lucy.py directly. """ if not is_installed(): print("Lucy is not installed in this workspace. Running install.py") - rc = run_command([sys.executable, "install.py"], interactive=True, extra_env=INSTALL_ENV) + rc = run_command([sys.executable, "install.py"], extra_env=INSTALL_ENV) if rc != 0: print(f"\nInstall failed with exit code {rc}.", file=sys.stderr) return rc @@ -95,37 +97,18 @@ def check_prereqs(): return False return True -def run_command(command, interactive=False, extra_env=None): - """Runs a command. +def run_command(command, extra_env=None): + """Runs a command with standard IO inherited. - If interactive is True, runs natively in the terminal. + The child keeps the real terminal, so its output is not block-buffered, git + and pixi report progress, and credential prompts reach the user. """ print(f"--- Running: {' '.join(command)} ---") env = os.environ.copy() if extra_env: env.update(extra_env) try: - if interactive: - # Inherit standard IO to maintain terminal size and TTY functionality - return subprocess.run(command, env=env).returncode - else: - # Popen is fine for non-interactive scripts like install/build - process = subprocess.Popen( - command, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - env=env, - stdin=subprocess.DEVNULL, - ) - while True: - output = process.stdout.readline() - if output == '' and process.poll() is not None: - break - if output: - print(output.strip()) - return process.poll() - + return subprocess.run(command, env=env).returncode except FileNotFoundError: print(f"Error: Command '{command[0]}' not found. Make sure it's in your PATH and executable.") return -1 @@ -133,6 +116,13 @@ def run_command(command, interactive=False, extra_env=None): print(f"An error occurred: {e}") return -1 +def use_responsive_escape(): + """ESC starts every arrow and function key sequence, so ncurses delays + reporting a lone ESC. Call after curses is initialised.""" + if hasattr(curses, "set_escdelay"): + curses.set_escdelay(ESCAPE_DELAY_MS) + + def not_installed_screen(stdscr): """First-run screen shown when the workspace isn't built yet. Returns True to install, False to close. (Terminal size is guaranteed by the @@ -140,6 +130,7 @@ def not_installed_screen(stdscr): curses.curs_set(0) stdscr.nodelay(0) stdscr.timeout(-1) + use_responsive_escape() is_dev_mode = get_dev_mode() @@ -193,6 +184,7 @@ def main_tui(stdscr): curses.curs_set(0) stdscr.nodelay(0) stdscr.timeout(-1) + use_responsive_escape() curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_CYAN, -1) @@ -225,6 +217,9 @@ def main_tui(stdscr): key = stdscr.getch() + if key in (ord('q'), ord('Q'), ord('x'), ord('X'), 27): # q / x / ESC + return None + if key == curses.KEY_UP: current_idx = (current_idx - 1 + len(options)) % len(options) if options[current_idx] == "---": @@ -242,19 +237,18 @@ def main_tui(stdscr): elif selected_option == "Update": return { "cmd": [sys.executable, "install.py"], - "interactive": False, "name": "Install", "extra_env": INSTALL_ENV, } elif selected_option == "Rebuild": return { "cmd": ["pixi", "run", "build"], - "interactive": False, "name": "Rebuild", "followup": [["pixi", "run", "panel-install"]], } elif selected_option == "Launch": - return {"cmd": ["./launch_lucy.sh"], "interactive": True, "name": "Launch"} + # Hands the terminal to the stack; the menu does not come back. + return {"cmd": ["./launch_lucy.sh"], "name": "Launch", "exits_menu": True} elif selected_option == "Exit": return None @@ -276,9 +270,6 @@ def check_initial_size(): print(f"Please increase the terminal size to at least {MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} characters.", file=sys.stderr) sys.exit(1) - if not check_prereqs(): - sys.exit(1) - # First run: nothing built yet — offer to install before showing the menu. if not is_installed(): try: @@ -302,6 +293,9 @@ def check_initial_size(): print("Press Enter to continue to the menu.") input() + if not check_prereqs(): + sys.exit(1) + while True: task = None try: @@ -324,17 +318,13 @@ def check_initial_size(): # User selected Exit break - rc = run_command( - task["cmd"], - interactive=task.get("interactive", False), - extra_env=task.get("extra_env"), - ) + rc = run_command(task["cmd"], extra_env=task.get("extra_env")) for follow in task.get("followup", []): if rc != 0: break - rc = run_command(follow, interactive=False, extra_env=task.get("extra_env")) + rc = run_command(follow, extra_env=task.get("extra_env")) - if task.get("interactive", False): + if task.get("exits_menu", False): print(f"--- Session finished with exit code {rc} ---") break diff --git a/install.py b/install.py index 22a35a8..e8a7a1a 100644 --- a/install.py +++ b/install.py @@ -89,7 +89,7 @@ def env_flag(name: str, env: Optional[dict] = None) -> bool: def default_run_command(command: list[str], check: bool = True, cwd: Optional[str] = None) -> int: - print(f"--- Running: {' '.join(command)} ---") + print(f"--- Running: {' '.join(command)} ---", flush=True) code = subprocess.run(command, cwd=cwd, check=False).returncode if check and code != 0: raise subprocess.CalledProcessError(code, command) diff --git a/tests/test_install_output.py b/tests/test_install_output.py new file mode 100644 index 0000000..6c7b8c6 --- /dev/null +++ b/tests/test_install_output.py @@ -0,0 +1,54 @@ +"""Tests for how install.py surfaces the progress of a long install.""" + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import install # noqa: E402 + + +def test_run_command_leaves_child_stdio_inherited(monkeypatch): + """Capturing a child would hide git/ssh passphrase and host-key prompts, + and would stop git and pixi reporting their own progress.""" + seen = {} + + def fake_run(command, cwd=None, check=False, **kwargs): + seen.update(kwargs) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(install.subprocess, "run", fake_run) + install.default_run_command(["git", "clone", "x", "y"]) + + assert not {"stdout", "stderr", "stdin"} & set(seen) + + +def test_run_command_streams_child_output_live(capfd): + install.default_run_command([sys.executable, "-c", "print('live output')"]) + assert "live output" in capfd.readouterr().out + + +def test_run_command_announcement_is_flushed(capfd): + """The announcement flushes the whole stdout buffer, so log lines written + before it stay in order ahead of the child's output when piped.""" + install.default_run_command([sys.executable, "-c", "pass"]) + assert "--- Running:" in capfd.readouterr().out + + +def test_clone_does_not_force_progress(): + """--progress only takes effect off a TTY, where its \\r frames garble + line-based consumers such as Lucy-Setup.exe. git covers the TTY case.""" + commands = [] + + def record(command, check=True, cwd=None): + commands.append(command) + return 0 + + install.fetch_repo_git("foo", "git@example.com:foo.git", "dev", "/nonexistent/foo", + "install", record, lambda _msg: None) + + assert commands == [["git", "clone", "-b", "dev", + "git@example.com:foo.git", "/nonexistent/foo"]] diff --git a/tests/test_menu_keys.py b/tests/test_menu_keys.py new file mode 100644 index 0000000..22e432b --- /dev/null +++ b/tests/test_menu_keys.py @@ -0,0 +1,85 @@ +"""Tests for Lucy.py's menu key handling.""" + +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import Lucy # noqa: E402 + + +class _StubCurses: + """Just the surface main_tui touches.""" + KEY_UP, KEY_DOWN = 259, 258 + A_BOLD, A_DIM, A_NORMAL = 1, 2, 0 + COLOR_CYAN = 6 + + def __init__(self): self.escdelay = None + def set_escdelay(self, ms): self.escdelay = ms + def curs_set(self, _): pass + def start_color(self): pass + def use_default_colors(self): pass + def init_pair(self, *_): pass + + +class _StubScreen: + def __init__(self, keys, size=(24, 80)): + self._keys = list(keys) + self._size = size + self.written = [] + + def getmaxyx(self): return self._size + def clear(self): pass + def refresh(self): pass + def nodelay(self, _): pass + def timeout(self, _): pass + def addstr(self, _y, _x, text, *_): self.written.append(text) + def getch(self): return self._keys.pop(0) + + +@pytest.fixture +def stub_curses(monkeypatch): + stub = _StubCurses() + monkeypatch.setattr(Lucy, "curses", stub) + return stub + + +@pytest.mark.parametrize("key", ["q", "Q", "x", "X"]) +def test_quit_keys_leave_the_menu(stub_curses, key): + assert Lucy.main_tui(_StubScreen([ord(key)])) is None + + +def test_escape_leaves_the_menu(stub_curses): + assert Lucy.main_tui(_StubScreen([27])) is None + + +def test_footer_fits_the_minimum_terminal_width(stub_curses): + screen = _StubScreen([ord("q")]) + Lucy.main_tui(screen) + # addstr starts at column 2 and curses errors on overflow. + assert max(len(line) for line in screen.written) + 2 <= Lucy.MIN_TERM_WIDTH + + +def test_navigation_keys_still_work(stub_curses): + """Down then quit: arrows must not be swallowed by the new branch.""" + screen = _StubScreen([_StubCurses.KEY_DOWN, _StubCurses.KEY_UP, ord("q")]) + assert Lucy.main_tui(screen) is None + + +def test_escape_delay_is_shortened(stub_curses): + """ncurses defaults to 1000 ms, which makes ESC feel frozen next to q and x.""" + Lucy.main_tui(_StubScreen([ord("q")])) + assert stub_curses.escdelay == Lucy.ESCAPE_DELAY_MS + assert 0 < Lucy.ESCAPE_DELAY_MS <= 100 + + +def test_escape_delay_is_optional(monkeypatch): + """Builds without set_escdelay must still run the menu.""" + stub = _StubCurses() + monkeypatch.delattr(_StubCurses, "set_escdelay") + monkeypatch.setattr(Lucy, "curses", stub) + assert Lucy.main_tui(_StubScreen([ord("q")])) is None