Skip to content

fix(tui): terminal corrupts to black screen after exiting shell session #2392

Description

@r3v5

Problem Statement

When using openshell term → selecting a sandbox → pressing [s] Shell → running commands → typing exit, the TUI corrupts to an entirely black screen. The TUI becomes unresponsive and must be force-quit. This affects the core sandbox interaction workflow in the TUI.

Technical Context

The TUI suspends itself before launching an SSH shell subprocess (leaves alternate screen, disables raw mode), then resumes after the subprocess exits (re-enables raw mode, re-enters alternate screen, clears, redraws). The resume path is missing several terminal state restoration steps that are present in other code paths, causing terminal state pollution from the SSH PTY session to persist into the restored TUI.

Affected Components

Component Key Files Role
TUI runtime crates/openshell-tui/src/lib.rs Terminal lifecycle, shell subprocess suspend/resume
TUI rendering crates/openshell-tui/src/ui.rs Frame drawing after resume
SSH utilities crates/openshell-core/src/forward.rs SSH command construction, ProxyCommand building

Technical Investigation

Architecture Overview

The TUI uses ratatui with a crossterm backend. Terminal state management follows the standard ratatui pattern:

  • Startup (lines 67-73): enable_raw_mode()EnterAlternateScreen + EnableMouseCaptureTerminal::new() (fresh buffers) → terminal.clear()
  • Shutdown (lines 436-443): disable_raw_mode()LeaveAlternateScreen + DisableMouseCaptureterminal.show_cursor()
  • Shell suspend (lines 964-971): LeaveAlternateScreen + DisableMouseCapturedisable_raw_mode()
  • Shell resume (lines 991-1003): enable_raw_mode()EnterAlternateScreen + EnableMouseCaptureterminal.clear()terminal.draw()events.discard_pending()events.resume()

The SSH subprocess is spawned with -tt (force PTY) and RequestTTY=force, inheriting stdin/stdout/stderr. This means the remote shell runs in its own PTY and writes directly to the parent terminal, bypassing ratatui entirely.

Code References

Location Description
crates/openshell-tui/src/lib.rs:67-73 TUI initialization — creates fresh Terminal with new buffers
crates/openshell-tui/src/lib.rs:436-443 TUI shutdown — includes show_cursor() not present in resume
crates/openshell-tui/src/lib.rs:854-1006 handle_shell_connect() — full shell suspend/resume handler
crates/openshell-tui/src/lib.rs:964-971 Suspend: leave alternate screen + disable raw mode
crates/openshell-tui/src/lib.rs:991-1003 Resume: re-enable raw mode + re-enter alternate screen + clear/draw
crates/openshell-tui/src/lib.rs:936-956 SSH command construction with -tt, RequestTTY=force, Stdio::inherit()
crates/openshell-tui/src/lib.rs:1013-1164 handle_exec_command() — same pattern, same bug
crates/openshell-tui/src/lib.rs:443 Shutdown calls terminal.show_cursor() — resume does not

Current Behavior

  1. User presses [s]app.pending_shell_connect = true (app.rs:1600)
  2. Main loop detects flag (lib.rs:145-147), calls handle_shell_connect()
  3. TUI suspends: leaves alternate screen, disables raw mode (lines 964-971)
  4. SSH spawns via spawn_blocking(move || command.status()) (line 974) — blocks until exit
  5. SSH exits, resume begins (line 991+)
  6. Raw mode re-enabled, alternate screen re-entered
  7. terminal.clear() resets ratatui's internal diff buffers
  8. terminal.draw() renders frame — but terminal state is polluted from SSH PTY
  9. Result: black screen

What Would Need to Change

The resume path (lines 991-1003) is missing three critical terminal state restoration operations compared to init/shutdown:

  1. ResetColor: SSH PTY can leave terminal foreground/background colors in a state where they match (both dark), making all drawn content invisible. The resume path must reset color attributes before drawing.

  2. cursor::Show: Present in shutdown (line 443) but absent from resume. If the SSH session or remote application hid the cursor, it stays hidden.

  3. Terminal size re-sync: If the user resized their terminal window during the SSH session, the ratatui Terminal object retains stale dimensions. terminal.size() + terminal.resize() is needed to re-query actual dimensions before drawing.

Additionally, a defensive SGR reset (\x1b[0m) before re-entering alternate screen would clear any attribute state (bold, underline, reverse video, character set changes) left by the SSH session.

The same bug exists in handle_exec_command() (lines 1152-1158) which follows the identical resume pattern.

Alternative Approaches Considered

A. Patch the existing resume sequence (minimal fix)
Add ResetColor, cursor::Show, and size re-query to the existing resume code. Lowest risk, keeps the Terminal object alive across suspend/resume.

B. Recreate the Terminal object on resume
Drop the old Terminal and create a new one after SSH exits (like init does). More thorough but loses ratatui's viewport state. Would need to ensure the crossterm backend's stdout handle is still valid.

C. Use ratatui::restore() / ratatui::init() helpers
Modern ratatui (0.28+) provides restore() and init() convenience functions that handle the full terminal lifecycle. Would simplify the suspend/resume code but requires checking the ratatui version in use.

Approach A is recommended — it's the minimal targeted fix with lowest risk.

Patterns to Follow

The shutdown path at lines 436-443 is the reference pattern. The resume code should mirror it in reverse: reset all terminal state, then re-enter TUI mode with a full redraw.

Proposed Approach

Add terminal state reset operations to the resume path in handle_shell_connect(): issue SGR reset, add ResetColor and cursor::Show to the execute! block, re-query terminal size with terminal.size() + terminal.resize(), then proceed with existing clear+draw. Apply the same fix to handle_exec_command(). This is a targeted ~10-line fix in a single file.

Scope Assessment

  • Complexity: Low
  • Confidence: High — clear path, well-understood terminal state management
  • Estimated files to change: 1 (crates/openshell-tui/src/lib.rs)
  • Issue type: fix

Risks & Open Questions

  • Terminal emulator differences: the exact behavior when re-entering alternate screen with polluted state varies by emulator (Terminal.app, iTerm2, Ghostty, Alacritty). Fix should be defensive enough to work across all.
  • The handle_exec_command() function has the same bug and should be fixed in the same PR.
  • Testing: this requires manual testing with an actual sandbox SSH session — no automated test can verify terminal visual output.

Test Considerations

  • Manual test: openshell term → select sandbox → [s] Shell → run commands → exit → verify TUI renders correctly
  • Edge cases: resize terminal during SSH session, then exit — verify TUI adapts to new size
  • Edge case: Ctrl+C out of SSH session (abnormal exit) — verify TUI still recovers
  • Both code paths: test [s] Shell interactive and exec command paths
  • Multiple terminals: test on at least 2 terminal emulators (e.g., iTerm2 + Terminal.app, or Ghostty + Alacritty)

Created by spike investigation. Use build-from-issue to plan and implement.

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    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