Skip to content

fix(tui): restore terminal cursor on exit (Linux/tmux leave cursor hidden) #235

Description

@inureyes

Problem / Background

After running all-smi on Linux and quitting with q, the terminal cursor remains hidden in the host shell. The user must manually run tput cnorm or reset to restore it. htop does not exhibit this behavior because it explicitly re-enables cursor visibility on exit (via ncurses endwin() which emits \e[?25h), and additionally installs signal handlers so SIGINT/SIGTERM/SIGSEGV still restore the cursor.

The bug is invisible on macOS and on some terminal emulators (notably iTerm2 and certain xterm builds) because those terminals happen to save/restore cursor visibility together with the alternate-screen buffer. On the Linux console (TTY), VTE-based terminals (GNOME Terminal, Konsole, etc.), and inside tmux/screen, the two states are independent — so the cursor stays hidden after exit.

Root Cause

src/view/ui_loop.rs lines 105-114 send cursor::Hide once at session start. The inline comment claims the cursor will be restored by TerminalManager::drop() via LeaveAlternateScreen:

// Hide cursor once at session start. The cursor is restored in
// TerminalManager::drop() (LeaveAlternateScreen resets it).
// This avoids per-frame Hide/Show churn.

But src/view/terminal_manager.rs lines 64-73 only send LeaveAlternateScreen + DisableMouseCapture + disable_raw_mode() — there is no cursor::Show:

impl Drop for TerminalManager {
    fn drop(&mut self) {
        if self.initialized {
            let mut stdout = stdout();
            let _ = execute!(stdout, LeaveAlternateScreen, DisableMouseCapture);
            let _ = disable_raw_mode();
        }
    }
}

The assumption that LeaveAlternateScreen (CSI ?1049l) restores cursor visibility (CSI ?25h) is wrong in the general case. By the DEC spec these are independent terminal modes, and only some terminals couple them. The current code relies on that coincidence, so the bug only manifests on terminals where the modes are truly independent — Linux TTY, VTE-based terminals, tmux/screen, etc.

Proposed Solution

  1. In src/view/terminal_manager.rs Drop impl, explicitly send cursor::Show along with LeaveAlternateScreen and DisableMouseCapture. Example:

    let _ = execute!(stdout, cursor::Show, LeaveAlternateScreen, DisableMouseCapture);

    Import crossterm::cursor accordingly.

  2. Make sure cursor restoration also runs on abnormal exit paths so the terminal isn't left broken when Drop doesn't run:

    • Install a panic hook that emits cursor::Show, LeaveAlternateScreen, DisableMouseCapture, and disable_raw_mode() before delegating to the default panic handler.
    • Install a Ctrl-C / signal handler (e.g. ctrlc crate or a signal stream) that performs the same cleanup before exiting. Keep this idempotent so the normal Drop path is still safe.
  3. Verify on Linux TTY, GNOME Terminal, and inside tmux that the cursor is visible after q, after Ctrl-C, and after an induced panic.

Acceptance Criteria

  • After quitting all-smi with q on Linux (TTY + at least one VTE terminal + tmux), the shell cursor is visible again.
  • After Ctrl-C on the same environments, the cursor is visible and the terminal is out of raw mode / alt-screen.
  • After a forced panic in the view loop, the cursor and screen are restored (no need to run reset).
  • No per-frame Show/Hide churn is introduced — the fix only affects setup/teardown paths, not the render loop.
  • The fix does not regress cursor handling on macOS / iTerm2 (idempotent Show is safe to emit even when cursor is already visible).

Technical Considerations

  • crossterm::cursor::Show maps to CSI ?25h and is safe to emit unconditionally on exit, including in environments where the cursor is already visible.
  • Panic hook should preserve and delegate to the existing default panic handler so backtraces and debug output are not lost.
  • Signal handler should set a flag and trigger cleanup rather than performing IO directly inside the handler when possible, but crossterm IO and disable_raw_mode are commonly used in such handlers and are acceptable here.
  • Reference: htop does the equivalent via ncurses endwin() (which calls curs_set(1)) and registers handlers for SIGINT/SIGTERM/SIGSEGV.

Priority

Marked priority:medium — the cursor is recoverable with tput cnorm or reset, but it's a visible regression that affects every Linux/tmux user on every exit.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions