Skip to content

Fix arrow keys over iOS SSH (split escape sequences + stale probe bytes)#56

Merged
jvanderberg merged 3 commits into
mainfrom
fix/ios-ssh-input-handling
May 14, 2026
Merged

Fix arrow keys over iOS SSH (split escape sequences + stale probe bytes)#56
jvanderberg merged 3 commits into
mainfrom
fix/ios-ssh-input-handling

Conversation

@jvanderberg
Copy link
Copy Markdown
Owner

Summary

Resolves #53 — arrow keys intermittently fail when connecting to markless over SSH from iOS clients (Termius, Shelly on iOS/iPadOS). Two independent root causes; both addressed here.

1. Split escape sequences (src/app/event_loop.rs)

The main event::poll used Duration::from_millis(0) whenever a render was pending, and the drain loop always used 0. iOS terminals deliver \x1b[A across separate reads, so crossterm saw a bare \x1b, reported it as a standalone Escape keypress, and the trailing [A came through on the next read as literal text.

Both the main poll and the drain now use a 5ms minimum. The selection logic is extracted into a const fn select_poll_ms so it's unit-testable. 5ms is imperceptible to a human and gives crossterm enough time to reassemble the sequence.

2. Stale probe bytes (src/app/event_loop.rs)

When a terminal doesn't understand the OSC 11 background query or the Kitty/Sixel image-protocol probes, response bytes (or the echoed-back payload) can sit in the TTY input buffer. crossterm reads them later as spurious key events.

Added drain_stale_input() called at event-loop entry — after try_init() puts the terminal in raw mode, before the main loop. Drains up to 256 events with a 20ms poll window so late-arriving probe responses are also caught. Logs event.drain.startup when anything is discarded so it surfaces in perf logs.

Test plan

  • cargo fmt --check
  • cargo clippy -- -D warnings
  • cargo test — 604 tests pass (3 new tests for select_poll_ms covering all three branches)
  • Manual verification: SSH to a Linux host from Termius on iOS/iPadOS and confirm arrow keys work both in default (images on) and --no-images modes
  • Manual verification: confirm normal terminals (Ghostty, iTerm2, Kitty, plain xterm) still feel responsive

Notes / follow-ups

  • The OSC 11 query in src/main.rs:129 spawns a reader thread that's never joined. If the terminal never responds (likely on iOS Termius), that thread blocks in read() on a /dev/tty handle and could compete with crossterm for keystrokes in the main loop. Not addressed here since it requires more invasive changes; worth a follow-up if iOS testing reveals lingering issues after this fix.

Two changes that together resolve broken/slow arrow keys when connecting
from iOS SSH clients (Termius, Shelly) reported in #53:

1. Use a 5ms minimum poll timeout instead of 0 when a render is pending.
   The previous zero-timeout poll gave crossterm no window to reassemble
   multi-byte escape sequences. iOS terminals deliver \x1b[A across
   separate reads, so the leading \x1b was reported as a standalone
   Escape and the trailing [A surfaced as literal characters. 5ms is
   imperceptible to a human and reliably catches the trailing bytes.
   Applied to both the main poll and the drain loop.

2. Drain stale input at event-loop entry. The OSC 11 background query
   and Kitty/Sixel image-protocol probes can leave bytes in the TTY
   buffer when a terminal doesn't understand them — those bytes would
   otherwise surface as spurious key events on first user interaction.

Closes #53.
CI was using `dtolnay/rust-toolchain@stable`, so every new stable release
that added clippy lints could fail CI without anyone changing code. Pin
to 1.95.0 in both ci.yml and release.yml, and add rust-toolchain.toml so
local rustup picks up the same version automatically.

To bump in the future, update all three values together.
Pinning the toolchain surfaces new lints that were never run against the
existing code:

- input.rs: collapse nested `if` guards into match-arm guards
  (collapsible_match)
- model.rs: replace `sort_by` with `sort_by_key` (unnecessary_sort_by)
- types.rs: drop redundant `.into_iter()` in `.zip()` (useless_conversion)
- watcher/mod.rs: remove a trailing comma in `format!` args
  (unnecessary_trailing_comma)
@jvanderberg jvanderberg merged commit 63dc819 into main May 14, 2026
3 checks passed
@jvanderberg jvanderberg deleted the fix/ios-ssh-input-handling branch May 14, 2026 01:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Arrow keys intermittently fail over iOS SSH — escape sequences split across reads

1 participant