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
68 changes: 29 additions & 39 deletions Lucy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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
Expand All @@ -95,51 +97,40 @@ 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
except Exception as e:
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
pre-check in __main__.)"""
curses.curs_set(0)
stdscr.nodelay(0)
stdscr.timeout(-1)
use_responsive_escape()

is_dev_mode = get_dev_mode()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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] == "---":
Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
54 changes: 54 additions & 0 deletions tests/test_install_output.py
Original file line number Diff line number Diff line change
@@ -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"]]
85 changes: 85 additions & 0 deletions tests/test_menu_keys.py
Original file line number Diff line number Diff line change
@@ -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