Skip to content
Source
Sign inSign up

[Bug]: Terminal paste drops a leading U+FEFF when bracketed paste is disabled #36001

Send to my agent
Closedcoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

Pasting text that starts with U+FEFF into a terminal program that has bracketed paste disabled silently loses that character. Paste should preserve this text character while applying the documented control-character filtering. A library-level reproduction with the vendored engine preserves the character in the raw engine output but loses it in T3 Code's browser adapter; the same text survives when bracketed paste is enabled.

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/web

Actual behavior

With mode 2004 disabled, GhosttyTerminalCore.encodePaste("\ufeffsample") returns "sample". Directly calling the vendored ghostty_paste_encode with the same input and bracketed mode disabled returns UTF-8 bytes ef bb bf 73 61 6d 70 6c 65, retaining U+FEFF. The adapter returns 73 61 6d 70 6c 65. Plain text, an embedded U+FEFF, unsafe-control filtering, and the bracketed version of the same paste all pass their controls.

The supported UI route is established by source: native paste calls this encoder before onData, and context-menu paste calls it before delivering the result (apps/web/src/terminal/ghostty/surface.ts#L1289-L1302, apps/web/src/terminal/ghostty/surface.ts#L975-L985). An interactive browser or desktop paste was not run.

Steps to reproduce

  1. Use a clean checkout of the commit under Version and Node with built-in TypeScript type stripping.
  2. Save the script below as repro-leading-feff.mjs and run node repro-leading-feff.mjs /path/to/t3code.
  3. Observe that the raw engine retains the leading character and the unbracketed adapter removes it, while all controls pass.

The focused reproduction failed once in one run. It uses the checkout's source and vendored WASM, requires no dependency installation, starts no shell or server, and copies modules into an automatically removed temporary directory only to resolve browser asset imports in Node. It gives HOME, TMPDIR, and T3CODE_HOME disposable directories and leaves the checkout unchanged. Exit 0 means the character is preserved, exit 1 means the defect is reproduced, and exit 2 means setup or a discriminating control failed.

Reproduction script: repro-leading-feff.mjs
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";

console.log(`node ${process.version}; ${process.platform} ${process.arch}`);
let scratch;
let core;
let status = 2;
try {
  const repo = path.resolve(process.argv[2] ?? "");
  const read = (name) => fs.readFileSync(path.join(repo, name), "utf8");
  scratch = fs.mkdtempSync(path.join(os.tmpdir(), "terminal-paste-"));
  for (const name of ["home", "tmp", "state"]) fs.mkdirSync(path.join(scratch, name));
  process.env.HOME = path.join(scratch, "home");
  process.env.TMPDIR = path.join(scratch, "tmp");
  process.env.T3CODE_HOME = path.join(scratch, "state");
  const put = (name, text) => fs.writeFileSync(path.join(scratch, name), text);
  const base = "apps/web/src/terminal/ghostty/";
  const wasmUrl = (name) => "data:application/wasm;base64," + fs.readFileSync(path.join(repo, base, "vendor", name)).toString("base64");
  // Only module resolution changes: the source methods and vendored WASM remain unchanged.
  put("runtime.ts", read(base + "runtime.ts")
    .replace(/^import ghosttyWasmUrl .*;$/m, "const ghosttyWasmUrl=" + JSON.stringify(wasmUrl("ghostty-vt.wasm")) + ";")
    .replace(/^import ghosttyWritePtyWasmUrl .*;$/m, "const ghosttyWritePtyWasmUrl=" + JSON.stringify(wasmUrl("ghostty-write-pty.wasm")) + ";"));
  const shifted = read("packages/shared/src/keybindings.ts").match(/export const US_SHIFTED_TO_UNSHIFTED_KEYS[\s\S]*?\n\]\);/)[0];
  const isMac = read("packages/shared/src/keybindingMatching.ts").match(/export function isMacPlatform[\s\S]*?\n\}/)[0];
  put("keyCodes.ts", read(base + "keyCodes.ts").replace(/^import .*;$/m, shifted + "\n" + isMac));
  put("core.ts", read(base + "core.ts").replace('from "./keyCodes"', 'from "./keyCodes.ts"').replace('from "./runtime"', 'from "./runtime.ts"'));
  const { GhosttyTerminalCore } = await import(pathToFileURL(path.join(scratch, "core.ts")));
  const { loadGhosttyRuntime } = await import(pathToFileURL(path.join(scratch, "runtime.ts")));
  const runtime = await loadGhosttyRuntime();
  core = await GhosttyTerminalCore.create(80, 24, 8, 16, {
    foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 }, cursor: { r: 255, g: 255, b: 255 },
  }, () => {});
  const input = "\ufeffsample";
  const bytes = new TextEncoder().encode(input);
  const pointer = runtime.alloc(bytes.length);
  const written = runtime.call("ghostty_wasm_alloc_usize");
  let output = 0;
  let outputSize = 0;
  let raw;
  try {
    runtime.bytes(pointer, bytes.length).set(bytes);
    if (runtime.call("ghostty_paste_encode", pointer, bytes.length, 0, 0, 0, written) !== -3) throw new Error("size query failed");
    outputSize = runtime.view(written, 4).getUint32(0, true);
    output = runtime.alloc(outputSize);
    if (runtime.call("ghostty_paste_encode", pointer, bytes.length, 0, output, outputSize, written) !== 0) throw new Error("raw encoding failed");
    raw = Buffer.from(runtime.bytes(output, runtime.view(written, 4).getUint32(0, true))).toString("hex");
  } finally {
    runtime.free(pointer, bytes.length);
    runtime.free(output, outputSize);
    runtime.call("ghostty_wasm_free_usize", written);
  }
  core.write("\x1b[?2004l");
  const actual = core.encodePaste(input);
  const plainControl = core.encodePaste("sample");
  const embeddedControl = core.encodePaste("a\ufeffsample");
  const unsafeControl = core.encodePaste("a\x1bb");
  core.write("\x1b[?2004h");
  const bracketedControl = core.encodePaste(input);
  console.log(`raw WASM UTF-8 hex: ${raw}`);
  console.log(`unbracketed adapter UTF-8 hex: ${Buffer.from(actual).toString("hex")}`);
  console.log(`bracketed adapter UTF-8 hex: ${Buffer.from(bracketedControl).toString("hex")}`);
  const controls = raw === Buffer.from(input).toString("hex") && plainControl === "sample" && embeddedControl === "a\ufeffsample" && unsafeControl === "a b" && bracketedControl === "\x1b[200~" + input + "\x1b[201~";
  console.log(`raw-engine, plain-text, embedded-character, unsafe-control, and bracketed controls: ${controls ? "PASS" : "FAIL"}`);
  status = !controls ? 2 : actual === input ? 0 : 1;
} catch (error) {
  console.log(`Setup error: ${error.name}`);
} finally {
  core?.dispose();
  if (scratch) fs.rmSync(scratch, { recursive: true, force: true });
}
console.log(status === 0 ? "RESULT: expected behavior" : status === 1 ? "RESULT: defect reproduced" : "RESULT: inconclusive");
process.exitCode = status;

