Problem
When the WASM parser encounters ESC k <text> ESC \, it logs unimplemented ESC action: ESC k and then renders <text> onto the terminal grid, silently consuming the trailing ESC \. This is the GNU screen / tmux title-setting extension — the payload should never appear on the display.
OSC 0; / 2; title-setting in the same build works correctly (payload is consumed, not painted), so this is specific to the ESC k code path.
Expected result
Neither title string (/tmp, ls) should appear on the grid.
Actual result
0: "/tmp"
1: "lsdemo.txt"
Both title strings leak. The second one is jammed into the first line of visible output, which on a real shell manifests as the running command's name concatenated with whatever it prints.
Console (per term.write() call):
[ghostty-vt] warning(stream): unimplemented ESC action: ESC k
[ghostty-vt] warning(stream): unimplemented ESC action: ESC k
Also verified:
- Same result whether
term.write() is called with Uint8Array or with a plain string.
- Same build correctly consumes
ESC ] 0 ; /tmp BEL (OSC 0) without leaking.
Reproduction
Standalone. ghostty-web@0.4.0, headless Chromium 146, macOS 15 arm64, Node 25.
mkdir ghostty-repro && cd ghostty-repro
npm init -y >/dev/null
npm install ghostty-web puppeteer
# save the file below as repro.mjs, then:
node repro.mjs
repro.mjs:
import http from "node:http";
import fs from "node:fs/promises";
import path from "node:path";
import puppeteer from "puppeteer";
const ROOT = path.dirname(new URL(import.meta.url).pathname);
const PORT = 5765;
// WASM needs to load over http(s), not file://, so serve the installed
// ghostty-web package from a tiny local static server.
const server = http.createServer(async (req, res) => {
const url = req.url === "/" ? "/index.html" : req.url;
const local = url.startsWith("/ghostty-web/")
? path.join(ROOT, "node_modules/ghostty-web", url.slice("/ghostty-web/".length))
: path.join(ROOT, url);
try {
const data = await fs.readFile(local);
const ext = path.extname(local);
res.writeHead(200, {
"Content-Type":
ext === ".js" || ext === ".mjs" ? "text/javascript" :
ext === ".wasm" ? "application/wasm" :
ext === ".html" ? "text/html" : "application/octet-stream",
});
res.end(data);
} catch {
res.writeHead(404).end();
}
});
await new Promise(r => server.listen(PORT, r));
await fs.writeFile(
path.join(ROOT, "index.html"),
`<!doctype html><meta charset="utf-8"><body><div id="t"></div></body>`,
);
const browser = await puppeteer.launch({ headless: "new" });
const page = await browser.newPage();
page.on("console", m => {
const t = m.text();
if (t.includes("unimplemented") || t.includes("warning")) console.log(" [page]", t);
});
await page.goto(`http://localhost:${PORT}/`);
const lines = await page.evaluate(async () => {
const mod = await import("/ghostty-web/dist/ghostty-web.js");
const ghostty = await mod.Ghostty.load();
const term = new mod.Terminal({ cols: 80, rows: 5, ghostty });
term.open(document.getElementById("t"));
// ESC k /tmp ESC \ (screen/tmux title-set #1)
// \r\n
// ESC k ls ESC \ (screen/tmux title-set #2)
// demo.txt\r\n (visible body)
term.write(new Uint8Array([
0x1b, 0x6b, 0x2f, 0x74, 0x6d, 0x70, 0x1b, 0x5c,
0x0d, 0x0a,
0x1b, 0x6b, 0x6c, 0x73, 0x1b, 0x5c,
0x64, 0x65, 0x6d, 0x6f, 0x2e, 0x74, 0x78, 0x74, 0x0d, 0x0a,
]));
await new Promise(r => setTimeout(r, 150));
const out = [];
const active = term.buffer.active;
for (let y = 0; y < term.rows; y++) {
const line = active.getLine(y);
if (line) out.push(line.translateToString(true));
}
return out;
});
console.log("\nRendered buffer:");
lines.forEach((l, i) => console.log(` ${i}: ${JSON.stringify(l)}`));
await browser.close();
server.close();
Running it prints:
[page] [ghostty-vt] warning(stream): unimplemented ESC action: ESC k
[page] [ghostty-vt] warning(stream): unimplemented ESC action: ESC k
Rendered buffer:
0: "/tmp"
1: "lsdemo.txt"
2: ""
3: ""
4: ""
Suggested fix
Recognize ESC k <text> ESC \ as the screen/tmux title-setting extension and consume it without rendering the payload. Routing <text> to a title-change listener (as OSC 0/2 already do) would be a bonus but isn't required — silently discarding is enough to fix the visible bug.
Problem
When the WASM parser encounters
ESC k <text> ESC \, it logsunimplemented ESC action: ESC kand then renders<text>onto the terminal grid, silently consuming the trailingESC \. This is the GNU screen / tmux title-setting extension — the payload should never appear on the display.OSC
0;/2;title-setting in the same build works correctly (payload is consumed, not painted), so this is specific to theESC kcode path.Expected result
Neither title string (
/tmp,ls) should appear on the grid.Actual result
Both title strings leak. The second one is jammed into the first line of visible output, which on a real shell manifests as the running command's name concatenated with whatever it prints.
Console (per
term.write()call):Also verified:
term.write()is called withUint8Arrayor with a plain string.ESC ] 0 ; /tmp BEL(OSC 0) without leaking.Reproduction
Standalone.
ghostty-web@0.4.0, headless Chromium 146, macOS 15 arm64, Node 25.repro.mjs:Running it prints:
Suggested fix
Recognize
ESC k <text> ESC \as the screen/tmux title-setting extension and consume it without rendering the payload. Routing<text>to a title-change listener (as OSC 0/2 already do) would be a bonus but isn't required — silently discarding is enough to fix the visible bug.