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 + EnableMouseCapture → Terminal::new() (fresh buffers) → terminal.clear()
- Shutdown (lines 436-443):
disable_raw_mode() → LeaveAlternateScreen + DisableMouseCapture → terminal.show_cursor()
- Shell suspend (lines 964-971):
LeaveAlternateScreen + DisableMouseCapture → disable_raw_mode()
- Shell resume (lines 991-1003):
enable_raw_mode() → EnterAlternateScreen + EnableMouseCapture → terminal.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
- User presses
[s] → app.pending_shell_connect = true (app.rs:1600)
- Main loop detects flag (lib.rs:145-147), calls
handle_shell_connect()
- TUI suspends: leaves alternate screen, disables raw mode (lines 964-971)
- SSH spawns via
spawn_blocking(move || command.status()) (line 974) — blocks until exit
- SSH exits, resume begins (line 991+)
- Raw mode re-enabled, alternate screen re-entered
terminal.clear() resets ratatui's internal diff buffers
terminal.draw() renders frame — but terminal state is polluted from SSH PTY
- 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:
-
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.
-
cursor::Show: Present in shutdown (line 443) but absent from resume. If the SSH session or remote application hid the cursor, it stays hidden.
-
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.
Problem Statement
When using
openshell term→ selecting a sandbox → pressing[s] Shell→ running commands → typingexit, 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
crates/openshell-tui/src/lib.rscrates/openshell-tui/src/ui.rscrates/openshell-core/src/forward.rsTechnical Investigation
Architecture Overview
The TUI uses ratatui with a crossterm backend. Terminal state management follows the standard ratatui pattern:
enable_raw_mode()→EnterAlternateScreen+EnableMouseCapture→Terminal::new()(fresh buffers) →terminal.clear()disable_raw_mode()→LeaveAlternateScreen+DisableMouseCapture→terminal.show_cursor()LeaveAlternateScreen+DisableMouseCapture→disable_raw_mode()enable_raw_mode()→EnterAlternateScreen+EnableMouseCapture→terminal.clear()→terminal.draw()→events.discard_pending()→events.resume()The SSH subprocess is spawned with
-tt(force PTY) andRequestTTY=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
crates/openshell-tui/src/lib.rs:67-73crates/openshell-tui/src/lib.rs:436-443show_cursor()not present in resumecrates/openshell-tui/src/lib.rs:854-1006handle_shell_connect()— full shell suspend/resume handlercrates/openshell-tui/src/lib.rs:964-971crates/openshell-tui/src/lib.rs:991-1003crates/openshell-tui/src/lib.rs:936-956-tt,RequestTTY=force,Stdio::inherit()crates/openshell-tui/src/lib.rs:1013-1164handle_exec_command()— same pattern, same bugcrates/openshell-tui/src/lib.rs:443terminal.show_cursor()— resume does notCurrent Behavior
[s]→app.pending_shell_connect = true(app.rs:1600)handle_shell_connect()spawn_blocking(move || command.status())(line 974) — blocks until exitterminal.clear()resets ratatui's internal diff buffersterminal.draw()renders frame — but terminal state is polluted from SSH PTYWhat Would Need to Change
The resume path (lines 991-1003) is missing three critical terminal state restoration operations compared to init/shutdown:
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.cursor::Show: Present in shutdown (line 443) but absent from resume. If the SSH session or remote application hid the cursor, it stays hidden.Terminal size re-sync: If the user resized their terminal window during the SSH session, the ratatui
Terminalobject 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 theTerminalobject alive across suspend/resume.B. Recreate the Terminal object on resume
Drop the old
Terminaland 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()helpersModern ratatui (0.28+) provides
restore()andinit()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, addResetColorandcursor::Showto theexecute!block, re-query terminal size withterminal.size()+terminal.resize(), then proceed with existing clear+draw. Apply the same fix tohandle_exec_command(). This is a targeted ~10-line fix in a single file.Scope Assessment
crates/openshell-tui/src/lib.rs)fixRisks & Open Questions
handle_exec_command()function has the same bug and should be fixed in the same PR.Test Considerations
openshell term→ select sandbox →[s] Shell→ run commands →exit→ verify TUI renders correctly[s] Shellinteractive and exec command pathsCreated by spike investigation. Use
build-from-issueto plan and implement.