diff --git a/memory-update.md b/memory-update.md index 022d2cd3..b2dc24ca 100644 --- a/memory-update.md +++ b/memory-update.md @@ -240,3 +240,12 @@ - Harness validation results: - `scripts/ai_tui2_agent_test.py --scenario extended`: passed - `scripts/ai_tui_agent_test.py --scenario core`: passed + +## 2026-03-01 Installer + TUI v2 preflight fix (gray-screen mitigation) +- Root cause found for "blank/gray screen" reports: TUI v2 was initializing `PasswordManager` inside Textual mount via `entry_service_factory`, so password/setup prompts could occur behind the Textual screen and appear as a frozen UI. +- Updated `src/seedpass/cli/__init__.py` to pre-initialize entry service before launching Textual (`_prime_tui2_service`), then pass the ready service into `launch_tui2`. +- Added explicit fallback/error behavior: + - default `seedpass`: preflight failures now fall back to legacy TUI with details. + - `seedpass tui2 --no-fallback-legacy`: preflight failures now return exit code 1 with a clear error. +- Updated installer `scripts/install.sh` to run `seedpass tui2 --check` after install and warn when Textual runtime is unavailable, plus print a legacy fallback hint for users seeing blank/gray startup. +- Added regression coverage in `src/tests/test_typer_cli.py` for preflight behavior and adapted existing TUI launch tests to mock preflight service creation. diff --git a/scripts/install.sh b/scripts/install.sh index 33b0b97d..50a3dbd0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -218,6 +218,14 @@ main() { if ! "$VENV_DIR/bin/python" -c "import seedpass.cli; print('ok')"; then print_error "SeedPass CLI import check failed." fi + if "$VENV_DIR/bin/seedpass" tui2 --check >/tmp/seedpass-tui2-check.json 2>/dev/null; then + if ! grep -q '"textual_available":[[:space:]]*true' /tmp/seedpass-tui2-check.json; then + print_warning "TUI v2 runtime check reports Textual as unavailable." + print_warning "You can run legacy mode with: seedpass --legacy-tui" + fi + else + print_warning "Unable to run 'seedpass tui2 --check' during install validation." + fi deactivate @@ -257,6 +265,7 @@ EOF2 # 8. Final instructions print_success "Installation/update complete!" print_info "You can now launch the interactive TUI by typing: seedpass" + print_info "If your terminal only shows a blank/gray screen, run: seedpass --legacy-tui" print_info "'seedpass' resolves to: $(command -v seedpass)" if [[ ":$PATH:" != *":$LAUNCHER_DIR:"* ]]; then print_warning "Directory '$LAUNCHER_DIR' is not in your PATH." diff --git a/src/seedpass/cli/__init__.py b/src/seedpass/cli/__init__.py index 82d0ef17..d38072fa 100644 --- a/src/seedpass/cli/__init__.py +++ b/src/seedpass/cli/__init__.py @@ -82,6 +82,20 @@ def _gui_backend_available() -> bool: return False +def _prime_tui2_service( + ctx: typer.Context, +) -> tuple[Optional[object], Optional[Exception]]: + """Initialize entry service before Textual mounts. + + PasswordManager initialization may prompt for credentials. Doing that before + entering Textual prevents hidden prompt stalls that appear as a blank screen. + """ + try: + return _get_entry_service(ctx), None + except Exception as exc: # pragma: no cover - defensive runtime path + return None, exc + + @app.callback(invoke_without_command=True) def main( ctx: typer.Context, @@ -107,9 +121,18 @@ def main( tui = importlib.import_module("main") raise typer.Exit(tui.main(fingerprint=fingerprint)) + service, preflight_error = _prime_tui2_service(ctx) + if preflight_error is not None: + typer.echo( + "TUI v2 preflight failed; falling back to legacy TUI. " + f"Details: {preflight_error}" + ) + tui = importlib.import_module("main") + raise typer.Exit(tui.main(fingerprint=fingerprint)) + launched = launch_tui2( fingerprint=fingerprint, - entry_service_factory=lambda: _get_entry_service(ctx), + entry_service_factory=lambda: service, ) if launched: return @@ -150,9 +173,24 @@ def tui2( return fingerprint = (ctx.obj or {}).get("fingerprint") + service, preflight_error = _prime_tui2_service(ctx) + if preflight_error is not None: + if fallback_legacy: + typer.echo( + "TUI v2 preflight failed; falling back to legacy TUI. " + f"Details: {preflight_error}" + ) + tui = importlib.import_module("main") + raise typer.Exit(tui.main(fingerprint=fingerprint)) + typer.echo( + f"TUI v2 preflight failed: {preflight_error}", + err=True, + ) + raise typer.Exit(1) + launched = launch_tui2( fingerprint=fingerprint, - entry_service_factory=lambda: _get_entry_service(ctx), + entry_service_factory=lambda: service, ) if launched: return diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 0a0c979e..9c8cf764 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -981,6 +981,7 @@ def test_root_tui2_forward_fingerprint(monkeypatch): called["factory"] = kwargs.get("entry_service_factory") return True + monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) monkeypatch.setattr(cli, "launch_tui2", fake_launch) result = runner.invoke(app, ["--fingerprint", "abc"]) assert result.exit_code == 0 @@ -1019,6 +1020,7 @@ def test_tui2_check(monkeypatch): def test_tui2_unavailable_without_fallback(monkeypatch): + monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) result = runner.invoke(app, ["tui2", "--no-fallback-legacy"]) assert result.exit_code == 1 @@ -1027,6 +1029,7 @@ def test_tui2_unavailable_without_fallback(monkeypatch): def test_tui2_fallback_legacy(monkeypatch): called = {} + monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) def fake_main(*, fingerprint=None): @@ -1046,6 +1049,7 @@ def test_tui2_fallback_legacy(monkeypatch): def test_root_fallback_legacy_when_tui2_unavailable(monkeypatch): called = {} + monkeypatch.setattr(cli, "_get_entry_service", lambda _ctx: object()) monkeypatch.setattr(cli, "launch_tui2", lambda **_: False) def fake_main(*, fingerprint=None): @@ -1063,6 +1067,17 @@ def test_root_fallback_legacy_when_tui2_unavailable(monkeypatch): assert "falling back to legacy TUI" in result.stdout +def test_tui2_preflight_failure_without_fallback(monkeypatch): + monkeypatch.setattr( + cli, + "_get_entry_service", + lambda _ctx: (_ for _ in ()).throw(RuntimeError("unlock failed")), + ) + result = runner.invoke(app, ["tui2", "--no-fallback-legacy"]) + assert result.exit_code == 1 + assert "preflight failed" in result.stderr.lower() + + def test_gui_command(monkeypatch): called = {}