diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 8c76461..2b11480 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -79,6 +79,21 @@ function expectEchoRender( expect(renderArgs[0][1]).toBe(false); } +/** + * Helper to get visible text for a terminal row. + */ +function getTerminalLineText(term: Terminal, y: number): string { + const line = term.wasmTerm?.getLine(y); + if (!line) { + throw new Error(`Unable to read terminal line ${y}`); + } + + return line + .map((cell) => String.fromCodePoint(cell.codepoint || 32)) + .join('') + .trimEnd(); +} + describe('Terminal', () => { let container: HTMLElement; @@ -939,6 +954,107 @@ describe('onKey event', () => { }); }); +describe('GNU screen/tmux title sequences', () => { + const issueReproSequence = '\x1bk/tmp\x1b\\\r\n\x1bkls\x1b\\demo.txt\r\n'; + let container: HTMLElement | null = null; + + beforeEach(async () => { + if (typeof document !== 'undefined') { + container = document.createElement('div'); + document.body.appendChild(container); + } + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + container = null; + } + }); + + test('ESC k title payload is ignored for string writes', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + if (!container) return; + term.open(container); + + try { + term.write(issueReproSequence); + + expect(getTerminalLineText(term, 0)).toBe(''); + expect(getTerminalLineText(term, 1)).toBe('demo.txt'); + } finally { + term.dispose(); + } + }); + + test('ESC k title payload is ignored for BEL-terminated string writes', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + if (!container) return; + term.open(container); + + try { + term.write('\x1bkfoo\x07bar\r\n'); + + expect(getTerminalLineText(term, 0)).toBe('bar'); + } finally { + term.dispose(); + } + }); + + test('ESC k title payload is ignored for 8-bit ST-terminated Uint8Array writes', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + if (!container) return; + term.open(container); + + try { + term.write( + new Uint8Array([0x1b, 0x6b, 0x66, 0x6f, 0x6f, 0x9c, 0x62, 0x61, 0x72, 0x0d, 0x0a]) + ); + + expect(getTerminalLineText(term, 0)).toBe('bar'); + } finally { + term.dispose(); + } + }); + + test('ESC k title payload is ignored for Uint8Array writes', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + if (!container) return; + term.open(container); + + try { + term.write(new TextEncoder().encode(issueReproSequence)); + + expect(getTerminalLineText(term, 0)).toBe(''); + expect(getTerminalLineText(term, 1)).toBe('demo.txt'); + } finally { + term.dispose(); + } + }); + + test('ESC k title payload remains ignored across split writes', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + if (!container) return; + term.open(container); + + try { + term.write('\x1b'); + term.write('k/tmp'); + term.write('\x1b'); + term.write('\\\r\n'); + term.write('\x1b'); + term.write('kls'); + term.write('\x1b'); + term.write('\\demo.txt\r\n'); + + expect(getTerminalLineText(term, 0)).toBe(''); + expect(getTerminalLineText(term, 1)).toBe('demo.txt'); + } finally { + term.dispose(); + } + }); +}); + describe('onTitleChange event', () => { let container: HTMLElement | null = null; diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index 231b32a..67d19c7 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -1593,6 +1593,58 @@ index ba2af2473..b8be8f273 100644 + // preserving the backing cell offset and dirty state. + row.* = .{ .cells = cells_offset, .dirty = dirty }; } +diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig +index 980906e49..c175d9d5b 100644 +--- a/src/terminal/Parser.zig ++++ b/src/terminal/Parser.zig +@@ -27,6 +27,7 @@ pub const State = enum { + dcs_ignore, + osc_string, + sos_pm_apc_string, ++ screen_title_string, + }; + + /// Transition action is an action that can be taken during a state +diff --git a/src/terminal/parse_table.zig b/src/terminal/parse_table.zig +index 01bd569cb..fe6701794 100644 +--- a/src/terminal/parse_table.zig ++++ b/src/terminal/parse_table.zig +@@ -148,6 +148,10 @@ fn genTable() Table { + // => dcs_entry + single(&result, 0x50, source, .dcs_entry, .none); + ++ // GNU screen/tmux title sequence: ESC k ST/BEL ++ // Consume payload so it never reaches the visible grid. ++ single(&result, 0x6B, source, .screen_title_string, .none); ++ + // => csi_entry + single(&result, 0x5B, source, .csi_entry, .none); + +@@ -324,6 +328,24 @@ fn genTable() Table { + range(&result, 0x3C, 0x3F, source, .csi_param, .collect); + } + ++ // screen_title_string ++ { ++ const source = State.screen_title_string; ++ ++ // events ++ single(&result, 0x19, source, source, .ignore); ++ range(&result, 0, 0x06, source, source, .ignore); ++ range(&result, 0x08, 0x17, source, source, .ignore); ++ range(&result, 0x1C, 0x1F, source, source, .ignore); ++ range(&result, 0x20, 0xFF, source, source, .ignore); ++ single(&result, 0x7F, source, source, .ignore); ++ ++ // Accept BEL and the 8-bit ST terminator explicitly; ESC \\ exits ++ // through the existing anywhere ESC transition. ++ single(&result, 0x07, source, .ground, .none); ++ single(&result, 0x9C, source, .ground, .none); ++ } ++ + // osc_string + { + const source = State.osc_string; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index b6430ea34..10e0ef79d 100644 --- a/src/terminal/render.zig