- drop the four on-screen arrow buttons: a physical keyboard already sends
them, and the app-cursor mode that made them awkward is handled by keySequence
- add a Paste button that reads the clipboard and hands the text to the shell
the same way a browser paste does, bracketed paste included
- report a refusal (no secure context or permission) instead of looking dead,
and fall back to Ctrl+V
- stop a mouse-down from stealing focus into the hidden input, which
collapsed the selection a drag was building; focus comes back on a plain
click, and after a real selection it is left where the copy shortcuts need it
- preserve a selection across repaints: note it as a row/character offset and
put it back after a row is rewritten, so output no longer wipes it, and hold
a frame while a drag is in progress
- let Ctrl/Cmd+C copy a selection instead of collapsing it first; it still
interrupts when nothing is selected
- make the mount non-focusable, tint the selection, and document the behaviour
The key bar carries only what a browser will not hand over: Ctrl+C, Ctrl+Z,
Ctrl+D, Escape, Tab and the arrows. It is a fallback, not the way to use the
terminal — typing works for everything else, and the bar deliberately does not
take focus so the keyboard keeps it. The arrow buttons ask the emulator for the
sequence, so they follow the mode a full-screen program requested just like the
real keys do.
Paste was broken on Windows and Linux. Ctrl+V fell through to the control-key
mapping and sent 0x16 with preventDefault, so the browser never got to fire a
paste event, and Shift+Insert did the same with the Insert sequence. Both, and
Ctrl+Shift+Insert, are now left to the platform; Cmd+V on macOS already worked.
The control keys were wrong in a way only running it showed: a control byte
cannot be written into an HTML attribute, so data-raw="\u0003" arrived as
six literal characters and the buttons typed them instead of sending them.
They carry a name now and the byte is looked up in JavaScript.
Verified against a real container: the bar's bytes (003, 032, 004, 033, ESC[A-D),
arrows inside less, Tab completion, Ctrl+V and Shift+Insert pasting, Enter
running a two-line paste, and Ctrl+C copying when something is selected.
A browser reaching this panel is often a long way away, so the session is now
built to survive the link rather than be torn down by it.
Liveness first, because the old numbers were wrong: pings every 20s with a 60s
deadline. The previous setting was a single 15-minute timer, and since the
browser answers pings automatically it never fired for an idle user — the one
thing it did do was take fifteen minutes to notice a peer that had vanished.
- the shell outlives the socket. When the client goes away the PTY keeps running
for 120s with its output landing in a 256 KiB ring (with a notice if anything
is dropped). A client that comes back with its token resumes the same shell, so
a tunnel, a cell handover or a sleeping laptop no longer costs a running build.
The token travels in the first frame rather than the URL, is bound to the user,
and lives in sessionStorage — reloading resumes, closing the window lets go.
- the page has its own heartbeat: a browser cannot see protocol-level pings, so
the server also sends a small JSON one and the page reconnects with exponential
backoff and jitter after 45s of silence, up to eight attempts.
- a handshake that neither completes nor fails would otherwise stall the retry
loop for good, so an attempt that has not opened in 15s is abandoned.
- at the per-user cap the client is offered its slots back instead of being
refused, which is the only way to reclaim a window the user can no longer see.
Where there is room, the count is just a notice.
Three bugs the browser testing turned up, all of them real:
- Pty writes had no deadline, so a wedged Incus connection blocked the handler
forever — resume hung after any pause long enough to break that socket.
- a chunk whose write failed was neither delivered nor buffered.
- a session stayed resumable between Close and the end of its drain.
An operator can now turn the whole thing off with `vps config set panel.web_ssh
false`: the button is not rendered and the page, the script and the connection
all refuse. It defaults on, including for a config written before the setting
existed.