From cb4cc77e5b024853b99cf22aeb591e2b0576daab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 22 Feb 2026 15:25:34 +0000 Subject: [PATCH] react: dedupe resize messages + expose accessibilitySupport Co-authored-by: Alexander Nazarov --- README.md | 1 + src/react/JabTerm.tsx | 37 +++++++++++------- src/react/types.ts | 8 ++++ tests/unit/react/JabTerm.test.tsx | 63 ++++++++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5aa53ac..eb11310 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ CI also regenerates these assets on pushes to `main` (so contributors typically | `className` | `string` | — | CSS class for the outer container | | `fontSize` | `number` | `13` | Font size in pixels | | `fontFamily` | `string` | system monospace | Font family | +| `accessibilitySupport` | `"on" \| "off" \| "auto"` | — | xterm accessibility support mode (set to `"on"` if you need to read terminal text from the DOM for automation) | | `theme` | `{ background?, foreground?, cursor? }` | `{ background: "#1e1e1e" }` | xterm.js theme overrides | The outer container also exposes `data-jabterm-state="connecting|open|closed"` to make UI tests (e.g. Playwright) wait reliably. diff --git a/src/react/JabTerm.tsx b/src/react/JabTerm.tsx index 3e494a7..69d1a4e 100644 --- a/src/react/JabTerm.tsx +++ b/src/react/JabTerm.tsx @@ -23,6 +23,7 @@ const JabTerm = forwardRef(function JabTerm( className, fontSize = 13, fontFamily = DEFAULT_FONT_FAMILY, + accessibilitySupport, theme, }, ref, @@ -33,6 +34,7 @@ const JabTerm = forwardRef(function JabTerm( const wsRef = useRef(null); const closingByCleanupRef = useRef(false); const disposedRef = useRef(false); + const lastSentSizeRef = useRef<{ cols: number; rows: number }>({ cols: 0, rows: 0 }); const [state, setState] = useState("connecting"); const captureEnabledRef = useRef(captureOutput); @@ -45,6 +47,19 @@ const JabTerm = forwardRef(function JabTerm( captureEnabledRef.current = captureOutput; captureMaxCharsRef.current = maxCaptureChars; + const sendResizeIfChanged = (cols: number, rows: number) => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const last = lastSentSizeRef.current; + if (last.cols === cols && last.rows === rows) return; + lastSentSizeRef.current = { cols, rows }; + try { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } catch { + /* ignore */ + } + }; + const appendCapture = (chunk: string) => { if (!captureEnabledRef.current) return; if (!chunk) return; @@ -84,7 +99,6 @@ const JabTerm = forwardRef(function JabTerm( }, resize(cols: number, rows: number) { const term = xtermRef.current; - const ws = wsRef.current; if (!term) return; const safeCols = Math.max(cols || 80, 10); const safeRows = Math.max(rows || 24, 10); @@ -93,13 +107,7 @@ const JabTerm = forwardRef(function JabTerm( } catch { /* ignore */ } - if (ws?.readyState === WebSocket.OPEN) { - try { - ws.send(JSON.stringify({ type: "resize", cols: safeCols, rows: safeRows })); - } catch { - /* ignore */ - } - } + sendResizeIfChanged(safeCols, safeRows); }, paste(text: string) { const term = xtermRef.current; @@ -165,6 +173,7 @@ const JabTerm = forwardRef(function JabTerm( if (!terminalRef.current) return; closingByCleanupRef.current = false; disposedRef.current = false; + lastSentSizeRef.current = { cols: 0, rows: 0 }; setState("connecting"); const TerminalCtor = @@ -185,6 +194,7 @@ const JabTerm = forwardRef(function JabTerm( cursorBlink: true, fontFamily, fontSize, + ...(accessibilitySupport !== undefined ? { accessibilitySupport } : {}), theme: { background: theme?.background ?? "#1e1e1e", foreground: theme?.foreground, @@ -216,7 +226,7 @@ const JabTerm = forwardRef(function JabTerm( fitAddon.fit(); const cols = Math.max(term.cols || 80, 80); const rows = Math.max(term.rows || 24, 24); - ws.send(JSON.stringify({ type: "resize", cols, rows })); + sendResizeIfChanged(cols, rows); }; ws.onmessage = (event) => { @@ -283,11 +293,9 @@ const JabTerm = forwardRef(function JabTerm( } catch { /* ignore */ } - if (ws.readyState === WebSocket.OPEN) { - const cols = Math.max(term.cols || 80, 80); - const rows = Math.max(term.rows || 24, 24); - ws.send(JSON.stringify({ type: "resize", cols, rows })); - } + const cols = Math.max(term.cols || 80, 80); + const rows = Math.max(term.rows || 24, 24); + sendResizeIfChanged(cols, rows); }; window.addEventListener("resize", handleResize); @@ -331,6 +339,7 @@ const JabTerm = forwardRef(function JabTerm( wsUrl, fontSize, fontFamily, + accessibilitySupport, theme?.background, theme?.foreground, theme?.cursor, diff --git a/src/react/types.ts b/src/react/types.ts index e6264f5..ac761a7 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -45,6 +45,14 @@ export interface JabTermProps { fontSize?: number; /** Font family. Default: system monospace stack */ fontFamily?: string; + /** + * xterm.js accessibility support mode. + * + * When set to `"on"`, xterm populates the accessibility tree (and in many setups + * also makes `.xterm-rows` contain textual content), which is useful for UI + * testing/automation that reads terminal output from the DOM. + */ + accessibilitySupport?: "on" | "off" | "auto"; /** xterm.js theme overrides. */ theme?: { background?: string; diff --git a/tests/unit/react/JabTerm.test.tsx b/tests/unit/react/JabTerm.test.tsx index f4f3a0d..0dfd683 100644 --- a/tests/unit/react/JabTerm.test.tsx +++ b/tests/unit/react/JabTerm.test.tsx @@ -6,6 +6,7 @@ import type { JabTermHandle } from "../../../src/react/types.js"; import JabTerm from "../../../src/react/JabTerm.js"; let lastTerminal: any = null; +let lastTerminalOptions: any = null; vi.mock("@xterm/addon-fit", () => { return { @@ -32,8 +33,9 @@ vi.mock("@xterm/xterm", () => { private onDataHandlers: Array<(data: string) => void> = []; private onTitleHandlers: Array<(title: string) => void> = []; - constructor() { + constructor(options?: any) { lastTerminal = this; + lastTerminalOptions = options ?? null; } onData(cb: (data: string) => void) { @@ -66,6 +68,9 @@ function getMockWs() { } describe("", () => { + const countResizeMessages = (ws: any) => + ws.sent.filter((x: unknown) => typeof x === "string" && x.includes("\"resize\"")).length; + it("handshakes on open and captures incoming output", async () => { const ref = createRef(); render(); @@ -139,5 +144,61 @@ describe("", () => { expect(lastTerminal).toBeTruthy(); expect(lastTerminal.dispose).toHaveBeenCalled(); }); + + it("passes accessibilitySupport to xterm Terminal options", async () => { + lastTerminalOptions = null; + render(); + await waitFor(() => { + expect(lastTerminalOptions).toBeTruthy(); + }); + expect(lastTerminalOptions.accessibilitySupport).toBe("on"); + }); + + it("deduplicates resize messages when dimensions do not change", () => { + vi.useFakeTimers(); + try { + render(); + const ws = getMockWs(); + expect(ws).toBeTruthy(); + + act(() => ws.__open()); + const initialResizeCount = countResizeMessages(ws); + expect(initialResizeCount).toBeGreaterThanOrEqual(1); + + act(() => { + vi.advanceTimersByTime(150); + window.dispatchEvent(new Event("resize")); + window.dispatchEvent(new Event("resize")); + }); + + expect(countResizeMessages(ws)).toBe(initialResizeCount); + } finally { + vi.useRealTimers(); + } + }); + + it("sends resize when terminal dimensions change", () => { + vi.useFakeTimers(); + try { + render(); + const ws = getMockWs(); + expect(ws).toBeTruthy(); + + act(() => ws.__open()); + act(() => vi.advanceTimersByTime(150)); + const before = countResizeMessages(ws); + + expect(lastTerminal).toBeTruthy(); + lastTerminal.cols = 120; + lastTerminal.rows = 40; + + act(() => window.dispatchEvent(new Event("resize"))); + expect(countResizeMessages(ws)).toBe(before + 1); + expect(String(ws.sent[ws.sent.length - 1])).toContain("\"cols\":120"); + expect(String(ws.sent[ws.sent.length - 1])).toContain("\"rows\":40"); + } finally { + vi.useRealTimers(); + } + }); });