···33## Unreleased
4455### Fixes
66-- Respond to terminal queries that expect a response on stdin — prevents garbage input in programs like `less` and `git log`: OSC 10/11 (foreground/background color), OSC 4 (palette color), DA2 (secondary device attributes), DSR (cursor position), XTVERSION
66+- Fix garbage characters in `less`/`git log`: respond to terminal queries (OSC 10/11/4, DA2, DSR, XTVERSION) and strip them from client broadcast so the client's terminal doesn't respond with duplicate input
7788### pty exec
99- Add `pty exec -- <command> [args...]` to replace the current session's command from inside the session
+26-4
src/server.ts
···103103 return undefined;
104104}
105105106106+/** Strip terminal query sequences that should not be forwarded to clients.
107107+ * Exported for unit testing. */
108108+export function stripTerminalQueries(data: string): string {
109109+ return data
110110+ .replace(/\x1b\]1[01];\?\x07/g, "") // OSC 10/11 with BEL
111111+ .replace(/\x1b\]1[01];\?\x1b\\/g, "") // OSC 10/11 with ST
112112+ .replace(/\x1b\]4;\d+;\?\x07/g, "") // OSC 4 with BEL
113113+ .replace(/\x1b\]4;\d+;\?\x1b\\/g, "") // OSC 4 with ST
114114+ .replace(/\x1b\[c/g, "") // DA1
115115+ .replace(/\x1b\[>c/g, "") // DA2
116116+ .replace(/\x1b\[6n/g, "") // DSR cursor position
117117+ .replace(/\x1b\[>0q/g, ""); // XTVERSION
118118+}
119119+106120export class PtyServer {
107121 private terminal: Terminal;
108122 private serialize: SerializeAddon;
···250264 // intercept common queries and respond directly to the PTY process.
251265252266 // OSC 10: foreground color query (less, vim)
267267+ // Return true to consume the sequence so it doesn't leak to clients.
253268 this.terminal.parser.registerOscHandler(10, (data: string) => {
254269 if (data === "?") {
255270 this.ptyProcess.write("\x1b]10;rgb:c0c0/c0c0/c0c0\x1b\\");
271271+ return true; // consume — don't pass to client
256272 }
257273 return false;
258274 });
···260276 this.terminal.parser.registerOscHandler(11, (data: string) => {
261277 if (data === "?") {
262278 this.ptyProcess.write("\x1b]11;rgb:0000/0000/0000\x1b\\");
279279+ return true;
263280 }
264281 return false;
265282 });
266283 // OSC 4: palette color query (vim, emacs)
267284 this.terminal.parser.registerOscHandler(4, (data: string) => {
268285 if (data.includes("?")) {
269269- // Format: "N;?" — respond with color N's value
270286 const idx = parseInt(data, 10);
271287 if (!isNaN(idx)) {
272272- // Respond with xterm-256color default for this index
273288 this.ptyProcess.write(`\x1b]4;${idx};rgb:0000/0000/0000\x1b\\`);
274289 }
290290+ return true;
275291 }
276292 return false;
277293 });
···340356 throw err;
341357 }
342358343343- // Feed PTY output into xterm-headless and broadcast to clients
359359+ // Feed PTY output into xterm-headless and broadcast to clients.
360360+ // Query sequences (OSC 10/11, DA1, etc.) are intercepted by parser
361361+ // handlers above and must NOT be forwarded to clients — otherwise the
362362+ // client's terminal responds and its response appears as garbage input.
344363 this.ptyProcess.onData((data: string) => {
345364 this.terminal.write(data);
346346- this.broadcast(encodeData(data));
365365+ const cleaned = stripTerminalQueries(data);
366366+ if (cleaned.length > 0) {
367367+ this.broadcast(encodeData(cleaned));
368368+ }
347369 });
348370349371 this.ptyProcess.onExit(({ exitCode }) => {
+74
tests/terminal-queries.test.ts
···22import * as fs from "node:fs";
33import * as os from "node:os";
44import { Session } from "../src/testing/index.ts";
55+import { stripTerminalQueries } from "../src/server.ts";
5667// Isolate from real session directory
78const testSessionDir = fs.mkdtempSync(os.tmpdir() + "/pty-queries-");
···1213 try { fs.rmSync(testSessionDir, { recursive: true, force: true }); } catch {}
1314 resolve(undefined);
1415 }, 500);
1616+ });
1717+});
1818+1919+describe("stripTerminalQueries", () => {
2020+ it("strips OSC 10 query with BEL terminator", () => {
2121+ expect(stripTerminalQueries("\x1b]10;?\x07")).toBe("");
2222+ });
2323+2424+ it("strips OSC 10 query with ST terminator", () => {
2525+ expect(stripTerminalQueries("\x1b]10;?\x1b\\")).toBe("");
2626+ });
2727+2828+ it("strips OSC 11 query with BEL terminator", () => {
2929+ expect(stripTerminalQueries("\x1b]11;?\x07")).toBe("");
3030+ });
3131+3232+ it("strips OSC 11 query with ST terminator", () => {
3333+ expect(stripTerminalQueries("\x1b]11;?\x1b\\")).toBe("");
3434+ });
3535+3636+ it("strips OSC 4 palette query with BEL", () => {
3737+ expect(stripTerminalQueries("\x1b]4;7;?\x07")).toBe("");
3838+ expect(stripTerminalQueries("\x1b]4;255;?\x07")).toBe("");
3939+ });
4040+4141+ it("strips OSC 4 palette query with ST", () => {
4242+ expect(stripTerminalQueries("\x1b]4;0;?\x1b\\")).toBe("");
4343+ });
4444+4545+ it("strips DA1 query", () => {
4646+ expect(stripTerminalQueries("\x1b[c")).toBe("");
4747+ });
4848+4949+ it("strips DA2 query", () => {
5050+ expect(stripTerminalQueries("\x1b[>c")).toBe("");
5151+ });
5252+5353+ it("strips DSR cursor position query", () => {
5454+ expect(stripTerminalQueries("\x1b[6n")).toBe("");
5555+ });
5656+5757+ it("strips XTVERSION query", () => {
5858+ expect(stripTerminalQueries("\x1b[>0q")).toBe("");
5959+ });
6060+6161+ it("preserves normal text", () => {
6262+ expect(stripTerminalQueries("hello world")).toBe("hello world");
6363+ });
6464+6565+ it("preserves normal ANSI sequences", () => {
6666+ const ansi = "\x1b[1;31mred bold\x1b[0m";
6767+ expect(stripTerminalQueries(ansi)).toBe(ansi);
6868+ });
6969+7070+ it("strips queries embedded in normal output", () => {
7171+ expect(stripTerminalQueries("before\x1b]11;?\x07after")).toBe("beforeafter");
7272+ });
7373+7474+ it("strips multiple queries in one chunk", () => {
7575+ const data = "\x1b]10;?\x07\x1b]11;?\x07\x1b[c";
7676+ expect(stripTerminalQueries(data)).toBe("");
7777+ });
7878+7979+ it("preserves OSC sequences that are not queries", () => {
8080+ // OSC 0 (set title) should pass through
8181+ const title = "\x1b]0;my title\x07";
8282+ expect(stripTerminalQueries(title)).toBe(title);
8383+ });
8484+8585+ it("does not strip OSC 10/11 set commands (only queries)", () => {
8686+ // Setting foreground color (not a query — no "?")
8787+ const set = "\x1b]10;rgb:ffff/0000/0000\x07";
8888+ expect(stripTerminalQueries(set)).toBe(set);
1589 });
1690});
1791