fix(web-ssh): stop private CSI sequences turning into SGR attributes

Typing in vi left the whole session underlined. The stream vi sends contains
xterm's modifyOtherKeys, which carries a private ">" prefix:

  CSI > 4 ; m      and      CSI > 4 ; 2 m

A CSI sequence has four private markers — ? < = > — and the parser only knew
"?". The others were dropped on the floor, so their parameters leaked into the
final byte and ">4;m" was read as SGR 4 (underline on) with nothing left to
turn it off. All four markers are recognised now, and a private sequence is
never treated as SGR. The secondary device-attributes query (CSI > c) also gets
its own reply, so a program asking for it does not sit out its timeout.

Found by capturing the real byte stream from vi over a PTY and replaying it
through the emulator: the flags now come out clean, while a plain CSI 4 m still
turns underline on as it should.

Reported alongside it: after leaving insert mode with Escape the shell seemed
to stop accepting keys until the window was clicked. That does not reproduce
against a bare browser here — the same sequence types ":wq" straight through —
so the likely culprit is an input method leaving its composing flag set, which
would swallow every later key. The guard now trusts our own compositionstart/end
tracking instead of the browser's flag, and a committed composition still
reaches the shell.
This commit is contained in:
bluebluesoda committed 2026-09-17 13:18:35 +00:00
1 parent e3c2b851e4
commit 08cc747b32
1 file changed
+18 -4
+18 -4
View File
@@ -290,7 +290,13 @@
break;
case "csi":
if (code >= 0x40 && code <= 0x7e) { this._csi(ch); this.state = "ground"; }
else if (code === 0x3f) this.priv = true;
else if (code === 0x3f || code === 0x3c || code === 0x3d || code === 0x3e) {
// The private/extension markers are ? < = >. All four have to be
// recognised: ignoring one lets its parameters leak into the final
// byte, which is how xterm's modifyOtherKeys (CSI > 4 ; 2 m) used to
// arrive as "underline on" and stay on for the rest of the session.
this.priv = true;
}
else if (code >= 0x30 && code <= 0x39) this.buf += ch;
else if (code === 0x3b) { this.params.push(parseInt(this.buf, 10)); this.buf = ""; }
break;
@@ -371,14 +377,16 @@
if (this.scrollBot <= this.scrollTop) { this.scrollTop = 0; this.scrollBot = this.rows - 1; }
this.cx = 0; this.cy = this.scrollTop;
break;
case "m": this._sgr(p); break;
case "m": if (!this.priv) this._sgr(p); break;
case "h": this._mode(p, true); break;
case "l": this._mode(p, false); break;
case "n":
if (p[0] === 6 && this.opts.onReply) this.opts.onReply("\x1b[" + (this.cy + 1) + ";" + (this.cx + 1) + "R");
break;
case "c":
if (this.opts.onReply) this.opts.onReply("\x1b[?1;2c");
// A private ">" query asks for the secondary attributes; answering with
// the primary form (or not at all) makes a program wait for its timeout.
if (this.opts.onReply) this.opts.onReply(this.priv ? "\x1b[>0;0;0c" : "\x1b[?1;2c");
break;
}
this._scheduleRender();
@@ -602,7 +610,13 @@
Term.prototype._onKey = function (e) {
if (this.exited) return;
if (e.isComposing || e.keyCode === 229) return; // the IME owns this keystroke
// The IME owns a keystroke while a composition is running. Which that is
// has to come from our own compositionstart/end tracking rather than from
// e.isComposing alone: a browser can leave that flag set after an IME
// candidate is cancelled (Escape), and then every later key is dropped
// until the user clicks somewhere else. Same for a key the IME is
// consuming, which browsers report as "Process".
if (this.composing || e.key === "Process" || e.key === "Unidentified") return;
var m = this.modes;
var send = null;