terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not natural". ── the replay typed into the shell ── the pty buffer was stored raw and replayed verbatim on every re-attach. anything in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP, the OSC colour queries — got asked again, and xterm answered correctly by writing the reply to its input. the pty receives that as a keystroke nobody typed. stripped on the way IN, since the buffer is the thing that gets replayed and a live client already answered them once when they were legitimately asked. only questions are removed; everything that draws is untouched. where a control shares its final byte with one that draws, the parameter is enumerated rather than wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and stripping the second would change what a replay renders. 36 tests, both directions, because both fail silently. ── two sockets on one session ── handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus whenever readyState was CLOSED — which is exactly what a pending timer leaves. both ran. every keystroke went twice, two replay frames fought over the screen, and only one socket was ever cleaned up because __terminalCleanup is overwritten by whichever connect ran last. connect() is now the single guard, and a stale socket's close no longer speaks for the session. ── the keyboard ── alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D (verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing bound it. so it broke on a dependency bump, with no shell config changed. bound in zsh rather than translated in the browser, deliberately: tmux.conf claims M-Left/M-Right for pane switching, and a client-side rewrite would send ^[b to tmux and break it. the real sequence lets tmux handle it inside a session and zsh outside. ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is assembled in machine-setup and never got them. the owner had a strictly worse shell than the accounts they provision. confirmed with `zsh -i -c bindkey` before and after. also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and every Escape, which is most of what "not natural" felt like. applied to this host by hand — setup only runs at install. cmd+arrow is left alone: xterm emits nothing for it, so there is no sequence to bind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,8 +46,40 @@ const resolveCwd = (cwd, base) => {
|
||||
return home;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sequences that make a terminal ANSWER, stripped before anything is stored.
|
||||
*
|
||||
* The scrollback is replayed verbatim to a re-attaching client. Anything in it that asks the terminal a
|
||||
* question gets asked AGAIN on every reconnect — and xterm answers, correctly, by writing the reply to its
|
||||
* input. That input is a keystroke as far as the pty is concerned, so a reconnect injects text into the
|
||||
* shell that nobody typed: `^[[?62;c` and friends landing on the command line, or being eaten by whatever
|
||||
* TUI is running. It is the "terminal goes weird after reconnecting" symptom, and it is not the shell's
|
||||
* fault.
|
||||
*
|
||||
* Stripping on the way IN rather than on the way out: the buffer is the thing that gets replayed, and a
|
||||
* live client has already answered these once, at the moment they were legitimately asked.
|
||||
*
|
||||
* What is removed is only ever a QUESTION. Colour, cursor movement, screen clears — everything that draws —
|
||||
* is untouched, so a replay still reproduces the screen exactly.
|
||||
*/
|
||||
// Each pattern is the QUERY form only. Where a control shares its final byte with a command that DRAWS,
|
||||
// the numeric parameter is enumerated rather than wildcarded — `CSI 18 t` asks the window size, but
|
||||
// `CSI 22 t` pushes the title, and stripping the second would silently change what a replay renders.
|
||||
const QUERY_SEQUENCES = [
|
||||
/\x1b\[\??[56]n/g, // DSR — cursor position (6n), status (5n), and the DEC `?` variants
|
||||
/\x1b\[[0-9;?>=]*c/g, // DA1/DA2/DA3 — device attributes. `c` is only ever a query.
|
||||
/\x1b\[\?[0-9;]*\$p/g, // DECRQM — mode query
|
||||
/\x1b\[(?:1[1345689]|2[01])(?:;[0-9]+)*t/g, // XTWINOPS reports only — NOT 22/23 (title push/pop)
|
||||
/\x1b\[>[0-9;]*q/g, // XTVERSION
|
||||
/\x1bP\+q[0-9a-fA-F;]*(?:\x1b\\|\x07)/g, // DCS XTGETTCAP — terminfo capability query
|
||||
/\x1b\](?:10|11|12|4;[0-9]+);\?(?:\x07|\x1b\\)/g, // OSC colour queries (fg/bg/cursor/palette)
|
||||
];
|
||||
|
||||
/** Exported for the test: this is the one function here whose mistakes are invisible until a replay. */
|
||||
export const stripQueries = (data) => QUERY_SEQUENCES.reduce((out, re) => out.replace(re, ''), data);
|
||||
|
||||
const appendBuffer = (session, data) => {
|
||||
session.buffer += data;
|
||||
session.buffer += stripQueries(data);
|
||||
if (session.buffer.length > BUFFER_MAX) {
|
||||
// Cut on a line boundary, not a byte offset. A blind slice can land inside an escape sequence, and the
|
||||
// replay then opens with the tail of a colour or cursor-move code — which xterm renders as garbage, or
|
||||
|
||||
Reference in New Issue
Block a user