Expected behavior

With bracketed paste disabled, the paste result should retain the leading U+FEFF as terminal input. The pinned paste encoder documents unsafe-control replacement, optional bracketing, and LF-to-CR conversion; U+FEFF is not one of its stripped control bytes (native/libghostty-vt/include/ghostty/vt/paste.h#L58-L85, pinned Ghostty src/input/paste.zig#L40-L103). The direct raw-engine control establishes this expectation for the exact shipped artifact.

Impact and scope

Impact: P2 - clipboard text with a leading U+FEFF is silently altered before it reaches the terminal application.

The failure is limited to a leading U+FEFF with mode 2004 off; ordinary text, an embedded U+FEFF, and the same input with mode 2004 on passed. Web and desktop share this adapter. No data loss outside the pasted input was observed. NOT_ENOUGH_INFO: native GUI/browser clipboard behavior and frequency in real copied text were not measured; those need an interactive client run. The bracketed-mode control preserves the character, but whether enabling that mode is a practical workaround depends on the running program and was not tested.

Impact

Minor bug or occasional failure

Version or commit

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. Vendored web libghostty-vt revision 9f62873bf195e4d8a762d768a1405a5f2f7b1697.

Environment

Node v26.12.1; macOS (darwin), arm64. The probe executes the browser adapter and vendored WASM in Node. NOT_ENOUGH_INFO: browser, Electron, and native device runtime versions were not exercised.

Interface

Library: GhosttyTerminalCore.encodePaste, reached by the web/desktop terminal's native paste and clipboard-menu handlers.

Earliest failure stage

Output or presentation: decoding the encoded terminal input bytes back into a JavaScript string before transport.

Evidence

  • Expected source: the pinned paste encoder contract and direct raw-engine control under Expected behavior.
  • Failure source: the adapter output in the reproduction transcript below.
  • Evidence provenance: observed.
  • Local verification: reproduced at the library boundary; UI reachability is source-traced.
  • Reproduction completeness: complete for the library reproduction; interactive clipboard behavior was not tested.

The exact reproduction output was:

node v26.12.1; darwin arm64
raw WASM UTF-8 hex: efbbbf73616d706c65
unbracketed adapter UTF-8 hex: 73616d706c65
bracketed adapter UTF-8 hex: 1b5b3230307eefbbbf73616d706c651b5b3230317e
raw-engine, plain-text, embedded-character, unsafe-control, and bracketed controls: PASS
RESULT: defect reproduced

The shared decoder is constructed with default options (apps/web/src/terminal/ghostty/core.ts#L165-L166) and decodes paste bytes at apps/web/src/terminal/ghostty/core.ts#L626-L628. The raw-versus-adapter comparison isolates the loss to the adapter, rather than the Ghostty paste encoder.

Analysis or suspected cause

Source and runtime evidence: the default TextDecoder treats an initial UTF-8 BOM as an encoding marker and omits it. When mode 2004 is enabled, the bracket prefix precedes the U+FEFF, so it is retained as content. The Encoding Standard's decoder serialization algorithm describes the BOM handling.

Restoration check

The same input must retain U+FEFF with mode 2004 off, while the raw-engine, embedded-character, ordinary-text, unsafe-control, and bracketed controls continue to pass. A passing result must not come from bypassing unsafe-control filtering or disabling paste.

  1. tanstack-source added labelsbug

  2. github-actions[bot](GitHub profile, opens in a new tab) added labelsvouch:denounced

  3. juliusmarminge closed this

Sign in to join this conversation.