Skip to content

ESC k <text> ESC \ (screen/tmux title sequence) leaks payload as visible text #153

@Fisher-Wang

Description

@Fisher-Wang

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

0: ""
1: "demo.txt"

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions