I build Termphin, an SSH client you can use from a phone. On a phone the connection is often bad: mobile data, a train, hotel Wi-Fi. Over SSH, a key you type goes to the server, and the character only appears when the server echoes it back. With 300 ms of round trip, every key appears 300 ms late. Every key, every command, every line.
mosh fixes this with local echo. The client draws the character straight away and checks it against the server's echo later. But mosh is its own protocol, and it has to be installed on every server you connect to.
I wanted the same thing inside a normal SSH session, with nothing to install on the server. So Termphin predicts what the server will echo and draws it straight away.
What gets predicted
Only keys whose effect is certain. Everything else stops prediction until the server has caught up.
| Key | What happens |
|---|---|
| A printable character at the end of the line | Drawn at once |
| Backspace, left arrow, inside what you typed on this line | The cursor moves at once, never into the prompt |
| Enter, Tab, right arrow, a control key, an escape sequence | Prediction stops |
| A paste, anything over 32 characters at once | Prediction stops |
| Any key in vim, less or htop | Prediction is off |
Backspace only moves the cursor. What takes the place of the deleted character is not always a blank: zsh and fish with autosuggestions draw the suggestion back there, so the app leaves that cell to the server.
The engine reads the exact bytes sent to the server, not what the terminal widget produced. Modifier keys and snippets change or bypass those bytes, and the prediction has to see what the server sees.
The first mistake
The first version read the cursor position off the screen. That sounds right, and it is wrong. The screen shows the state before the keys still travelling to the server, so reading it while keys were in flight meant reading a stale position.
The result was a cursor that did not move, and garbage drawn into the prompt.
The fix: the engine keeps its own idea of where the cursor will be. It only reads the screen again once the server has caught up with every key.
Knowing when the server has caught up
That raises a question SSH has no direct answer to: has the server received my keys yet?
The answer I found is the keepalive. After a write, the app sends an SSH keepalive. sshd answers it in order, behind the channel data, so its reply means every key before it has arrived.
Only one keepalive is out at a time, and later keys are covered by the next one. The reply leaves sshd when the key is handed to the terminal, and the program's echo follows a moment later, so the app allows 120 ms of grace after it.
Without these receipts the only option is waiting for quiet: the larger of 1 second and 4 times the echo time. That is safe, but it predicts far less.
Never showing a password
Predictions are grouped into epochs. After anything unpredictable a new epoch starts, and it stays hidden until one of its predictions is confirmed by the server's output.
That is what keeps a password typed after sudo off the screen. The server never echoes it, so nothing is confirmed, and nothing is shown.
A few more rules keep the screen honest:
- If the program's cursor ends up on a different row than predicted, something unforeseen was drawn - a background job printing
Done, for example - and every prediction is dropped. - A prediction nobody answers for 5 seconds is judged against the screen anyway. That covers a session that died with keys in flight.
- Predictions are tied to terminal lines, not screen rows, so output that scrolls the screen carries them along.
- The app never repaints in the middle of a program's synchronized update, which would show a half-drawn screen.
Only when the link is slow
The app measures its own echo round trip and decides from that.
| Echo round trip | Predictions |
|---|---|
| Over 60 ms | Shown |
| 40 ms or less | Hidden again |
| Over 160 ms | Dimmed, plain again at 100 ms or less |
| One key unanswered for 250 ms | Shown, even on a fast link |
The gap between 60 and 40 stops them flickering on a link that hovers around one number. Dimming tells you what the server has not confirmed yet. The 250 ms case is a glitch on a good link, and ten quick confirmations, at least 150 ms apart, turn it off again.
So it is on by default. On a fast link you never see it.
The fuzz test that found every rule
Every rule above came out of a failing test. The test is a fake shell that answers like a real line editor: a prompt, history, Ctrl-C, Ctrl-U and Ctrl-W, a mode that behaves like zsh with autosuggestions, a sudo that asks for a password, and a command that opens a full-screen program. The password part is as simple as it sounds:
if (_password) {
if (key != '\r') return '';
_password = false;
return '\r\nok\r\n$prompt';
}
Random keys are typed into it through the engine. Each reply arrives after 80 to 330 ms, and one in ten after a spike of 400 to 1500 ms.
A second terminal, the oracle, gets the same replies with no delay. After every step the test checks one rule: nothing on screen may disagree with a state the server actually passed through. Once everything goes quiet, nothing predicted may be left on screen.
It runs 500 seeds of 300 steps each. When the engine changes, the test runs. A new behaviour gets a new fake-shell mode.
Where it does not help
Not in vim, less or htop. Not after Tab, Enter or a paste, until the server has caught up.
It predicts only at the end of the line. With text after the cursor a key waits for its echo, except when you are typing over the suggestion already under the cursor.
Termphin is available for:
Android: https://play.google.com/store/apps/details?id=dev.termphin.termphin and iOS Soon.
Website: https://termphin.dev
If you frequently SSH from trains, airports, or spotty mobile connections, give it a try. I'd love to hear how the prediction feels on your setup.
Top comments (0)