From 08cc747b3284ec6dfb18588f65fbe698d40af114 Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Thu, 17 Sep 2026 13:18:35 +0000 Subject: [PATCH] fix(web-ssh): stop private CSI sequences turning into SGR attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/internal/panel/assets/term.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/internal/panel/assets/term.js b/src/internal/panel/assets/term.js index 09cd45a..7a71b08 100644 --- a/src/internal/panel/assets/term.js +++ b/src/internal/panel/assets/term.js @@ -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;