Fix terminal emoji size and alignment (#8375)

* fix(terminal): render emoji at their intended width

The native grid scales glyphs to the columns assigned by the terminal buffer. Use grapheme widths across current hosts and clients so emoji fit their slots and replay preserves cursor positions.

* fix(terminal): make width regression test portable

* test(terminal): wait for replay fixture output to finish

A trailing newline could arrive after the readiness marker and change the retained history between replay captures.
This commit is contained in:
Mohamed Boudra
2026-11-01 10:33:59 +07:00
committed by GitHub
parent 86c60d367c
commit da75aa7a4a
27 changed files with 422 additions and 99 deletions
+2 -1
View File
@@ -117,7 +117,8 @@
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/addon-serialize": "0.14.0",
"@xterm/headless": "^6.0.0",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/headless": "6.1.0-beta.334",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
@@ -1157,6 +1157,7 @@ describe("relay external socket reconnect behavior", () => {
expect(serverInfo.features?.workspaceMarkUnread).toBe(true);
expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true);
expect(serverInfo.features?.["terminal-size-ownership"]).toBe(true);
expect(serverInfo.features?.["terminal-grapheme-widths"]).toBe(true);
expect(serverInfo.features?.agentTurnIdentity).toBeUndefined();
expect(serverInfo.permissions).toEqual(DAEMON_PERMISSIONS);
await server.close();
@@ -2077,6 +2077,8 @@ export class VoiceAssistantWebSocketServer {
"terminal-input-mode-replay": true,
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
"terminal-size-ownership": true,
// COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01.
"terminal-grapheme-widths": true,
workspaceTerminals: true,
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: true,
@@ -0,0 +1,60 @@
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { createHeadlessTerminal } from "./headless-terminal.js";
const EMOJI_ROW = "A😀👍❤️👋🏽🇰🇷👨‍👩‍👧B";
// A, six two-column emoji clusters, B.
const EMOJI_ROW_END_COL = 1 + 6 * 2 + 1;
async function cursorColAfter(text: string): Promise<number> {
const terminal = createHeadlessTerminal({ rows: 2, cols: 40 });
try {
await new Promise<void>((resolve) => terminal.write(text, resolve));
return terminal.buffer.active.cursorX;
} finally {
terminal.dispose();
}
}
describe("createHeadlessTerminal", () => {
it("gives each emoji cluster one two-column cell", async () => {
expect(await cursorColAfter(EMOJI_ROW)).toBe(EMOJI_ROW_END_COL);
});
it("keeps ASCII and CJK widths", async () => {
expect(await cursorColAfter("ABC")).toBe(3);
expect(await cursorColAfter("中文")).toBe(4);
});
// The width table is decoded into a Node Buffer when the module loads. A
// Buffer carved out of Node's shared pool starts at a non-zero byteOffset,
// and an addon that reads the table header from offset 0 gets wrong widths
// or fails to load. Whether the pool offset is zero depends on earlier
// allocations, so the test forces a non-zero offset in a fresh process
// before the module loads. Zero bytes in front of the table make the
// misread header deterministic.
it("decodes widths correctly when Node's buffer pool offset is non-zero", () => {
const moduleUrl = new URL("./headless-terminal.ts", import.meta.url).href;
const script = `
Buffer.poolSize = 8 * 1024 * 1024;
const occupied = Buffer.from(new Uint8Array(3 * 1024 * 1024));
if (occupied.byteOffset !== 0 || occupied.buffer.byteLength !== Buffer.poolSize) {
throw new Error("expected a fresh shared pool");
}
const { createHeadlessTerminal } = await import(${JSON.stringify(moduleUrl)});
const terminal = createHeadlessTerminal({ rows: 2, cols: 40 });
await new Promise((resolve) => terminal.write(${JSON.stringify(EMOJI_ROW)}, resolve));
process.stdout.write(String(terminal.buffer.active.cursorX));
terminal.dispose();
`;
const result = spawnSync(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", script],
{ cwd: fileURLToPath(new URL(".", import.meta.url)), encoding: "utf8" },
);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toBe(String(EMOJI_ROW_END_COL));
});
});
@@ -0,0 +1,22 @@
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import xterm, { type Terminal } from "@xterm/headless";
export interface HeadlessTerminalOptions {
rows: number;
cols: number;
scrollback?: number;
}
/**
* A headless xterm that measures text the way Paseo clients render it.
*
* Grapheme-cluster widths give every emoji cluster (VS16, skin-tone, flags,
* ZWJ sequences) one two-column cell, matching color-emoji fonts. Clients
* replay this buffer's ANSI with the same table, so cells and the cursor line
* up after reconnect. See docs/terminal-rendering.md.
*/
export function createHeadlessTerminal(options: HeadlessTerminalOptions): Terminal {
const terminal = new xterm.Terminal({ ...options, allowProposedApi: true });
terminal.loadAddon(new UnicodeGraphemesAddon());
return terminal;
}
@@ -563,6 +563,71 @@ describe("createTerminal", () => {
expect(state.grid[0][1].char).toBe("");
});
it("reports the cursor column after an emoji cluster in DSR replies", async () => {
// Programs that measure the cursor (prompts, TUIs) must see the same
// column the client renders: an emoji occupies two cells.
const program = `
process.stdin.setRawMode(true);
process.stdin.once("data", (reply) => {
process.stdout.write("\\r\\nREPLY " + JSON.stringify(reply.toString()).slice(2, -1));
});
process.stdout.write("A😀\\x1b[6n");
setInterval(() => {}, 100000);
`;
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
cols: 40,
rows: 5,
command: process.execPath,
args: ["-e", program],
}),
);
const state = await waitForState(session, (current) =>
getRowText(current, 1).startsWith("REPLY"),
);
expect(getRowText(state, 1)).toBe("REPLY u001b[1;4R");
});
it("replays emoji clusters at their columns with the cursor after them", async () => {
const text = "A😀B❤️C👋🏽D👨‍👩‍👧E";
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
cols: 40,
rows: 5,
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`,
],
}),
);
const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "E"));
expect(state.grid[0].slice(0, 13).map((cell) => cell.char)).toEqual([
"A",
"😀",
"",
"B",
"❤️",
"",
"C",
"👋🏽",
"",
"D",
"👨‍👩‍👧",
"",
"E",
]);
expect(state.cursor).toEqual({ row: 0, col: 13 });
});
it("captures exit diagnostics from the terminal buffer", async () => {
const session = trackSession(
await createTerminal({
+3 -9
View File
@@ -1,5 +1,5 @@
import * as pty from "node-pty";
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
import type { Terminal as TerminalType } from "@xterm/headless";
import { randomUUID } from "crypto";
import {
accessSync,
@@ -25,6 +25,7 @@ import {
findExecutable,
findExecutableWithoutProbing,
} from "../executable-resolution/executable-resolution.js";
import { createHeadlessTerminal } from "./headless-terminal.js";
import { createTerminalSerializer } from "./terminal-serializer.js";
import {
captureTerminalLines,
@@ -36,7 +37,6 @@ import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode
import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js";
import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity";
const { Terminal } = xterm;
const require = createRequire(import.meta.url);
let nodePtySpawnHelperChecked = false;
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
@@ -796,13 +796,7 @@ export async function createTerminal(
const activityChangeListeners = new Set<(transition: TerminalActivityTransition) => void>();
let titleChangeSubscription: { dispose(): void } | null = null;
// Create xterm.js headless terminal
const terminal = new Terminal({
rows,
cols,
scrollback: 10000,
allowProposedApi: true,
});
const terminal = createHeadlessTerminal({ rows, cols, scrollback: 10000 });
const serialize = createTerminalSerializer(terminal);
@@ -1,15 +1,14 @@
import xterm from "@xterm/headless";
import type { TerminalState } from "@getpaseo/protocol/messages";
import { createHeadlessTerminal } from "../headless-terminal.js";
import type { LocalTerminalSession } from "../terminal.js";
/** Observe the public ANSI replay using a real receiver; no production cell extraction API. */
export async function readTerminalState(session: LocalTerminalSession): Promise<TerminalState> {
const { metadata, data } = session.serializeReplay();
const terminal = new xterm.Terminal({
const terminal = createHeadlessTerminal({
rows: metadata.rows,
cols: metadata.cols,
scrollback: 10000,
allowProposedApi: true,
});
try {
await new Promise<void>((resolve) => terminal.write(data, resolve));
@@ -1863,7 +1863,8 @@ it("completes a replay larger than the socket budget while a sibling stays usabl
...nodeTerminalCommand(`
const row = Array.from({length: 1000}, (_, i) => "\\x1b[" + (31 + i % 2) + "mX").join("");
for (let i = 0; i < 900; i++) process.stdout.write(row + "\\r\\n");
process.stdout.write("READY-WIDE\\r\\n");
// Seeing the marker must mean no trailing newline can still add history.
process.stdout.write("READY-WIDE");
process.stdin.on("data", data => { if (data.toString().includes("small")) { process.stdout.write("SAME-TERMINAL-LIVE\\r\\n"); return; } process.stdout.write("q".repeat(8 * 1024 * 1024) + "\\r\\nRECOVERED-LIVE\\r\\n", () => require("node:fs").writeFileSync(${JSON.stringify(overflowProgress)}, "done")); });
setInterval(() => {}, 1000);
`),