From f1b2408de91922a5afae252e67ca691fa5921cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Sat, 6 Jun 2026 05:34:20 +0000 Subject: [PATCH 1/2] Fix terminal right edge readability --- apps/web/src/stage-terminal.css | 4 +++- apps/web/src/terminal-pane-basic.test.ts | 1 + apps/web/src/terminal-pane-disconnect.test.ts | 1 + apps/web/src/terminal-pane-resize.test.ts | 18 ++++++++++-------- apps/web/src/terminal-pane-wheel.test.ts | 3 +++ apps/web/src/terminal-pane.test.ts | 1 + apps/web/src/terminal-pane.tsx | 11 +++++++++-- e2e/operator-cockpit.spec.ts | 4 +++- 8 files changed, 31 insertions(+), 12 deletions(-) diff --git a/apps/web/src/stage-terminal.css b/apps/web/src/stage-terminal.css index eaecc600..27b6de37 100644 --- a/apps/web/src/stage-terminal.css +++ b/apps/web/src/stage-terminal.css @@ -478,6 +478,8 @@ .terminal-surface { --terminal-xterm-bg: #f5f1e8; + --terminal-xterm-gutter-left: 6px; + --terminal-xterm-gutter-right: 18px; position: relative; box-sizing: border-box; @@ -503,7 +505,7 @@ box-sizing: border-box; width: 100%; height: 100% !important; - padding: 4px 6px 0; + padding: 4px var(--terminal-xterm-gutter-right) 0 var(--terminal-xterm-gutter-left); } .terminal-surface .xterm-viewport { diff --git a/apps/web/src/terminal-pane-basic.test.ts b/apps/web/src/terminal-pane-basic.test.ts index ec7d81c9..59a52a45 100644 --- a/apps/web/src/terminal-pane-basic.test.ts +++ b/apps/web/src/terminal-pane-basic.test.ts @@ -25,6 +25,7 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); + resize = vi.fn(); constructor(options: Record) { this.options = options; diff --git a/apps/web/src/terminal-pane-disconnect.test.ts b/apps/web/src/terminal-pane-disconnect.test.ts index fd681bb5..a8fd86f7 100644 --- a/apps/web/src/terminal-pane-disconnect.test.ts +++ b/apps/web/src/terminal-pane-disconnect.test.ts @@ -20,6 +20,7 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); + resize = vi.fn(); constructor(options: Record) { this.options = options; diff --git a/apps/web/src/terminal-pane-resize.test.ts b/apps/web/src/terminal-pane-resize.test.ts index cec217b0..3abc39ed 100644 --- a/apps/web/src/terminal-pane-resize.test.ts +++ b/apps/web/src/terminal-pane-resize.test.ts @@ -20,6 +20,7 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); + resize = vi.fn(); private dataHandler: ((data: string) => void) | null = null; private keyHandler: ((event: KeyboardEvent) => boolean) | null = null; private selectionHandler: (() => void) | null = null; @@ -208,14 +209,15 @@ describe("TerminalPane resize handling", () => { flushAnimationFrames(); expect(fit.fit).toHaveBeenCalledTimes(1); - expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 80, rows: 24 }]); + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); + expect(term.resize).toHaveBeenCalledWith(78, 24); observer.trigger(); window.dispatchEvent(new Event("resize")); flushAnimationFrames(); expect(fit.fit).toHaveBeenCalledTimes(2); - expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 80, rows: 24 }]); + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); term.cols = 100; term.rows = 32; @@ -225,8 +227,8 @@ describe("TerminalPane resize handling", () => { expect(fit.fit).toHaveBeenCalledTimes(3); expect(resizeMessages(ws)).toEqual([ - { type: "resize", cols: 80, rows: 24 }, - { type: "resize", cols: 100, rows: 32 }, + { type: "resize", cols: 78, rows: 24 }, + { type: "resize", cols: 98, rows: 32 }, ]); }); @@ -262,7 +264,7 @@ describe("TerminalPane resize handling", () => { observer.trigger(); flushAnimationFrames(); - expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 90, rows: 30 }]); + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 88, rows: 30 }]); }); it("sends the first valid resize when the WebSocket opens after an earlier fit", async () => { @@ -278,7 +280,7 @@ describe("TerminalPane resize handling", () => { await flushReactUpdate(() => ws.open()); flushAnimationFrames(); - expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 80, rows: 24 }]); + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); }); it("sends the first valid resize again after reconnect", async () => { @@ -287,7 +289,7 @@ describe("TerminalPane resize handling", () => { if (!first) throw new Error("missing first ws"); await flushReactUpdate(() => first.open()); flushAnimationFrames(); - expect(resizeMessages(first)).toEqual([{ type: "resize", cols: 80, rows: 24 }]); + expect(resizeMessages(first)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); await flushReactUpdate(() => { getTerminalHandle("sess_1")?.reload(); @@ -298,7 +300,7 @@ describe("TerminalPane resize handling", () => { await flushReactUpdate(() => second.open()); flushAnimationFrames(); - expect(resizeMessages(second)).toEqual([{ type: "resize", cols: 80, rows: 24 }]); + expect(resizeMessages(second)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); }); it("cancels pending resize work on unmount", async () => { diff --git a/apps/web/src/terminal-pane-wheel.test.ts b/apps/web/src/terminal-pane-wheel.test.ts index 81c087e5..7c374a37 100644 --- a/apps/web/src/terminal-pane-wheel.test.ts +++ b/apps/web/src/terminal-pane-wheel.test.ts @@ -21,12 +21,15 @@ const xtermMocks = vi.hoisted(() => { class FakeTerminal { static instances: FakeTerminal[] = []; options: Record; + cols = 80; + rows = 24; focus = vi.fn(); dispose = vi.fn(); refresh = vi.fn(); selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); + resize = vi.fn(); constructor(options: Record) { this.options = options; diff --git a/apps/web/src/terminal-pane.test.ts b/apps/web/src/terminal-pane.test.ts index 8975365d..797c5fb6 100644 --- a/apps/web/src/terminal-pane.test.ts +++ b/apps/web/src/terminal-pane.test.ts @@ -40,6 +40,7 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); + resize = vi.fn(); private dataHandler: ((data: string) => void) | null = null; private keyHandler: ((event: KeyboardEvent) => boolean) | null = null; private selectionHandler: (() => void) | null = null; diff --git a/apps/web/src/terminal-pane.tsx b/apps/web/src/terminal-pane.tsx index bb1a637a..5b1f5a4d 100644 --- a/apps/web/src/terminal-pane.tsx +++ b/apps/web/src/terminal-pane.tsx @@ -46,6 +46,7 @@ const TERMINAL_MAX_SCROLL_LINES_PER_MESSAGE = 200; const TERMINAL_AUTO_RETRY_LIMIT = 3; const TERMINAL_AUTO_RETRY_BACKOFF_MS = 5_000; const TERMINAL_PTY_INPUT_FLUSH_MS = 4; +const TERMINAL_RIGHT_EDGE_RESERVED_COLUMNS = 2; const AUTO_RETRYABLE_TERMINAL_ERRORS = new Set(["terminal_disconnected", "terminal_socket_error"]); const RUNTIME_MOUSE_EVENT_RUNTIMES = new Set(["claude-code"]); export type TerminalPaneKey = typeof LINE_START_KEY | typeof LINE_END_KEY | typeof LINE_KILL_KEY; @@ -239,9 +240,11 @@ export function TerminalPane(props: { session: WorkspaceSession; active?: boolea } catch { return; } - const cols = terminal.cols; + const fittedCols = terminal.cols; const rows = terminal.rows; - if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) return; + if (!Number.isFinite(fittedCols) || !Number.isFinite(rows) || fittedCols <= 0 || rows <= 0) return; + const cols = terminalReadableCols(fittedCols); + if (cols !== fittedCols) terminal.resize(cols, rows); if (lastSentResize?.cols === cols && lastSentResize.rows === rows) return; if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols, rows })); @@ -526,6 +529,10 @@ function guidanceFor(code: string) { } } +function terminalReadableCols(cols: number): number { + return Math.max(20, Math.trunc(cols) - TERMINAL_RIGHT_EDGE_RESERVED_COLUMNS); +} + function handleTerminalKeyEvent( event: KeyboardEvent, terminal: Terminal, diff --git a/e2e/operator-cockpit.spec.ts b/e2e/operator-cockpit.spec.ts index dc15d883..e3b0beaa 100644 --- a/e2e/operator-cockpit.spec.ts +++ b/e2e/operator-cockpit.spec.ts @@ -395,13 +395,15 @@ test("desktop terminal surface is opaque and stable in the cockpit", async ({ pa hostRight: hostRect.right, xtermRight: xtermRect.right, screenRight: screenRect.right, + screenRightGap: hostRect.right - screenRect.right, viewportRight: viewportRect.right, surfacePaddingRight: Number.parseFloat(surfaceStyle.paddingRight), xtermPaddingRight: Number.parseFloat(xtermStyle.paddingRight), }; }); expect(terminalGeometry.surfacePaddingRight).toBe(0); - expect(terminalGeometry.xtermPaddingRight).toBeGreaterThan(0); + expect(terminalGeometry.xtermPaddingRight).toBeGreaterThanOrEqual(16); + expect(terminalGeometry.screenRightGap).toBeGreaterThanOrEqual(12); expect(terminalGeometry.xtermRight).toBeLessThanOrEqual(terminalGeometry.hostRight + 0.5); expect(terminalGeometry.screenRight).toBeLessThanOrEqual(terminalGeometry.hostRight + 0.5); expect(terminalGeometry.viewportRight).toBeLessThanOrEqual(terminalGeometry.hostRight + 0.5); From 86f4cdaaefaf788d81a592b5fbb3cf32ea6ebb8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ovidiu=20M=C4=83ru=C8=99?= Date: Sat, 6 Jun 2026 05:57:54 +0000 Subject: [PATCH 2/2] Stabilize terminal resize gutter --- apps/web/src/stage-terminal.css | 2 +- apps/web/src/terminal-pane-basic.test.ts | 7 +- apps/web/src/terminal-pane-disconnect.test.ts | 7 +- apps/web/src/terminal-pane-resize.test.ts | 88 +++++++++++++++---- apps/web/src/terminal-pane-wheel.test.ts | 7 +- apps/web/src/terminal-pane.test.ts | 7 +- apps/web/src/terminal-pane.tsx | 10 ++- e2e/operator-cockpit.spec.ts | 63 ++++++++++++- 8 files changed, 162 insertions(+), 29 deletions(-) diff --git a/apps/web/src/stage-terminal.css b/apps/web/src/stage-terminal.css index 27b6de37..a2a55718 100644 --- a/apps/web/src/stage-terminal.css +++ b/apps/web/src/stage-terminal.css @@ -501,7 +501,7 @@ } .terminal-surface .xterm { - /* FitAddon subtracts .xterm padding when computing PTY columns. */ + /* FitAddon subtracts this padding; TerminalPane reserves extra readable columns. */ box-sizing: border-box; width: 100%; height: 100% !important; diff --git a/apps/web/src/terminal-pane-basic.test.ts b/apps/web/src/terminal-pane-basic.test.ts index 59a52a45..e40b383b 100644 --- a/apps/web/src/terminal-pane-basic.test.ts +++ b/apps/web/src/terminal-pane-basic.test.ts @@ -25,7 +25,10 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); - resize = vi.fn(); + resize = vi.fn((cols: number, rows: number) => { + this.cols = cols; + this.rows = rows; + }); constructor(options: Record) { this.options = options; @@ -48,7 +51,7 @@ const xtermMocks = vi.hoisted(() => { class FakeFitAddon { static instances: FakeFitAddon[] = []; - fit = vi.fn(); + proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 })); constructor() { FakeFitAddon.instances.push(this); diff --git a/apps/web/src/terminal-pane-disconnect.test.ts b/apps/web/src/terminal-pane-disconnect.test.ts index a8fd86f7..4174c42d 100644 --- a/apps/web/src/terminal-pane-disconnect.test.ts +++ b/apps/web/src/terminal-pane-disconnect.test.ts @@ -20,7 +20,10 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); - resize = vi.fn(); + resize = vi.fn((cols: number, rows: number) => { + this.cols = cols; + this.rows = rows; + }); constructor(options: Record) { this.options = options; @@ -45,7 +48,7 @@ const xtermMocks = vi.hoisted(() => { class FakeFitAddon { static instances: FakeFitAddon[] = []; - fit = vi.fn(); + proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 })); constructor() { FakeFitAddon.instances.push(this); diff --git a/apps/web/src/terminal-pane-resize.test.ts b/apps/web/src/terminal-pane-resize.test.ts index 3abc39ed..aea61721 100644 --- a/apps/web/src/terminal-pane-resize.test.ts +++ b/apps/web/src/terminal-pane-resize.test.ts @@ -20,7 +20,10 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); - resize = vi.fn(); + resize = vi.fn((cols: number, rows: number) => { + this.cols = cols; + this.rows = rows; + }); private dataHandler: ((data: string) => void) | null = null; private keyHandler: ((event: KeyboardEvent) => boolean) | null = null; private selectionHandler: (() => void) | null = null; @@ -61,7 +64,8 @@ const xtermMocks = vi.hoisted(() => { class FakeFitAddon { static instances: FakeFitAddon[] = []; - fit = vi.fn(); + proposedDimensions: { cols: number; rows: number } | undefined = { cols: 80, rows: 24 }; + proposeDimensions = vi.fn(() => this.proposedDimensions); constructor() { FakeFitAddon.instances.push(this); @@ -204,11 +208,11 @@ describe("TerminalPane resize handling", () => { observer.trigger(); window.dispatchEvent(new Event("resize")); observer.trigger(); - expect(fit.fit).not.toHaveBeenCalled(); + expect(fit.proposeDimensions).not.toHaveBeenCalled(); flushAnimationFrames(); - expect(fit.fit).toHaveBeenCalledTimes(1); + expect(fit.proposeDimensions).toHaveBeenCalledTimes(1); expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); expect(term.resize).toHaveBeenCalledWith(78, 24); @@ -216,16 +220,18 @@ describe("TerminalPane resize handling", () => { window.dispatchEvent(new Event("resize")); flushAnimationFrames(); - expect(fit.fit).toHaveBeenCalledTimes(2); + expect(fit.proposeDimensions).toHaveBeenCalledTimes(2); + expect(term.resize).toHaveBeenCalledTimes(1); expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); - term.cols = 100; - term.rows = 32; + fit.proposedDimensions = { cols: 100, rows: 32 }; observer.trigger(); window.dispatchEvent(new Event("resize")); flushAnimationFrames(); - expect(fit.fit).toHaveBeenCalledTimes(3); + expect(fit.proposeDimensions).toHaveBeenCalledTimes(3); + expect(term.resize).toHaveBeenCalledWith(98, 32); + expect(term.resize).toHaveBeenCalledTimes(2); expect(resizeMessages(ws)).toEqual([ { type: "resize", cols: 78, rows: 24 }, { type: "resize", cols: 98, rows: 32 }, @@ -235,9 +241,9 @@ describe("TerminalPane resize handling", () => { it("does not send invalid terminal dimensions", async () => { await renderTerminal(); const ws = TerminalPaneWebSocketMock.instances[0]; - const term = xtermMocks.FakeTerminal.instances[0]; + const fit = xtermMocks.FakeFitAddon.instances[0]; const observer = FakeResizeObserver.instances[0]; - if (!ws || !term || !observer) throw new Error("terminal rig missing"); + if (!ws || !fit || !observer) throw new Error("terminal rig missing"); await flushReactUpdate(() => ws.open()); @@ -251,22 +257,74 @@ describe("TerminalPane resize handling", () => { ]; for (const [cols, rows] of invalidDimensions) { - term.cols = cols; - term.rows = rows; + fit.proposedDimensions = { cols, rows }; observer.trigger(); flushAnimationFrames(); } expect(resizeMessages(ws)).toEqual([]); - term.cols = 90; - term.rows = 30; + fit.proposedDimensions = { cols: 90, rows: 30 }; observer.trigger(); flushAnimationFrames(); expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 88, rows: 30 }]); }); + it("does not repeatedly shrink when fit cannot propose dimensions", async () => { + await renderTerminal(); + const ws = TerminalPaneWebSocketMock.instances[0]; + const term = xtermMocks.FakeTerminal.instances[0]; + const fit = xtermMocks.FakeFitAddon.instances[0]; + const observer = FakeResizeObserver.instances[0]; + if (!ws || !term || !fit || !observer) throw new Error("terminal rig missing"); + + await flushReactUpdate(() => ws.open()); + flushAnimationFrames(); + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); + expect(term.cols).toBe(78); + + fit.proposedDimensions = undefined; + observer.trigger(); + flushAnimationFrames(); + observer.trigger(); + flushAnimationFrames(); + + expect(term.cols).toBe(78); + expect(term.resize).toHaveBeenCalledTimes(1); + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 78, rows: 24 }]); + }); + + it("clamps readable terminal columns at the backend minimum", async () => { + await renderTerminal(); + const ws = TerminalPaneWebSocketMock.instances[0]; + const fit = xtermMocks.FakeFitAddon.instances[0]; + const observer = FakeResizeObserver.instances[0]; + if (!ws || !fit || !observer) throw new Error("terminal rig missing"); + + await flushReactUpdate(() => ws.open()); + ws.sent = []; + + const narrowSizes: Array<[number, number]> = [ + [19, 12], + [20, 13], + [21, 14], + ]; + for (const [cols, rows] of narrowSizes) { + fit.proposedDimensions = { cols, rows }; + observer.trigger(); + flushAnimationFrames(); + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 20, rows }]); + ws.sent = []; + } + + fit.proposedDimensions = { cols: 23.9, rows: 15 }; + observer.trigger(); + flushAnimationFrames(); + + expect(resizeMessages(ws)).toEqual([{ type: "resize", cols: 21, rows: 15 }]); + }); + it("sends the first valid resize when the WebSocket opens after an earlier fit", async () => { await renderTerminal(); const ws = TerminalPaneWebSocketMock.instances[0]; @@ -316,7 +374,7 @@ describe("TerminalPane resize handling", () => { untrackRoot(root); flushAnimationFrames(); - expect(fit.fit).not.toHaveBeenCalled(); + expect(fit.proposeDimensions).not.toHaveBeenCalled(); expect(resizeMessages(ws)).toEqual([]); }); diff --git a/apps/web/src/terminal-pane-wheel.test.ts b/apps/web/src/terminal-pane-wheel.test.ts index 7c374a37..221c98ee 100644 --- a/apps/web/src/terminal-pane-wheel.test.ts +++ b/apps/web/src/terminal-pane-wheel.test.ts @@ -29,7 +29,10 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); - resize = vi.fn(); + resize = vi.fn((cols: number, rows: number) => { + this.cols = cols; + this.rows = rows; + }); constructor(options: Record) { this.options = options; @@ -52,7 +55,7 @@ const xtermMocks = vi.hoisted(() => { class FakeFitAddon { static instances: FakeFitAddon[] = []; - fit = vi.fn(); + proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 })); constructor() { FakeFitAddon.instances.push(this); diff --git a/apps/web/src/terminal-pane.test.ts b/apps/web/src/terminal-pane.test.ts index 797c5fb6..085dec35 100644 --- a/apps/web/src/terminal-pane.test.ts +++ b/apps/web/src/terminal-pane.test.ts @@ -40,7 +40,10 @@ const xtermMocks = vi.hoisted(() => { selectAll = vi.fn(); hasSelection = vi.fn(() => true); getSelection = vi.fn(() => "selected text"); - resize = vi.fn(); + resize = vi.fn((cols: number, rows: number) => { + this.cols = cols; + this.rows = rows; + }); private dataHandler: ((data: string) => void) | null = null; private keyHandler: ((event: KeyboardEvent) => boolean) | null = null; private selectionHandler: (() => void) | null = null; @@ -81,7 +84,7 @@ const xtermMocks = vi.hoisted(() => { class FakeFitAddon { static instances: FakeFitAddon[] = []; - fit = vi.fn(); + proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 })); constructor() { FakeFitAddon.instances.push(this); diff --git a/apps/web/src/terminal-pane.tsx b/apps/web/src/terminal-pane.tsx index 5b1f5a4d..3a0da48d 100644 --- a/apps/web/src/terminal-pane.tsx +++ b/apps/web/src/terminal-pane.tsx @@ -235,16 +235,18 @@ export function TerminalPane(props: { session: WorkspaceSession; active?: boolea const runResize = () => { if (disposed) return; if (!activeRef.current) return; + let fittedSize: ReturnType; try { - fit.fit(); + fittedSize = fit.proposeDimensions(); } catch { return; } - const fittedCols = terminal.cols; - const rows = terminal.rows; + if (!fittedSize) return; + const fittedCols = fittedSize.cols; + const rows = fittedSize.rows; if (!Number.isFinite(fittedCols) || !Number.isFinite(rows) || fittedCols <= 0 || rows <= 0) return; const cols = terminalReadableCols(fittedCols); - if (cols !== fittedCols) terminal.resize(cols, rows); + if (terminal.cols !== cols || terminal.rows !== rows) terminal.resize(cols, rows); if (lastSentResize?.cols === cols && lastSentResize.rows === rows) return; if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols, rows })); diff --git a/e2e/operator-cockpit.spec.ts b/e2e/operator-cockpit.spec.ts index e3b0beaa..377bd085 100644 --- a/e2e/operator-cockpit.spec.ts +++ b/e2e/operator-cockpit.spec.ts @@ -322,7 +322,7 @@ test("desktop terminal surface is opaque and stable in the cockpit", async ({ pa const workspaceName = `render-${Date.now().toString(36)}`; workspaceId = (await createWorkspace(request, repo.id, workspaceName)).workspaceId; await waitForWorkspace(request, workspaceId, "ready"); - await startSession(request, workspaceId, "Render Shell"); + const session = await startSession(request, workspaceId, "Render Shell"); await page.goto("/"); const navigator = page.locator("aside[aria-label='Navigator']"); @@ -407,6 +407,67 @@ test("desktop terminal surface is opaque and stable in the cockpit", async ({ pa expect(terminalGeometry.xtermRight).toBeLessThanOrEqual(terminalGeometry.hostRight + 0.5); expect(terminalGeometry.screenRight).toBeLessThanOrEqual(terminalGeometry.hostRight + 0.5); expect(terminalGeometry.viewportRight).toBeLessThanOrEqual(terminalGeometry.hostRight + 0.5); + + const edgeMarker = `EDGE_${Date.now().toString(36)}`; + const edgeSocket = await openTerminalSocket(session.id); + try { + const edgeOutput = waitForTerminalOutput(edgeSocket, edgeMarker); + edgeSocket.send( + Buffer.from( + `cols=$(tput cols); pad=$((cols-${edgeMarker.length})); [ "$pad" -lt 0 ] && pad=0; printf '%*s%s\\n' "$pad" "" "${edgeMarker}"\r`, + "utf8", + ), + ); + await edgeOutput; + } finally { + edgeSocket.close(); + } + + await expect + .poll( + () => + terminalHost.evaluate( + (element, marker) => element.querySelector(".xterm-rows")?.textContent?.includes(marker) ?? false, + edgeMarker, + ), + { timeout: 10_000 }, + ) + .toBe(true); + const markerGeometry = await terminalHost.evaluate((element, marker) => { + const hostRect = element.getBoundingClientRect(); + const rows = Array.from(element.querySelectorAll(".xterm-rows > div")); + const row = rows.find((candidate) => candidate.textContent?.includes(marker)); + if (!(row instanceof HTMLElement)) return null; + const markerRect = markerTextRect(row, marker); + if (!markerRect) return null; + return { + markerRightGap: hostRect.right - markerRect.right, + rowText: row.textContent ?? "", + }; + + function markerTextRect(root: HTMLElement, needle: string): DOMRect | null { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node = walker.nextNode(); + while (node) { + const text = node.textContent ?? ""; + const index = text.indexOf(needle); + if (index !== -1) { + const range = document.createRange(); + range.setStart(node, index); + range.setEnd(node, index + needle.length); + const rect = Array.from(range.getClientRects()).at(-1) ?? null; + range.detach(); + return rect; + } + node = walker.nextNode(); + } + return null; + } + }, edgeMarker); + expect(markerGeometry, `Expected terminal output row to contain ${edgeMarker}`).not.toBeNull(); + if (!markerGeometry) throw new Error(`Terminal marker ${edgeMarker} was not measurable`); + expect(markerGeometry.rowText).toContain(edgeMarker); + expect(markerGeometry.markerRightGap).toBeGreaterThanOrEqual(12); } finally { if (workspaceId) await request.delete(`${API_BASE}/api/workspaces/${workspaceId}?archiveOnly=true`); fs.rmSync(fixture.dir, { recursive: true, force: true });