fix(tui): restore terminal cursor on exit (Linux/tmux)#240
Conversation
On Linux terminals (VTE family, tmux, kitty) cursor visibility is tracked independently of the alternate-screen mode. Leaving the alternate screen does not automatically restore a hidden cursor — so after pressing q, Ctrl-C, or a panic the shell cursor remained invisible, requiring the user to run `reset`. Root cause: TerminalManager::Drop emitted LeaveAlternateScreen but not cursor::Show; and the SIGINT/SIGTERM handlers called std::process::exit(0) directly, bypassing Drop entirely. Fix: - Add TERMINAL_NEEDS_RESTORE (AtomicBool) and restore_terminal() in terminal_manager.rs. The function is idempotent: a single atomic swap ensures that only the first caller does the actual cleanup, making it safe to call concurrently from Drop, signal handlers, and a panic hook. - Set the flag in TerminalManager::initialize() after the alternate screen is entered; clear it in restore_terminal() after cleanup. - Replace the inline execute! in TerminalManager::Drop with a call to restore_terminal() so cursor::Show is included on the normal q-exit path. - Install a panic hook (via Once) inside TerminalManager::new() that calls restore_terminal() before delegating to the previous hook, so panic backtraces render on a usable terminal. - Call view::terminal_manager::restore_terminal() in both the SIGINT and SIGTERM handlers in main.rs before std::process::exit(0). The function is a no-op for subcommands that never initialise the TUI. - Rename setup_panic_handler to setup_panic_handlers and remove the macOS- only cfg gate; the macOS-specific shutdown call stays gated inside the closure. The call site in main() becomes unconditional. - Update the stale comment in ui_loop.rs that claimed LeaveAlternateScreen resets cursor visibility (which was the bug). Tests added in terminal_manager.rs verify: no-op when flag is unset, flag is cleared after restore_terminal(), idempotent repeated calls.
Implementation Review SummaryIntent
FindingsLOW
NIT
Verification
Cross-cutting checks confirmed
VerdictAPPROVE — implementation is correct, well-commented, well-tested, and matches the issue's acceptance criteria. The three LOW/NIT items are non-blocking polish suggestions. |
Reword the comment in restore_terminal() that incorrectly claimed "no locks, no shared state": stdout() does hold an internal stdlib mutex briefly inside execute!, so the accurate claim is that no *application-owned* locks are held across the call — which is what matters for panic-hook and Drop safety. Add a module-level TEST_MUTEX (std::sync::Mutex) to the tests block so all three tests that mutate the process-global TERMINAL_NEEDS_RESTORE flag run sequentially even when cargo parallelises unit tests within a binary. No new dev-dependency needed.
PR Finalization CompleteSummaryComment fix — The misleading "no locks, no shared state" comment at Test serialization — Added a module-level Documentation — No Lint/Format — VerificationCommit: 65a3013 — ready for merge. |
Summary
On Linux terminals (VTE family, tmux, kitty) cursor visibility is tracked independently of the alternate-screen mode. Leaving the alternate screen does not automatically restore a hidden cursor — so after pressing
q, Ctrl-C, or a panic the shell cursor remained invisible, requiring the user to runreset.Root cause
Two independent failure modes:
TerminalManager::DropemittedLeaveAlternateScreenbut notcursor::Show, so the normalq-exit path left the cursor hidden on non-Apple terminals.std::process::exit(0)directly, which bypasses allDropimpls — so even addingcursor::ShowtoDropalone would not fix Ctrl-C. (See issue fix(tui): restore terminal cursor on exit (Linux/tmux leave cursor hidden) #235 for full analysis.)Changes
src/view/terminal_manager.rs: AddedTERMINAL_NEEDS_RESTORE(AtomicBool) static flag and a publicrestore_terminal()function. The flag is set ininitialize()after the alternate screen is entered and cleared atomically inrestore_terminal(). The function is idempotent — safe to call concurrently fromDrop, signal handlers, and a panic hook. AddedPANIC_HOOK_INSTALLED(Once) to install a panic hook exactly once; the hook callsrestore_terminal()before delegating to the previous hook so panic backtraces render on a usable terminal.TerminalManager::Dropnow delegates torestore_terminal()(including explicitcursor::Show). Added 3 unit tests for the swap semantics and idempotency.src/main.rs: Addedview::terminal_manager::restore_terminal()calls at the start of both the SIGINT and SIGTERM handlers, beforestd::process::exit(0). Renamedsetup_panic_handlertosetup_panic_handlersand removed the#[cfg(target_os = "macos")]gate on the function definition; the macOS-specific native-metrics shutdown remains gated inside the closure. The call site inmain()is now unconditional.src/view/ui_loop.rs: Updated stale comment that claimedLeaveAlternateScreenresets cursor visibility — which was precisely the bug.Verification
Automated checks run:
cargo check --features cli --libcargo check --features cli --lib --testscargo clippy --features cli --lib --tests -- -D warningscargo test --features cli --bin all-smi -- view::terminal_manager::testscargo fmt --checkAcceptance criteria requiring manual verification by the reviewer:
qon Linux (TTY + VTE terminal) — cursor must be visible in the shell afterward.qinside tmux — cursor must be visible.reset.The macOS / iTerm2 regression check (idempotent
cursor::Showis safe when cursor already visible) does not require separate verification — the escape sequence is a no-op when the cursor is already shown.Closes #235