···167167 client.rows = size.rows;
168168 client.cols = size.cols;
169169 client.attachSeq = ++this.attachCounter;
170170- this.negotiateSize();
170170+ const resized = this.negotiateSize();
171171172172- // Send current screen state
173173- const screen = this.serialize.serialize();
174174- socket.write(encodeScreen(screen));
172172+ const sendScreen = () => {
173173+ if (socket.destroyed) return;
174174+ const screen = this.serialize.serialize();
175175+ socket.write(encodeScreen(screen));
176176+ if (this.exited) {
177177+ socket.write(encodeExit(this.exitCode));
178178+ }
179179+ };
175180176176- // If already exited, tell them immediately
177177- if (this.exited) {
178178- socket.write(encodeExit(this.exitCode));
181181+ if (resized && !this.exited) {
182182+ // The PTY was resized, which sends SIGWINCH to the process.
183183+ // Wait briefly so the process can redraw before we serialize,
184184+ // otherwise the client sees a transient state (e.g., cursor
185185+ // clamped to the new width instead of where the TUI places it).
186186+ setTimeout(sendScreen, 50);
187187+ } else {
188188+ sendScreen();
179189 }
180190 break;
181191 }
···228238 });
229239 }
230240231231- private negotiateSize(): void {
241241+ /** Resize the PTY to match the most recently attached client.
242242+ * Returns true if the size actually changed. */
243243+ private negotiateSize(): boolean {
232244 // Use the most recently attached/resized non-readonly client's size
233245 let lastClient: Client | null = null;
234246 for (const client of this.clients.values()) {
···244256 if (rows !== this.terminal.rows || cols !== this.terminal.cols) {
245257 this.ptyProcess.resize(cols, rows);
246258 this.terminal.resize(cols, rows);
259259+ return true;
247260 }
248261 }
262262+ return false;
249263 }
250264251265 private broadcast(data: Buffer): void {
+88
tests/integration.test.ts
···33import * as fs from "node:fs";
44import * as os from "node:os";
55import * as path from "node:path";
66+import { Terminal } from "@xterm/headless";
67import { PtyServer, type ServerOptions } from "../src/server.ts";
78import {
89 MessageType,
···419420 peeker.destroy();
420421 });
421422423423+ it("peek captures TUI app running in alternate screen buffer", async () => {
424424+ const name = uniqueName();
425425+ // Simulate a TUI app: enter alt screen, enable mouse tracking, draw content
426426+ await startServer(name, "sh", [
427427+ "-c",
428428+ "printf '\\033[?1049h';" + // enter alternate screen
429429+ "printf '\\033[?1000h';" + // enable mouse click tracking
430430+ "printf '\\033[?1003h';" + // enable mouse any-event tracking
431431+ "printf '\\033[?1h';" + // enable application cursor keys
432432+ "printf '\\033[H';" + // home cursor
433433+ "printf '\\033[32mTUI-PEEK-TEST\\033[0m\\n';" +
434434+ "printf 'Status: running\\n';" +
435435+ "sleep 30",
436436+ ]);
437437+438438+ await new Promise((r) => setTimeout(r, 300));
439439+440440+ // Peek should capture the alternate screen content
441441+ const peeker = await connect(name);
442442+ const peekReader = new PacketReader();
443443+ peeker.write(encodePeek());
444444+445445+ const screenPacket = await waitForType(peeker, peekReader, MessageType.SCREEN);
446446+ const screen = screenPacket.payload.toString();
447447+ expect(screen).toContain("TUI-PEEK-TEST");
448448+ expect(screen).toContain("Status: running");
449449+450450+ peeker.destroy();
451451+ });
452452+422453 it("writes session metadata on creation", async () => {
423454 const name = uniqueName();
424455 await startServer(name, "cat", ["-u"]);
···630661631662 client.destroy();
632663 });
664664+665665+ it("SCREEN cursor position matches process intent after resize", async () => {
666666+ const name = uniqueName();
667667+668668+ // TUI-like process: enters alt screen, positions cursor at col 60.
669669+ // On SIGWINCH: redraws with cursor at col 10.
670670+ // Using "sleep & wait" so bash processes the trap immediately when
671671+ // SIGWINCH interrupts the wait builtin (no sleep cycle delay).
672672+ await startServer(
673673+ name,
674674+ "bash",
675675+ [
676676+ "-c",
677677+ "printf '\\033[?1049h\\033[2J\\033[1;1HTitle\\033[5;60H'; " +
678678+ "trap 'printf \"\\033[2J\\033[1;1HTitle\\033[5;10H\"' WINCH; " +
679679+ "sleep 300 & wait; sleep 300",
680680+ ],
681681+ { rows: 24, cols: 80 }
682682+ );
683683+684684+ await new Promise((r) => setTimeout(r, 500));
685685+686686+ // Attach at original size, verify cursor is at (row 5, col 60) = (4, 59) 0-indexed
687687+ const client1 = await connect(name);
688688+ const reader1 = new PacketReader();
689689+ client1.write(encodeAttach(24, 80));
690690+ const screen1 = await waitForType(client1, reader1, MessageType.SCREEN);
691691+692692+ const t1 = new Terminal({ rows: 24, cols: 80, allowProposedApi: true });
693693+ await new Promise<void>((r) => t1.write(screen1.payload.toString(), r));
694694+ expect(t1.buffer.active.cursorY).toBe(4);
695695+ expect(t1.buffer.active.cursorX).toBe(59);
696696+ t1.dispose();
697697+698698+ client1.destroy();
699699+ await new Promise((r) => setTimeout(r, 200));
700700+701701+ // Reconnect at a NARROWER terminal — col 60 doesn't exist in 40 cols.
702702+ // The server resizes xterm-headless to 40 cols (clamping cursor to col 39),
703703+ // then serializes BEFORE the process can respond to SIGWINCH.
704704+ const client2 = await connect(name);
705705+ const reader2 = new PacketReader();
706706+ client2.write(encodeAttach(24, 40));
707707+ const screen2 = await waitForType(client2, reader2, MessageType.SCREEN);
708708+709709+ const t2 = new Terminal({ rows: 24, cols: 40, allowProposedApi: true });
710710+ await new Promise<void>((r) => t2.write(screen2.payload.toString(), r));
711711+712712+ // The process's SIGWINCH trap will redraw with cursor at (4, 9).
713713+ // But SCREEN was serialized before the trap could fire, so the cursor
714714+ // is at the clamped position (4, 39) — not where the process wants it.
715715+ expect(t2.buffer.active.cursorY).toBe(4);
716716+ expect(t2.buffer.active.cursorX).toBe(9); // FAILS: actual is 39 (clamped)
717717+ t2.dispose();
718718+719719+ client2.destroy();
720720+ }, 15000);
633721});
+189
tests/sanitize.test.ts
···11+import { describe, it, expect } from "vitest";
22+import { Terminal } from "@xterm/headless";
33+import { TERMINAL_SANITIZE } from "../src/client.ts";
44+55+// Helper: write to terminal and wait for processing
66+function write(terminal: Terminal, data: string): Promise<void> {
77+ return new Promise((resolve) => terminal.write(data, resolve));
88+}
99+1010+// Helper: read the character at a given row/col
1111+function charAt(terminal: Terminal, row: number, col: number): string {
1212+ const line = terminal.buffer.active.getLine(row);
1313+ if (!line) return "";
1414+ const cell = line.getCell(col);
1515+ if (!cell) return "";
1616+ return cell.getChars();
1717+}
1818+1919+// Helper: read a range of characters from a row
2020+function textAt(terminal: Terminal, row: number, startCol: number, len: number): string {
2121+ let result = "";
2222+ for (let i = 0; i < len; i++) {
2323+ result += charAt(terminal, row, startCol + i);
2424+ }
2525+ return result;
2626+}
2727+2828+describe("TERMINAL_SANITIZE resets poisoned terminal modes", () => {
2929+ // ─── Autowrap (DECAWM) ───
3030+ //
3131+ // TUI apps sometimes disable line wrapping (\x1b[?7l) so that long lines
3232+ // are truncated at the right margin instead of wrapping. If not re-enabled,
3333+ // the user's shell output won't wrap — text past the terminal width is lost.
3434+3535+ it("re-enables autowrap (DECAWM) after it was disabled", async () => {
3636+ const t = new Terminal({ rows: 10, cols: 5, allowProposedApi: true });
3737+ await write(t, "\x1b[?7l"); // disable autowrap
3838+ await write(t, TERMINAL_SANITIZE);
3939+4040+ // Write 6 characters into a 5-column terminal.
4141+ // With autowrap ON: "12345" on row 0, "X" on row 1.
4242+ // With autowrap OFF: "1234X" on row 0 (last char overwrites), nothing on row 1.
4343+ await write(t, "12345X");
4444+4545+ expect(charAt(t, 1, 0)).toBe("X");
4646+ t.dispose();
4747+ });
4848+4949+ // ─── DEC Special Graphics character set ───
5050+ //
5151+ // Programs that draw box-drawing lines (e.g., borders in TUI dashboards)
5252+ // switch G0 to DEC Special Graphics via \x1b(0. In this character set,
5353+ // ASCII letters map to line-drawing symbols (e.g., 'q' → '─', 'a' → '▒').
5454+ // If not reset to ASCII (\x1b(B), typing in the shell produces box chars.
5555+5656+ it("resets G0 character set to ASCII after DEC line drawing was enabled", async () => {
5757+ const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true });
5858+ await write(t, "\x1b(0"); // switch G0 to DEC Special Graphics
5959+ await write(t, TERMINAL_SANITIZE);
6060+6161+ // Write regular ASCII. If charset is still DEC line drawing:
6262+ // 'a' → '▒', 'q' → '─', 'j' → '┘'
6363+ // If charset was properly reset to ASCII:
6464+ // 'a' → 'a', 'q' → 'q', 'j' → 'j'
6565+ await write(t, "aqj");
6666+6767+ expect(charAt(t, 0, 0)).toBe("a");
6868+ expect(charAt(t, 0, 1)).toBe("q");
6969+ expect(charAt(t, 0, 2)).toBe("j");
7070+ t.dispose();
7171+ });
7272+7373+ // ─── Insert mode (IRM) ───
7474+ //
7575+ // Insert mode (\x1b[4h) causes newly written characters to push existing
7676+ // characters to the right instead of overwriting them. If left on, shell
7777+ // output would insert instead of overwrite, producing garbled display.
7878+7979+ it("resets insert mode (IRM) to replace mode", async () => {
8080+ const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true });
8181+ await write(t, "\x1b[4h"); // enable insert mode
8282+ await write(t, TERMINAL_SANITIZE);
8383+8484+ // Write "AB", then move cursor back to column 0 and write "X".
8585+ // Replace mode (default): row 0 = "XB"
8686+ // Insert mode: row 0 = "XAB"
8787+ await write(t, "AB");
8888+ await write(t, "\x1b[1;1H"); // cursor to row 1, col 1 (home)
8989+ await write(t, "X");
9090+9191+ expect(charAt(t, 0, 0)).toBe("X");
9292+ expect(charAt(t, 0, 1)).toBe("B");
9393+ expect(charAt(t, 0, 2)).toBe(""); // should be empty in replace mode
9494+ t.dispose();
9595+ });
9696+9797+ // ─── Origin mode (DECOM) + scroll region (DECSTBM) ───
9898+ //
9999+ // Origin mode (\x1b[?6h) makes cursor positioning relative to the scroll
100100+ // region instead of the full screen. Combined with a scroll region
101101+ // (\x1b[<top>;<bottom>r), cursor home goes to the top of the region, not
102102+ // the top of the screen. If left set, cursor addressing is wrong.
103103+104104+ it("resets origin mode and scroll region", async () => {
105105+ const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true });
106106+ await write(t, "\x1b[3;8r"); // scroll region: rows 3-8
107107+ await write(t, "\x1b[?6h"); // enable origin mode
108108+ await write(t, TERMINAL_SANITIZE);
109109+110110+ // Move to "home" position
111111+ await write(t, "\x1b[H");
112112+ await write(t, "X");
113113+114114+ // With origin mode OFF and no scroll region:
115115+ // \x1b[H goes to row 0, col 0 (absolute top-left)
116116+ // With origin mode ON and scroll region 3-8:
117117+ // \x1b[H goes to row 2 (0-indexed), col 0 (top of scroll region)
118118+ expect(charAt(t, 0, 0)).toBe("X");
119119+ t.dispose();
120120+ });
121121+122122+ // ─── Scroll region alone (DECSTBM) ───
123123+ //
124124+ // Even without origin mode, a leftover scroll region restricts where
125125+ // scrolling happens. New output at the bottom would only scroll within the
126126+ // region, leaving lines above and below frozen.
127127+128128+ it("resets scroll region to full terminal", async () => {
129129+ const t = new Terminal({ rows: 10, cols: 20, allowProposedApi: true });
130130+ await write(t, "\x1b[3;5r"); // restrict scrolling to rows 3-5
131131+ await write(t, TERMINAL_SANITIZE);
132132+133133+ // Write "MARKER" on row 3 (1-indexed), which is inside the old scroll region.
134134+ await write(t, "\x1b[3;1H");
135135+ await write(t, "MARKER");
136136+137137+ // Move to row 5 (1-indexed) — the old scroll region's bottom row.
138138+ // Write a newline:
139139+ // If scroll region was reset (full terminal): cursor moves to row 6,
140140+ // no scrolling occurs (not at terminal bottom), MARKER stays on row 3.
141141+ // If scroll region is still 3-5: cursor is at bottom of region, newline
142142+ // scrolls within the region, MARKER is pushed off the top of the region.
143143+ await write(t, "\x1b[5;1H");
144144+ await write(t, "\n");
145145+ await write(t, "BELOW");
146146+147147+ // MARKER should still be at row 2 (0-indexed) if scroll region was reset
148148+ expect(textAt(t, 2, 0, 6)).toBe("MARKER");
149149+ // BELOW should be at row 4 (0-indexed = row 5 in 1-indexed, after \n moved to row 6 → 0-indexed 5)
150150+ expect(textAt(t, 5, 0, 5)).toBe("BELOW");
151151+ t.dispose();
152152+ });
153153+154154+ // ─── Application keypad mode (DECKPAM) ───
155155+ //
156156+ // Application keypad mode (\x1b=) changes numpad key sequences from normal
157157+ // numbers to application-mode escape sequences. If left on, pressing numpad
158158+ // keys in the shell produces escape sequences instead of numbers.
159159+ // Testing this behaviorally is hard, so verify TERMINAL_SANITIZE includes
160160+ // the reset sequence \x1b> (DECKPNM — normal keypad mode).
161161+162162+ it("includes application keypad mode reset (DECKPNM)", () => {
163163+ expect(TERMINAL_SANITIZE).toContain("\x1b>");
164164+ });
165165+166166+ // ─── Focus event reporting ───
167167+ //
168168+ // Focus reporting (\x1b[?1004h) tells the terminal to send \x1b[I (focus in)
169169+ // and \x1b[O (focus out) events. If left on, switching terminal windows/tabs
170170+ // produces garbage escape sequences in the shell.
171171+172172+ it("disables focus event reporting", () => {
173173+ expect(TERMINAL_SANITIZE).toContain("\x1b[?1004l");
174174+ });
175175+176176+ // ─── Cursor style ───
177177+ //
178178+ // Programs like vim change cursor style (e.g., \x1b[6 q for bar cursor in
179179+ // insert mode). If not reset, the user's cursor stays as a bar/underline
180180+ // instead of reverting to the terminal's default style.
181181+182182+ it("resets cursor style to terminal default", () => {
183183+ // \x1b[0 q or \x1b[ q resets cursor to user's default
184184+ const hasReset =
185185+ TERMINAL_SANITIZE.includes("\x1b[0 q") ||
186186+ TERMINAL_SANITIZE.includes("\x1b[ q");
187187+ expect(hasReset).toBe(true);
188188+ });
189189+});