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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions