Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions lib/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand Down
52 changes: 52 additions & 0 deletions patches/ghostty-wasm-api.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text> 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);
Comment thread
ThomasK33 marked this conversation as resolved.
+ 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
Expand Down
Loading