Skip to content

fix: restore enhanced keyboard modes after PTY disconnect #234

Description

@inureyes

Problem / Background

After an interactive bssh PTY session ends, the local terminal can remain in an enhanced keyboard-reporting mode that was enabled by a remote application. Subsequent key presses at the local shell are then emitted as raw control-sequence fragments instead of normal input.

Observed examples on Ghostty 1.3.1 with TERM=xterm-ghostty:

Space           -> ;1:3u
Right arrow     -> :1C:3C

The complete input sequences are consistent with Kitty keyboard protocol / CSI-u events such as:

CSI 32;1:3u     # Space, no modifiers, key release
CSI 1;1:1C      # Right arrow, no modifiers, key press
CSI 1;1:3C      # Right arrow, no modifiers, key release

The local shell or line editor may consume the leading ESC [ and part of the parameters, leaving only fragments such as ;1:3u or :1C:3C visible.

This state persists after bssh returns to the local prompt and usually requires reset, an explicit Kitty keyboard-mode reset, or opening a new terminal tab/window.

Root Cause

Interactive terminal protocols intentionally allow applications to change how the terminal emulator reports keyboard events. Remote applications such as Neovim, Helix, Yazi, fish, nushell, and crossterm-based TUIs may enable Kitty keyboard progressive enhancements by emitting sequences such as:

CSI > flags u

Those bytes travel through the SSH PTY and are interpreted by the local terminal emulator. The terminal then reports key presses, repeats, and releases using CSI-u sequences until the application restores the previous mode with CSI < u or resets the active flags.

When the remote application or SSH transport terminates before sending its matching cleanup sequence, the local terminal remains in the enhanced keyboard mode. A network drop, remote process crash, forced disconnect, or application that enables the mode without balancing it can all trigger this condition.

This behavior is documented by the Kitty keyboard protocol and Neovim:

Neovim explicitly notes that an unclean exit can leave the terminal in a bad state and recommends reset as recovery.

Code Analysis

bssh correctly detects several PTY termination conditions in src/pty/session/session_manager.rs:

  • ChannelMsg::Eof and ChannelMsg::Close
  • Channel::wait() returning None
  • Failure while sending local input to the SSH channel
  • Local termination and internal PTY errors

After the session loop, TerminalStateGuard is dropped and the higher-level PTY paths also call force_terminal_cleanup().

However, both cleanup implementations in src/pty/terminal.rs restore only a subset of terminal state:

  • Bracketed paste mode
  • Mouse tracking modes 1000, 1002, 1003, 1006, and 1015
  • Alternate screen mode
  • Cursor visibility
  • Local raw termios mode

The emitted cleanup blob currently contains:

b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l\x1b[?1049l\x1b[?25h"

It does not restore any of the following:

  • Kitty keyboard progressive-enhancement flags or stack (CSI < ... u / CSI = ... u)
  • xterm modifyOtherKeys, which Neovim uses as a fallback when CSI-u is unavailable

Therefore cleanup executes after ordinary EOF and network-loss paths, but it is incomplete for enhanced keyboard protocols.

TerminalState::save_terminal_state() also currently contains a TODO and assumes that bssh starts from a clean terminal state. A blind reset to legacy keyboard mode would fix the common shell case but could overwrite a keyboard mode owned by an outer local TUI that launched bssh.

Deterministic Reproduction

This can be reproduced without depending on a particular remote editor:

  1. Start an interactive PTY session from a Kitty-keyboard-compatible terminal:

    bssh user@host
  2. From the remote shell, enable all crossterm-supported Kitty keyboard enhancements and immediately exit without balancing the push:

    printf '\033[>15u'; exit
  3. After bssh returns to the local shell, press Space and the arrow keys.

  4. Observe CSI-u fragments such as ;1:3u, :1C, or :3C instead of normal key behavior.

  5. Run reset or open a new terminal tab to recover.

A realistic abrupt-disconnect reproduction is to start a remote Kitty-keyboard-aware TUI, verify that enhanced event reporting is active, and then terminate the SSH transport or remote PTY process before the application exits cleanly.

Proposed Solution

Extend the centralized terminal teardown so that bssh restores enhanced keyboard modes on every cleanup path.

1. Centralize terminal reset emission

Extract the repeated escape-sequence writes from TerminalStateGuard::restore_terminal_state() and force_terminal_cleanup() into a shared best-effort helper that writes to an injectable Write target. Both normal RAII cleanup and panic/last-resort cleanup must use the same sequence set.

The helper must remain idempotent, non-panicking, and best-effort. Preserve the current try_lock() behavior in force_terminal_cleanup() so panic cleanup cannot deadlock on TERMINAL_MUTEX.

2. Restore Kitty keyboard state

At minimum, teardown should ensure that stale Kitty keyboard progressive enhancements no longer affect the shell after bssh exits. Candidate sequences include popping outstanding keyboard-mode entries and explicitly clearing active flags:

CSI < number u
CSI = 0 u

The implementation must account for the Kitty protocol maintaining separate keyboard-mode stacks for the main and alternate screens. Cleanup may need to restore the current screen before leaving alternate-screen mode and restore the main screen again afterward.

3. Preserve the pre-existing local mode where possible

The robust design should query and record the local Kitty keyboard flags before handing terminal input to the remote PTY:

CSI ? u

On teardown, restore the recorded flags rather than always forcing legacy mode. This prevents bssh from breaking an outer local TUI that already owns an enhanced keyboard mode.

If exact state preservation cannot be implemented safely in the first patch, use a clearly documented best-effort fallback for ordinary shell sessions and track exact state preservation separately. Avoid silently claiming that TerminalState is fully preserved while save_terminal_state() still assumes a clean baseline.

4. Reset the xterm fallback

Also restore xterm modifyOtherKeys when it may have been enabled by a remote application. Neovim uses CSI > 4 ; 2 m as the fallback when CSI-u support is unavailable. The teardown should emit the corresponding disable/reset sequence on terminals that use this path.

Reference:

5. Do not filter valid enable sequences during the session

Do not solve this by stripping Kitty or modifyOtherKeys control sequences from remote output. Interactive remote applications require these sequences for correct key handling. The correct boundary is session teardown: allow terminal protocols during the PTY session, then restore the local terminal state when ownership returns to the local shell.

Acceptance Criteria

  • After normal remote EOF/Close, the local terminal accepts Space, arrow keys, Enter, and modifier combinations normally.
  • After an abrupt SSH transport loss while an enhanced-keyboard-aware application is active, no CSI-u fragments such as ;1:3u, :1C, or :3C appear at the local shell.
  • Local disconnect through the supported ~. escape restores keyboard state.
  • Input-send errors and internal PTY error paths restore keyboard state.
  • Both TerminalStateGuard::Drop and force_terminal_cleanup() use the same keyboard cleanup behavior.
  • Kitty keyboard state is restored on both the alternate and main screen paths.
  • xterm modifyOtherKeys is disabled or restored when it was enabled through the fallback protocol.
  • Cleanup remains idempotent, best-effort, panic-safe, and safe to call more than once.
  • A pre-existing enhanced keyboard mode owned by an outer local application is preserved where the terminal supports querying/restoring it.
  • Non-supporting terminal emulators ignore the added sequences without visible artifacts or regressions.
  • Non-PTY commands and traditional interactive mode do not regress.
  • Automated tests verify the exact reset bytes through an injectable writer instead of relying only on captured process stdout.
  • A real-TTY integration or documented manual test covers Ghostty and at least one additional Kitty-keyboard-compatible terminal.

Out of Scope

No in-process cleanup can run after local SIGKILL, immediate process abort without unwinding, terminal emulator termination, power loss, or kernel failure. Those cases still require terminal-level recovery such as reset or opening a new terminal instance.

Related Issues

#189 is the closest precedent: remote applications could leave mouse tracking enabled after disconnect, and bssh fixed it by extending the centralized teardown. Enhanced keyboard modes require the same class of cleanup, with additional care to preserve any pre-existing local keyboard state.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions