···44export interface TermOptions {
55 height: number;
66 width: number;
77+88+ /**
99+ * Wrap every full-screen frame in Synchronized Output (DEC mode 2026:
1010+ * `CSI ?2026 h` … `CSI ?2026 l`) so the terminal presents it atomically and
1111+ * never tears mid-update. Terminals that don't support the mode ignore it, so
1212+ * this is safe to leave on. Set `false` for raw output (e.g. piping, or a
1313+ * terminal you've found misbehaves). Inline (line mode) frames are never
1414+ * wrapped. Defaults to `true`.
1515+ */
1616+ sync?: boolean;
717}
818919export interface RenderOptions {
···7484}
75857686export async function createTerm(options: TermOptions): Promise<Term> {
7777- let { width, height } = options;
7878- let native = await createTermNative(width, height);
8787+ let { width, height, sync = true } = options;
8888+ let native = await createTermNative(width, height, sync);
7989 let { memory, statePtr, opsBuf } = native;
80908191 let prev = new Set<string>();
+60-3
test/term.test.ts
···5252 ]).output,
5353 );
54545555- // the SGR active when "h" is emitted should include the
5656- // parent's red background (48;2;255;0;0), not terminal default
5757- let before = ansi.slice(0, ansi.indexOf("h"));
5555+ // the SGR active when the "hi" glyphs are emitted should include the
5656+ // parent's red background (48;2;255;0;0), not terminal default.
5757+ // (anchored to the text, not a bare "h", which also appears in the
5858+ // synchronized-output BSU prefix \x1b[?2026h)
5959+ let before = ansi.slice(0, ansi.indexOf("hi"));
5860 expect(before).toContain("\x1b[48;2;255;0;0");
5961 });
6062···395397 });
396398 });
397399});
400400+401401+describe("synchronized output", () => {
402402+ // DEC mode 2026: every full-screen frame is wrapped in BSU/ESU so the
403403+ // terminal presents it atomically and never tears mid-update.
404404+ let BSU = "\x1b[?2026h";
405405+ let ESU = "\x1b[?2026l";
406406+407407+ it("wraps the full-screen (cups) frame in BSU/ESU", async () => {
408408+ let term = await createTerm({ width: 20, height: 5 });
409409+ let out = decode(
410410+ term.render([
411411+ open("root", {
412412+ layout: { width: grow(), height: grow() },
413413+ bg: rgba(10, 20, 30),
414414+ }),
415415+ close(),
416416+ ]).output,
417417+ );
418418+ expect(out.startsWith(BSU)).toBe(true);
419419+ expect(out.endsWith(ESU)).toBe(true);
420420+ // exactly one wrap per frame
421421+ expect(out.split(BSU).length).toBe(2);
422422+ expect(out.split(ESU).length).toBe(2);
423423+ });
424424+425425+ it("does not wrap inline (line mode) output", async () => {
426426+ let term = await createTerm({ width: 20, height: 5 });
427427+ let out = decode(
428428+ term.render([
429429+ open("root", {
430430+ layout: { width: grow(), height: grow() },
431431+ bg: rgba(10, 20, 30),
432432+ }),
433433+ close(),
434434+ ], { mode: "line" }).output,
435435+ );
436436+ expect(out.includes("\x1b[?2026")).toBe(false);
437437+ });
438438+439439+ it("omits the wrap when sync is disabled", async () => {
440440+ let term = await createTerm({ width: 20, height: 5, sync: false });
441441+ let out = decode(
442442+ term.render([
443443+ open("root", {
444444+ layout: { width: grow(), height: grow() },
445445+ bg: rgba(10, 20, 30),
446446+ }),
447447+ close(),
448448+ ]).output,
449449+ );
450450+ expect(out.includes("\x1b[?2026")).toBe(false);
451451+ // the actual frame content (bg SGR) is still emitted
452452+ expect(out.includes("\x1b[48;2;10;20;30")).toBe(true);
453453+ });
454454+});