From 86a0a2529b0d54554862612f4933a4cf00ad777c Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 10:50:01 -0600 Subject: [PATCH 1/7] feat(web): drag to resize split terminal panes Split terminal panes were laid out as a fixed equal grid with only a border between them, so there was no way to give one terminal more space. Each pane boundary now has a drag handle. During a drag the grid template is written directly to the DOM once per animation frame, so panes track the cursor without re-rendering the terminals; each Ghostty surface refits in the same frame via its ResizeObserver and the PTY resize stays debounced. Sizes commit to drawer state on release, per group and split direction, and a double-click resets panes to equal. Panes keep a minimum of 160px wide or 64px tall. Works in both the drawer and the right panel terminal. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/TerminalSplitPanes.tsx | 278 ++++++++++++++++ .../src/components/ThreadTerminalDrawer.tsx | 102 +++--- apps/web/src/terminal/splitPaneSizes.test.ts | 313 ++++++++++++++++++ apps/web/src/terminal/splitPaneSizes.ts | 109 ++++++ 4 files changed, 745 insertions(+), 57 deletions(-) create mode 100644 apps/web/src/components/TerminalSplitPanes.tsx create mode 100644 apps/web/src/terminal/splitPaneSizes.test.ts create mode 100644 apps/web/src/terminal/splitPaneSizes.ts diff --git a/apps/web/src/components/TerminalSplitPanes.tsx b/apps/web/src/components/TerminalSplitPanes.tsx new file mode 100644 index 000000000000..273c98f053d0 --- /dev/null +++ b/apps/web/src/components/TerminalSplitPanes.tsx @@ -0,0 +1,278 @@ +import { + type PointerEvent as ReactPointerEvent, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useRef, +} from "react"; +import { + equalPaneSizes, + MIN_TERMINAL_PANE_PX, + paneBoundaryOffsets, + paneGridTemplate, + resizeAdjacentPanes, + resolvePaneSizes, + type TerminalSplitDirection, +} from "~/terminal/splitPaneSizes"; + +interface TerminalSplitPanesProps { + terminalIds: readonly string[]; + direction: TerminalSplitDirection; + activeTerminalId: string; + sizes: readonly number[] | undefined; + onSizesChange: (sizes: number[]) => void; + onPaneActivate: (terminalId: string) => void; + onResizeEnd: () => void; + renderTerminal: (terminalId: string) => ReactNode; +} + +interface PointerState { + pointerId: number; + handleIndex: number; + target: HTMLDivElement; + direction: TerminalSplitDirection; + startClientX: number; + startClientY: number; + pendingClientX: number; + pendingClientY: number; + startSizes: number[]; + containerPx: number; +} + +function sizesDiffer(left: readonly number[], right: readonly number[]) { + return left.some((size, index) => size !== right[index]); +} + +/** Renders terminal panes with frame-synchronous, directly manipulated split handles. */ +export function TerminalSplitPanes({ + terminalIds, + direction, + activeTerminalId, + sizes, + onSizesChange, + onPaneActivate, + onResizeEnd, + renderTerminal, +}: TerminalSplitPanesProps) { + const containerRef = useRef(null); + const latestSizesRef = useRef([]); + const pointerStateRef = useRef(null); + const pendingRafRef = useRef(null); + const handleStateRef = useRef>([]); + const callbacksRef = useRef({ onSizesChange, onResizeEnd }); + useLayoutEffect(() => { + callbacksRef.current = { onSizesChange, onResizeEnd }; + }, [onSizesChange, onResizeEnd]); + + const resolved = resolvePaneSizes(sizes, terminalIds.length); + + const writeSizesToDom = useCallback( + (nextSizes: readonly number[]) => { + const pointerState = pointerStateRef.current; + const activeDirection = pointerState?.direction ?? direction; + const container = containerRef.current; + if (!container) return; + const template = paneGridTemplate(nextSizes); + const horizontal = activeDirection === "horizontal"; + container.style.gridTemplateColumns = horizontal ? template : ""; + container.style.gridTemplateRows = horizontal ? "" : template; + for (const [index, offset] of paneBoundaryOffsets(nextSizes).entries()) { + const handle = handleStateRef.current[index]; + if (!handle) continue; + const position = `calc(${offset * 100}%)`; + handle.style.left = horizontal ? position : ""; + handle.style.top = horizontal ? "" : position; + } + }, + [direction], + ); + + const flushPending = useCallback(() => { + if (pendingRafRef.current !== null) { + cancelAnimationFrame(pendingRafRef.current); + pendingRafRef.current = null; + } + const state = pointerStateRef.current; + if (!state) return; + const position = state.direction === "horizontal" ? state.pendingClientX : state.pendingClientY; + const start = state.direction === "horizontal" ? state.startClientX : state.startClientY; + const nextSizes = resizeAdjacentPanes({ + sizes: state.startSizes, + handleIndex: state.handleIndex, + deltaPx: position - start, + containerPx: state.containerPx, + minPanePx: MIN_TERMINAL_PANE_PX[state.direction], + }); + writeSizesToDom(nextSizes); + latestSizesRef.current = nextSizes; + }, [writeSizesToDom]); + + const finishDrag = useCallback(() => { + const state = pointerStateRef.current; + if (!state) return; + flushPending(); + pointerStateRef.current = null; + try { + if (state.target.hasPointerCapture(state.pointerId)) { + state.target.releasePointerCapture(state.pointerId); + } + } catch { + // Capture may already have been released by the browser. + } + state.target.removeAttribute("data-dragging"); + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + + const distance = Math.hypot( + state.pendingClientX - state.startClientX, + state.pendingClientY - state.startClientY, + ); + const changed = sizesDiffer(state.startSizes, latestSizesRef.current); + if (changed && distance > 2) { + callbacksRef.current.onSizesChange(latestSizesRef.current); + callbacksRef.current.onResizeEnd(); + } else { + latestSizesRef.current = state.startSizes; + writeSizesToDom(state.startSizes); + } + }, [flushPending, writeSizesToDom]); + + useLayoutEffect(() => { + const displayedSizes = pointerStateRef.current ? latestSizesRef.current : resolved; + latestSizesRef.current = displayedSizes; + writeSizesToDom(displayedSizes); + }); + + useEffect(() => { + const onPointerMove = (event: PointerEvent) => { + const state = pointerStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + event.preventDefault(); + state.pendingClientX = event.clientX; + state.pendingClientY = event.clientY; + if (pendingRafRef.current !== null) return; + pendingRafRef.current = requestAnimationFrame(() => { + pendingRafRef.current = null; + flushPending(); + }); + }; + const onPointerEnd = (event: PointerEvent) => { + const state = pointerStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + if (event.type === "pointerup") { + state.pendingClientX = event.clientX; + state.pendingClientY = event.clientY; + } + finishDrag(); + }; + window.addEventListener("pointermove", onPointerMove, { passive: false }); + window.addEventListener("pointerup", onPointerEnd); + window.addEventListener("pointercancel", onPointerEnd); + window.addEventListener("blur", finishDrag); + return () => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerEnd); + window.removeEventListener("pointercancel", onPointerEnd); + window.removeEventListener("blur", finishDrag); + finishDrag(); + if (pendingRafRef.current !== null) cancelAnimationFrame(pendingRafRef.current); + }; + }, [finishDrag, flushPending]); + + const startDrag = (event: ReactPointerEvent, handleIndex: number) => { + if (event.button !== 0 || pointerStateRef.current) return; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + return; + } + event.preventDefault(); + event.stopPropagation(); + const bounds = containerRef.current?.getBoundingClientRect(); + if (!bounds) return; + pointerStateRef.current = { + pointerId: event.pointerId, + handleIndex, + target: event.currentTarget, + direction, + startClientX: event.clientX, + startClientY: event.clientY, + pendingClientX: event.clientX, + pendingClientY: event.clientY, + startSizes: resolved, + containerPx: direction === "horizontal" ? bounds.width : bounds.height, + }; + latestSizesRef.current = resolved; + event.currentTarget.dataset.dragging = "true"; + document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize"; + document.body.style.userSelect = "none"; + }; + + // Mid-drag re-renders are corrected by the layout effect, which re-applies latestSizesRef. + const offsets = paneBoundaryOffsets(resolved); + const gridStyle = + direction === "horizontal" + ? { gridTemplateColumns: paneGridTemplate(resolved) } + : { gridTemplateRows: paneGridTemplate(resolved) }; + + return ( +
+ {terminalIds.map((terminalId) => ( +
{ + if (terminalId !== activeTerminalId) onPaneActivate(terminalId); + }} + > +
{renderTerminal(terminalId)}
+
+ ))} + {offsets.map((offset: number, handleIndex: number) => ( +
{ + handleStateRef.current[handleIndex] = element; + }} + className={`group absolute z-20 select-none touch-none ${ + direction === "horizontal" + ? "bottom-0 top-0 w-2 -translate-x-1/2 cursor-col-resize" + : "left-0 right-0 h-2 -translate-y-1/2 cursor-row-resize" + }`} + style={ + direction === "horizontal" + ? { left: `calc(${offset * 100}%)` } + : { top: `calc(${offset * 100}%)` } + } + role="separator" + aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"} + aria-label="Resize terminal panes" + onPointerDown={(event) => startDrag(event, handleIndex)} + onLostPointerCapture={(event) => { + if (pointerStateRef.current?.pointerId === event.pointerId) finishDrag(); + }} + onDoubleClick={() => { + callbacksRef.current.onSizesChange(equalPaneSizes(terminalIds.length)); + callbacksRef.current.onResizeEnd(); + }} + > + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 91c7cc855596..4e7825d38823 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -42,6 +42,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { TerminalSplitPanes } from "~/components/TerminalSplitPanes"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; @@ -1106,6 +1107,7 @@ export default function ThreadTerminalDrawer({ setDrawerHeight(nextHeight); }); const [resizeEpoch, setResizeEpoch] = useState(0); + const [splitSizesByGroupId, setSplitSizesByGroupId] = useState>({}); const drawerHeightRef = useRef(drawerHeight); const lastSyncedHeightRef = useRef(controlledDrawerHeight); const onHeightChangeRef = useRef(onHeightChange); @@ -1225,6 +1227,10 @@ export default function ThreadTerminalDrawer({ (normalizedTerminalIds.length > 0 ? [resolvedActiveTerminalId] : []); const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; + const resolvedActiveGroupId = resolvedTerminalGroups[resolvedActiveGroupIndex]?.id; + const splitSizeKey = resolvedActiveGroupId + ? `${resolvedActiveGroupId}:${splitDirection}` + : undefined; const hasTerminalSidebar = normalizedTerminalIds.length > 1; const isSplitView = visibleTerminalIds.length > 1; const showGroupHeaders = @@ -1494,66 +1500,48 @@ export default function ThreadTerminalDrawer({ >
{isSplitView ? ( -
- {visibleTerminalIds.map((terminalId) => { + { + if (!splitSizeKey) return; + setSplitSizesByGroupId((previous) => ({ + ...previous, + [splitSizeKey]: sizes, + })); + }} + onPaneActivate={onActiveTerminalChange} + onResizeEnd={() => setResizeEpoch((value) => value + 1)} + renderTerminal={(terminalId) => { const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); return ( -
{ - if (terminalId !== resolvedActiveTerminalId) { - onActiveTerminalChange(terminalId); - } - }} - > -
- onCloseTerminal(terminalId)} - onAddTerminalContext={onAddTerminalContext} - focusRequestId={focusRequestId} - autoFocus={terminalId === resolvedActiveTerminalId} - visible={visible} - resizeEpoch={resizeEpoch} - drawerHeight={drawerHeight} - keybindings={keybindings} - /> -
-
+ onCloseTerminal(terminalId)} + onAddTerminalContext={onAddTerminalContext} + focusRequestId={focusRequestId} + autoFocus={terminalId === resolvedActiveTerminalId} + visible={visible} + resizeEpoch={resizeEpoch} + drawerHeight={drawerHeight} + keybindings={keybindings} + /> ); - })} -
+ }} + /> ) : (
{ + it("returns empty array for count <= 0", () => { + expect(equalPaneSizes(0)).toEqual([]); + expect(equalPaneSizes(-1)).toEqual([]); + }); + + it("returns equal fractions for count > 0", () => { + expect(equalPaneSizes(1)).toEqual([1]); + expect(equalPaneSizes(2)).toEqual([0.5, 0.5]); + expect(equalPaneSizes(3)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(equalPaneSizes(4)).toEqual([0.25, 0.25, 0.25, 0.25]); + }); + + it("sums to 1", () => { + for (const count of [1, 2, 3, 5, 10]) { + const sizes = equalPaneSizes(count); + const sum = sizes.reduce((a, b) => a + b, 0); + expect(sum).toBeCloseTo(1, 5); + } + }); +}); + +describe("resolvePaneSizes", () => { + it("returns equalPaneSizes when sizes is undefined", () => { + expect(resolvePaneSizes(undefined, 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes(undefined, 3)).toEqual([1 / 3, 1 / 3, 1 / 3]); + }); + + it("returns equalPaneSizes when length mismatch", () => { + expect(resolvePaneSizes([0.5, 0.5], 3)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(resolvePaneSizes([0.25, 0.25, 0.25, 0.25], 2)).toEqual([0.5, 0.5]); + }); + + it("returns equalPaneSizes when entry is non-finite", () => { + expect(resolvePaneSizes([0.5, NaN], 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes([0.5, Infinity], 2)).toEqual([0.5, 0.5]); + }); + + it("returns equalPaneSizes when entry is <= 0", () => { + expect(resolvePaneSizes([0.5, 0], 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes([0.5, -0.1], 2)).toEqual([0.5, 0.5]); + }); + + it("normalizes valid sizes to sum to 1", () => { + expect(resolvePaneSizes([1, 1], 2)).toEqual([0.5, 0.5]); + expect(resolvePaneSizes([1, 2, 3], 3)).toEqual([1 / 6, 2 / 6, 3 / 6]); + expect(resolvePaneSizes([2, 3, 5], 3)).toEqual([0.2, 0.3, 0.5]); + }); + + it("returns a new array, not the input", () => { + const input = [0.5, 0.5]; + const result = resolvePaneSizes(input, 2); + expect(result).not.toBe(input); + }); +}); + +describe("resizeAdjacentPanes", () => { + it("returns a copy when containerPx <= 0", () => { + const sizes = [0.5, 0.5]; + const result = resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: 10, + containerPx: 0, + minPanePx: 100, + }); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("returns a copy when handleIndex out of range", () => { + const sizes = [0.5, 0.5]; + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: -1, + deltaPx: 10, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 1, + deltaPx: 10, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 2, + deltaPx: 10, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + }); + + it("returns a copy when deltaPx is not finite", () => { + const sizes = [0.5, 0.5]; + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: NaN, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + + expect( + resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: Infinity, + containerPx: 100, + minPanePx: 50, + }), + ).toEqual(sizes); + }); + + it("grows left pane and shrinks right with positive deltaPx", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: 25, + containerPx: 200, + minPanePx: 20, + }); + expect(result[0]!).toBeCloseTo(0.625, 5); + expect(result[1]!).toBeCloseTo(0.375, 5); + }); + + it("shrinks left pane and grows right with negative deltaPx", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: -25, + containerPx: 200, + minPanePx: 20, + }); + expect(result[0]!).toBeCloseTo(0.375, 5); + expect(result[1]!).toBeCloseTo(0.625, 5); + }); + + it("preserves combined fraction of two panes", () => { + const sizes = [0.3, 0.7]; + const result = resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: 50, + containerPx: 100, + minPanePx: 10, + }); + const combined = (result[0] ?? 0) + (result[1] ?? 0); + expect(combined).toBeCloseTo(1.0, 5); + }); + + it("clamps to minimum fraction at left edge", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: -100, + containerPx: 200, + minPanePx: 80, + }); + expect(result[0]!).toBeCloseTo(0.4, 5); + expect(result[1]!).toBeCloseTo(0.6, 5); + }); + + it("clamps to minimum fraction at right edge", () => { + const result = resizeAdjacentPanes({ + sizes: [0.5, 0.5], + handleIndex: 0, + deltaPx: 100, + containerPx: 200, + minPanePx: 80, + }); + expect(result[0]!).toBeCloseTo(0.6, 5); + expect(result[1]!).toBeCloseTo(0.4, 5); + }); + + it("uses half of combined when pair is too small for both mins", () => { + const result = resizeAdjacentPanes({ + sizes: [0.1, 0.1], + handleIndex: 0, + deltaPx: 50, + containerPx: 100, + minPanePx: 60, + }); + expect(result[0]!).toBeCloseTo(0.1, 5); + expect(result[1]!).toBeCloseTo(0.1, 5); + }); + + it("does not mutate input", () => { + const sizes = [0.5, 0.5]; + const sizesCopy = [...sizes]; + resizeAdjacentPanes({ + sizes, + handleIndex: 0, + deltaPx: 50, + containerPx: 100, + minPanePx: 10, + }); + expect(sizes).toEqual(sizesCopy); + }); + + it("works with multiple panes", () => { + const sizes = [0.25, 0.25, 0.25, 0.25]; + const result = resizeAdjacentPanes({ + sizes, + handleIndex: 1, + deltaPx: 10, + containerPx: 200, + minPanePx: 10, + }); + expect(result[0]!).toBeCloseTo(0.25, 5); + expect(result[1]!).toBeCloseTo(0.3, 5); + expect(result[2]!).toBeCloseTo(0.2, 5); + expect(result[3]!).toBeCloseTo(0.25, 5); + }); +}); + +describe("paneGridTemplate", () => { + it("formats single pane", () => { + expect(paneGridTemplate([1])).toBe("minmax(0, 1fr)"); + }); + + it("formats two equal panes", () => { + expect(paneGridTemplate([0.5, 0.5])).toBe("minmax(0, 0.5fr) minmax(0, 0.5fr)"); + }); + + it("formats three equal panes", () => { + expect(paneGridTemplate([1 / 3, 1 / 3, 1 / 3])).toBe( + "minmax(0, 0.333333fr) minmax(0, 0.333333fr) minmax(0, 0.333333fr)", + ); + }); + + it("formats unequal panes", () => { + const result = paneGridTemplate([0.25, 0.75]); + expect(result).toBe("minmax(0, 0.25fr) minmax(0, 0.75fr)"); + }); + + it("limits fractions to 6 decimal places", () => { + const result = paneGridTemplate([0.3333333, 0.6666667]); + expect(result).toContain("0.333333"); + expect(result).toContain("0.666667"); + }); +}); + +describe("paneBoundaryOffsets", () => { + it("returns empty array for single pane", () => { + expect(paneBoundaryOffsets([1])).toEqual([]); + }); + + it("returns correct offsets for two equal panes", () => { + expect(paneBoundaryOffsets([0.5, 0.5])).toEqual([0.5]); + }); + + it("returns correct offsets for two unequal panes", () => { + expect(paneBoundaryOffsets([0.25, 0.75])).toEqual([0.25]); + }); + + it("returns correct offsets for three equal panes", () => { + expect(paneBoundaryOffsets([1 / 3, 1 / 3, 1 / 3])).toEqual([1 / 3, 2 / 3]); + }); + + it("returns correct offsets for three unequal panes", () => { + const result = paneBoundaryOffsets([0.25, 0.25, 0.5]); + expect(result[0]!).toBeCloseTo(0.25, 5); + expect(result[1]!).toBeCloseTo(0.5, 5); + }); + + it("has length sizes.length - 1", () => { + for (const count of [1, 2, 3, 5, 10]) { + const sizes = equalPaneSizes(count); + const offsets = paneBoundaryOffsets(sizes); + expect(offsets).toHaveLength(count - 1); + } + }); + + it("boundary offsets are in range [0, 1)", () => { + const sizes = [0.1, 0.2, 0.3, 0.4]; + const offsets = paneBoundaryOffsets(sizes); + for (const offset of offsets) { + expect(offset).toBeGreaterThanOrEqual(0); + expect(offset).toBeLessThan(1); + } + }); + + it("boundary offsets are increasing", () => { + const sizes = [0.1, 0.2, 0.3, 0.4]; + const offsets = paneBoundaryOffsets(sizes); + for (let i = 1; i < offsets.length; i++) { + expect(offsets[i]!).toBeGreaterThan(offsets[i - 1]!); + } + }); +}); diff --git a/apps/web/src/terminal/splitPaneSizes.ts b/apps/web/src/terminal/splitPaneSizes.ts new file mode 100644 index 000000000000..dc66b6f06290 --- /dev/null +++ b/apps/web/src/terminal/splitPaneSizes.ts @@ -0,0 +1,109 @@ +export type TerminalSplitDirection = "horizontal" | "vertical"; + +/** Minimum pane extent in CSS px along the split axis. "horizontal" = side-by-side columns (width), "vertical" = stacked rows (height). */ +export const MIN_TERMINAL_PANE_PX: Readonly> = Object.freeze( + { horizontal: 160, vertical: 64 }, +); + +/** Equal fractions for `count` panes (count <= 0 returns []). */ +export function equalPaneSizes(count: number): number[] { + if (count <= 0) return []; + const fraction = 1 / count; + return Array.from({ length: count }, () => fraction); +} + +/** + * Returns usable sizes for `count` panes: when `sizes` is undefined, has a different length, + * or contains a non-finite / <= 0 entry, returns equalPaneSizes(count). Otherwise returns a + * NEW array normalized so it sums to exactly 1 (divide each by the sum). + */ +export function resolvePaneSizes(sizes: readonly number[] | undefined, count: number): number[] { + if (!sizes || sizes.length !== count) { + return equalPaneSizes(count); + } + + for (const size of sizes) { + if (!Number.isFinite(size) || size <= 0) { + return equalPaneSizes(count); + } + } + + const sum = sizes.reduce((a, b) => a + b, 0); + return Array.from(sizes, (size) => size / sum); +} + +/** + * Moves the boundary between pane `handleIndex` and pane `handleIndex + 1` by `deltaPx` + * (positive = towards the end, i.e. pane handleIndex grows). Only those two panes change; + * their combined fraction is preserved. Each of the two is clamped to at least + * minPanePx / containerPx, but if the pair is too small for both minimums the min fraction + * becomes half of the pair's total. Returns a NEW array (never mutates input). If + * containerPx <= 0, handleIndex is out of range (must be 0..sizes.length-2), or deltaPx is + * not finite, return a copy of `sizes` unchanged. + */ +export function resizeAdjacentPanes(input: { + readonly sizes: readonly number[]; + readonly handleIndex: number; + readonly deltaPx: number; + readonly containerPx: number; + readonly minPanePx: number; +}): number[] { + const { sizes, handleIndex, deltaPx, containerPx, minPanePx } = input; + + const isValidHandleIndex = handleIndex >= 0 && handleIndex < sizes.length - 1; + if (containerPx <= 0 || !isValidHandleIndex || !Number.isFinite(deltaPx)) { + return Array.from(sizes); + } + + const result = Array.from(sizes); + const minFraction = minPanePx / containerPx; + const deltaFraction = deltaPx / containerPx; + + const leftIndex = handleIndex; + const rightIndex = handleIndex + 1; + + const leftSize = result[leftIndex] ?? 0; + const rightSize = result[rightIndex] ?? 0; + const combined = leftSize + rightSize; + + let newLeft = leftSize + deltaFraction; + let newRight = combined - newLeft; + + if (combined < 2 * minFraction) { + const halfCombined = combined / 2; + newLeft = halfCombined; + newRight = halfCombined; + } else { + newLeft = Math.max(minFraction, Math.min(newLeft, combined - minFraction)); + newRight = combined - newLeft; + } + + result[leftIndex] = newLeft; + result[rightIndex] = newRight; + + return result; +} + +/** CSS grid template for the sizes: each entry `minmax(0, fr)` joined by a space. Use the fraction with at most 6 decimal places (trim trailing zeros is not required). */ +export function paneGridTemplate(sizes: readonly number[]): string { + return sizes + .map((size) => { + const rounded = Math.round(size * 1000000) / 1000000; + return `minmax(0, ${rounded}fr)`; + }) + .join(" "); +} + +/** Cumulative offsets (fractions 0..1) of each internal boundary: length sizes.length - 1. e.g. [0.25, 0.25, 0.5] -> [0.25, 0.5]. */ +export function paneBoundaryOffsets(sizes: readonly number[]): number[] { + const result: number[] = []; + let cumulative = 0; + + for (let i = 0; i < sizes.length - 1; i++) { + const size = sizes[i] ?? 0; + cumulative += size; + result.push(cumulative); + } + + return result; +} From 0703bf25b82b40b5200e9533c7e99f9a945b6e3a Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 11:34:39 -0600 Subject: [PATCH 2/7] fix(web): keep split terminal panes usable after resizes Review follow-ups for resizable split panes. The minimum pane size was only enforced while dragging, so narrowing the window or drawer could squeeze a pane below 160px wide or 64px tall. Stored sizes are now constrained against the container's current extent before rendering, and the same constrained sizes drive the grid, the handle positions, and drag start. Stored sizes keep the user's intent, so the split restores when the container grows again. The ResizeObserver only re-renders when the constrained layout actually changes. Split handles are now keyboard operable: they are focusable separators with aria-valuenow, arrow keys move the boundary (Shift for larger steps), and Enter resets panes to equal sizes. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/TerminalSplitPanes.tsx | 95 ++++++++++++++++--- apps/web/src/terminal/splitPaneSizes.test.ts | 47 +++++++++ apps/web/src/terminal/splitPaneSizes.ts | 25 +++++ 3 files changed, 156 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/TerminalSplitPanes.tsx b/apps/web/src/components/TerminalSplitPanes.tsx index 273c98f053d0..98ef4f091679 100644 --- a/apps/web/src/components/TerminalSplitPanes.tsx +++ b/apps/web/src/components/TerminalSplitPanes.tsx @@ -1,12 +1,15 @@ import { + type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, + useState, } from "react"; import { + constrainPaneSizes, equalPaneSizes, MIN_TERMINAL_PANE_PX, paneBoundaryOffsets, @@ -56,6 +59,9 @@ export function TerminalSplitPanes({ renderTerminal, }: TerminalSplitPanesProps) { const containerRef = useRef(null); + const containerPxRef = useRef(0); + const renderedContainerPxRef = useRef(0); + const [containerPx, setContainerPx] = useState(0); const latestSizesRef = useRef([]); const pointerStateRef = useRef(null); const pendingRafRef = useRef(null); @@ -66,6 +72,29 @@ export function TerminalSplitPanes({ }, [onSizesChange, onResizeEnd]); const resolved = resolvePaneSizes(sizes, terminalIds.length); + const resolvedRef = useRef(resolved); + resolvedRef.current = resolved; + const displayed = constrainPaneSizes(resolved, containerPx, MIN_TERMINAL_PANE_PX[direction]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const observer = new ResizeObserver(([entry]) => { + if (!entry) return; + const nextContainerPx = + direction === "horizontal" ? entry.contentRect.width : entry.contentRect.height; + containerPxRef.current = nextContainerPx; + const currentSizes = resolvedRef.current; + const minimum = MIN_TERMINAL_PANE_PX[direction]; + const previous = constrainPaneSizes(currentSizes, renderedContainerPxRef.current, minimum); + const next = constrainPaneSizes(currentSizes, nextContainerPx, minimum); + if (!sizesDiffer(previous, next)) return; + renderedContainerPxRef.current = nextContainerPx; + setContainerPx(nextContainerPx); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [direction, sizes, terminalIds.length]); const writeSizesToDom = useCallback( (nextSizes: readonly number[]) => { @@ -139,7 +168,7 @@ export function TerminalSplitPanes({ }, [flushPending, writeSizesToDom]); useLayoutEffect(() => { - const displayedSizes = pointerStateRef.current ? latestSizesRef.current : resolved; + const displayedSizes = pointerStateRef.current ? latestSizesRef.current : displayed; latestSizesRef.current = displayedSizes; writeSizesToDom(displayedSizes); }); @@ -182,6 +211,8 @@ export function TerminalSplitPanes({ const startDrag = (event: ReactPointerEvent, handleIndex: number) => { if (event.button !== 0 || pointerStateRef.current) return; + const dragContainerPx = containerPxRef.current; + if (dragContainerPx <= 0) return; try { event.currentTarget.setPointerCapture(event.pointerId); } catch { @@ -189,8 +220,6 @@ export function TerminalSplitPanes({ } event.preventDefault(); event.stopPropagation(); - const bounds = containerRef.current?.getBoundingClientRect(); - if (!bounds) return; pointerStateRef.current = { pointerId: event.pointerId, handleIndex, @@ -200,21 +229,60 @@ export function TerminalSplitPanes({ startClientY: event.clientY, pendingClientX: event.clientX, pendingClientY: event.clientY, - startSizes: resolved, - containerPx: direction === "horizontal" ? bounds.width : bounds.height, + startSizes: displayed, + containerPx: dragContainerPx, }; - latestSizesRef.current = resolved; + latestSizesRef.current = displayed; event.currentTarget.dataset.dragging = "true"; document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize"; document.body.style.userSelect = "none"; }; + const handleKeyDown = (event: ReactKeyboardEvent, handleIndex: number) => { + if (pointerStateRef.current) return; + + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + callbacksRef.current.onSizesChange(equalPaneSizes(terminalIds.length)); + callbacksRef.current.onResizeEnd(); + return; + } + + const step = event.shiftKey ? 96 : 24; + const deltaPx = + direction === "horizontal" + ? event.key === "ArrowLeft" + ? -step + : event.key === "ArrowRight" + ? step + : undefined + : event.key === "ArrowUp" + ? -step + : event.key === "ArrowDown" + ? step + : undefined; + if (deltaPx === undefined) return; + + event.preventDefault(); + event.stopPropagation(); + const next = resizeAdjacentPanes({ + sizes: displayed, + handleIndex, + deltaPx, + containerPx: containerPxRef.current, + minPanePx: MIN_TERMINAL_PANE_PX[direction], + }); + callbacksRef.current.onSizesChange(next); + callbacksRef.current.onResizeEnd(); + }; + // Mid-drag re-renders are corrected by the layout effect, which re-applies latestSizesRef. - const offsets = paneBoundaryOffsets(resolved); + const offsets = paneBoundaryOffsets(displayed); const gridStyle = direction === "horizontal" - ? { gridTemplateColumns: paneGridTemplate(resolved) } - : { gridTemplateRows: paneGridTemplate(resolved) }; + ? { gridTemplateColumns: paneGridTemplate(displayed) } + : { gridTemplateRows: paneGridTemplate(displayed) }; return (
{ handleStateRef.current[handleIndex] = element; }} - className={`group absolute z-20 select-none touch-none ${ + className={`group absolute z-20 select-none touch-none outline-none ${ direction === "horizontal" ? "bottom-0 top-0 w-2 -translate-x-1/2 cursor-col-resize" : "left-0 right-0 h-2 -translate-y-1/2 cursor-row-resize" @@ -252,9 +320,14 @@ export function TerminalSplitPanes({ : { top: `calc(${offset * 100}%)` } } role="separator" + tabIndex={0} aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"} aria-label="Resize terminal panes" + aria-valuemin={0} + aria-valuemax={100} + aria-valuenow={Math.round(offset * 100)} onPointerDown={(event) => startDrag(event, handleIndex)} + onKeyDown={(event) => handleKeyDown(event, handleIndex)} onLostPointerCapture={(event) => { if (pointerStateRef.current?.pointerId === event.pointerId) finishDrag(); }} @@ -265,7 +338,7 @@ export function TerminalSplitPanes({ > { + it("returns unchanged when all panes fit at minimum", () => { + const sizes = [0.25, 0.75]; + const result = constrainPaneSizes(sizes, 1000, 160); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("raises a below-min pane and takes the deficit proportionally", () => { + const result = constrainPaneSizes([0.1, 0.3, 0.6], 1000, 200); + expect(result[0]!).toBeCloseTo(0.2, 5); + expect(result[1]!).toBeCloseTo(0.28, 5); + expect(result[2]!).toBeCloseTo(0.52, 5); + }); + + it("sums to exactly 1", () => { + const result = constrainPaneSizes([0.05, 0.25, 0.7], 1000, 200); + expect(result.reduce((total, size) => total + size, 0)).toBe(1); + }); + + it("falls back to equal sizes when the container cannot fit every minimum", () => { + expect(constrainPaneSizes([0.1, 0.2, 0.7], 400, 160)).toEqual(equalPaneSizes(3)); + }); + + it("returns a copy unchanged when containerPx <= 0", () => { + const sizes = [0.25, 0.75]; + const result = constrainPaneSizes(sizes, 0, 160); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("returns a copy unchanged for a single pane", () => { + const sizes = [1]; + const result = constrainPaneSizes(sizes, 100, 160); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("never mutates the input", () => { + const sizes = [0.1, 0.3, 0.6]; + const copy = [...sizes]; + constrainPaneSizes(sizes, 1000, 200); + expect(sizes).toEqual(copy); + }); +}); + describe("equalPaneSizes", () => { it("returns empty array for count <= 0", () => { expect(equalPaneSizes(0)).toEqual([]); diff --git a/apps/web/src/terminal/splitPaneSizes.ts b/apps/web/src/terminal/splitPaneSizes.ts index dc66b6f06290..e325fc82bc91 100644 --- a/apps/web/src/terminal/splitPaneSizes.ts +++ b/apps/web/src/terminal/splitPaneSizes.ts @@ -32,6 +32,31 @@ export function resolvePaneSizes(sizes: readonly number[] | undefined, count: nu return Array.from(sizes, (size) => size / sum); } +/** Raises undersized panes to the minimum fraction while preserving a total size of 1. */ +export function constrainPaneSizes( + sizes: readonly number[], + containerPx: number, + minPanePx: number, +): number[] { + if (containerPx <= 0 || sizes.length <= 1) return Array.from(sizes); + + const minFraction = minPanePx / containerPx; + if (sizes.length * minFraction >= 1) return equalPaneSizes(sizes.length); + if (sizes.every((size) => size >= minFraction)) return Array.from(sizes); + + const totalExcess = sizes.reduce((total, size) => total + Math.max(0, size - minFraction), 0); + const availableExcess = 1 - sizes.length * minFraction; + const result = sizes.map((size) => + size <= minFraction + ? minFraction + : minFraction + ((size - minFraction) / totalExcess) * availableExcess, + ); + const correction = 1 - result.reduce((total, size) => total + size, 0); + const correctionIndex = result.findIndex((size) => size > minFraction); + result[correctionIndex] = (result[correctionIndex] ?? 0) + correction; + return result; +} + /** * Moves the boundary between pane `handleIndex` and pane `handleIndex + 1` by `deltaPx` * (positive = towards the end, i.e. pane handleIndex grows). Only those two panes change; From 61dc5bc9b2f3f7b8e31c0f4c74c3d15aaac4ccf5 Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 12:03:21 -0600 Subject: [PATCH 3/7] refactor(web): reuse useResizeDrag for split terminal panes TerminalSplitPanes hand-rolled the same pointer lifecycle that useResizeDrag already provides for the sidebar and side panels: pointer capture, rAF-throttled moves, body cursor and user-select, and ending the drag on release, cancel, lost capture, window blur, or unmount. useResizeDrag gains an optional `axis` so a session can track clientY and show the row-resize cursor; existing horizontal callers are unchanged. The split panes now run a single hook instance for all handles, and the handle element is captured at drag start because React clears currentTarget before cleanup runs. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/TerminalSplitPanes.tsx | 193 +++++------------- apps/web/src/hooks/useResizeDrag.test.tsx | 103 ++++++++++ apps/web/src/hooks/useResizeDrag.ts | 25 ++- 3 files changed, 173 insertions(+), 148 deletions(-) create mode 100644 apps/web/src/hooks/useResizeDrag.test.tsx diff --git a/apps/web/src/components/TerminalSplitPanes.tsx b/apps/web/src/components/TerminalSplitPanes.tsx index 98ef4f091679..16f5c9b5efcd 100644 --- a/apps/web/src/components/TerminalSplitPanes.tsx +++ b/apps/web/src/components/TerminalSplitPanes.tsx @@ -1,6 +1,5 @@ import { type KeyboardEvent as ReactKeyboardEvent, - type PointerEvent as ReactPointerEvent, type ReactNode, useCallback, useEffect, @@ -8,6 +7,7 @@ import { useRef, useState, } from "react"; +import { useResizeDrag } from "~/hooks/useResizeDrag"; import { constrainPaneSizes, equalPaneSizes, @@ -30,19 +30,6 @@ interface TerminalSplitPanesProps { renderTerminal: (terminalId: string) => ReactNode; } -interface PointerState { - pointerId: number; - handleIndex: number; - target: HTMLDivElement; - direction: TerminalSplitDirection; - startClientX: number; - startClientY: number; - pendingClientX: number; - pendingClientY: number; - startSizes: number[]; - containerPx: number; -} - function sizesDiffer(left: readonly number[], right: readonly number[]) { return left.some((size, index) => size !== right[index]); } @@ -63,8 +50,7 @@ export function TerminalSplitPanes({ const renderedContainerPxRef = useRef(0); const [containerPx, setContainerPx] = useState(0); const latestSizesRef = useRef([]); - const pointerStateRef = useRef(null); - const pendingRafRef = useRef(null); + const draggingRef = useRef(false); const handleStateRef = useRef>([]); const callbacksRef = useRef({ onSizesChange, onResizeEnd }); useLayoutEffect(() => { @@ -73,7 +59,6 @@ export function TerminalSplitPanes({ const resolved = resolvePaneSizes(sizes, terminalIds.length); const resolvedRef = useRef(resolved); - resolvedRef.current = resolved; const displayed = constrainPaneSizes(resolved, containerPx, MIN_TERMINAL_PANE_PX[direction]); useEffect(() => { @@ -98,12 +83,10 @@ export function TerminalSplitPanes({ const writeSizesToDom = useCallback( (nextSizes: readonly number[]) => { - const pointerState = pointerStateRef.current; - const activeDirection = pointerState?.direction ?? direction; const container = containerRef.current; if (!container) return; const template = paneGridTemplate(nextSizes); - const horizontal = activeDirection === "horizontal"; + const horizontal = direction === "horizontal"; container.style.gridTemplateColumns = horizontal ? template : ""; container.style.gridTemplateRows = horizontal ? "" : template; for (const [index, offset] of paneBoundaryOffsets(nextSizes).entries()) { @@ -117,129 +100,63 @@ export function TerminalSplitPanes({ [direction], ); - const flushPending = useCallback(() => { - if (pendingRafRef.current !== null) { - cancelAnimationFrame(pendingRafRef.current); - pendingRafRef.current = null; - } - const state = pointerStateRef.current; - if (!state) return; - const position = state.direction === "horizontal" ? state.pendingClientX : state.pendingClientY; - const start = state.direction === "horizontal" ? state.startClientX : state.startClientY; - const nextSizes = resizeAdjacentPanes({ - sizes: state.startSizes, - handleIndex: state.handleIndex, - deltaPx: position - start, - containerPx: state.containerPx, - minPanePx: MIN_TERMINAL_PANE_PX[state.direction], - }); - writeSizesToDom(nextSizes); - latestSizesRef.current = nextSizes; - }, [writeSizesToDom]); + const resizeHandlers = useResizeDrag( + (event) => { + const dragContainerPx = containerPxRef.current; + if (dragContainerPx <= 0) return null; + // React clears currentTarget after dispatch; cleanup runs at drag end. + const handle = event.currentTarget; + const handleIndex = Number(handle.dataset.handleIndex); + const startSizes = displayed; + const boundaryStartPx = paneBoundaryOffsets(startSizes)[handleIndex]! * dragContainerPx; + draggingRef.current = true; + latestSizesRef.current = startSizes; + handle.dataset.dragging = "true"; - const finishDrag = useCallback(() => { - const state = pointerStateRef.current; - if (!state) return; - flushPending(); - pointerStateRef.current = null; - try { - if (state.target.hasPointerCapture(state.pointerId)) { - state.target.releasePointerCapture(state.pointerId); - } - } catch { - // Capture may already have been released by the browser. - } - state.target.removeAttribute("data-dragging"); - document.body.style.removeProperty("cursor"); - document.body.style.removeProperty("user-select"); - - const distance = Math.hypot( - state.pendingClientX - state.startClientX, - state.pendingClientY - state.startClientY, - ); - const changed = sizesDiffer(state.startSizes, latestSizesRef.current); - if (changed && distance > 2) { - callbacksRef.current.onSizesChange(latestSizesRef.current); - callbacksRef.current.onResizeEnd(); - } else { - latestSizesRef.current = state.startSizes; - writeSizesToDom(state.startSizes); - } - }, [flushPending, writeSizesToDom]); + return { + width: boundaryStartPx, + axis: direction === "horizontal" ? "x" : "y", + edge: "right", + resize(value) { + const next = resizeAdjacentPanes({ + sizes: startSizes, + handleIndex, + deltaPx: value - boundaryStartPx, + containerPx: dragContainerPx, + minPanePx: MIN_TERMINAL_PANE_PX[direction], + }); + writeSizesToDom(next); + latestSizesRef.current = next; + return paneBoundaryOffsets(next)[handleIndex]! * dragContainerPx; + }, + finish(_value, moved) { + const changed = sizesDiffer(startSizes, latestSizesRef.current); + if (moved && changed) { + callbacksRef.current.onSizesChange(latestSizesRef.current); + callbacksRef.current.onResizeEnd(); + } else { + latestSizesRef.current = startSizes; + writeSizesToDom(startSizes); + } + }, + cleanup() { + draggingRef.current = false; + handle.removeAttribute("data-dragging"); + }, + }; + }, + `${direction}:${terminalIds.join(",")}`, + ); useLayoutEffect(() => { - const displayedSizes = pointerStateRef.current ? latestSizesRef.current : displayed; + resolvedRef.current = resolved; + const displayedSizes = draggingRef.current ? latestSizesRef.current : displayed; latestSizesRef.current = displayedSizes; writeSizesToDom(displayedSizes); }); - useEffect(() => { - const onPointerMove = (event: PointerEvent) => { - const state = pointerStateRef.current; - if (!state || state.pointerId !== event.pointerId) return; - event.preventDefault(); - state.pendingClientX = event.clientX; - state.pendingClientY = event.clientY; - if (pendingRafRef.current !== null) return; - pendingRafRef.current = requestAnimationFrame(() => { - pendingRafRef.current = null; - flushPending(); - }); - }; - const onPointerEnd = (event: PointerEvent) => { - const state = pointerStateRef.current; - if (!state || state.pointerId !== event.pointerId) return; - if (event.type === "pointerup") { - state.pendingClientX = event.clientX; - state.pendingClientY = event.clientY; - } - finishDrag(); - }; - window.addEventListener("pointermove", onPointerMove, { passive: false }); - window.addEventListener("pointerup", onPointerEnd); - window.addEventListener("pointercancel", onPointerEnd); - window.addEventListener("blur", finishDrag); - return () => { - window.removeEventListener("pointermove", onPointerMove); - window.removeEventListener("pointerup", onPointerEnd); - window.removeEventListener("pointercancel", onPointerEnd); - window.removeEventListener("blur", finishDrag); - finishDrag(); - if (pendingRafRef.current !== null) cancelAnimationFrame(pendingRafRef.current); - }; - }, [finishDrag, flushPending]); - - const startDrag = (event: ReactPointerEvent, handleIndex: number) => { - if (event.button !== 0 || pointerStateRef.current) return; - const dragContainerPx = containerPxRef.current; - if (dragContainerPx <= 0) return; - try { - event.currentTarget.setPointerCapture(event.pointerId); - } catch { - return; - } - event.preventDefault(); - event.stopPropagation(); - pointerStateRef.current = { - pointerId: event.pointerId, - handleIndex, - target: event.currentTarget, - direction, - startClientX: event.clientX, - startClientY: event.clientY, - pendingClientX: event.clientX, - pendingClientY: event.clientY, - startSizes: displayed, - containerPx: dragContainerPx, - }; - latestSizesRef.current = displayed; - event.currentTarget.dataset.dragging = "true"; - document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize"; - document.body.style.userSelect = "none"; - }; - const handleKeyDown = (event: ReactKeyboardEvent, handleIndex: number) => { - if (pointerStateRef.current) return; + if (draggingRef.current) return; if (event.key === "Enter") { event.preventDefault(); @@ -326,11 +243,9 @@ export function TerminalSplitPanes({ aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(offset * 100)} - onPointerDown={(event) => startDrag(event, handleIndex)} + data-handle-index={handleIndex} + {...resizeHandlers} onKeyDown={(event) => handleKeyDown(event, handleIndex)} - onLostPointerCapture={(event) => { - if (pointerStateRef.current?.pointerId === event.pointerId) finishDrag(); - }} onDoubleClick={() => { callbacksRef.current.onSizesChange(equalPaneSizes(terminalIds.length)); callbacksRef.current.onResizeEnd(); diff --git a/apps/web/src/hooks/useResizeDrag.test.tsx b/apps/web/src/hooks/useResizeDrag.test.tsx new file mode 100644 index 000000000000..977aadcaca15 --- /dev/null +++ b/apps/web/src/hooks/useResizeDrag.test.tsx @@ -0,0 +1,103 @@ +import { act, useLayoutEffect, type PointerEvent } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useResizeDrag } from "./useResizeDrag"; + +let renderer: ReactTestRenderer; +let handlers: ReturnType>; +let frame: FrameRequestCallback | undefined; +let captured = false; + +const target = { + setPointerCapture: () => { + captured = true; + }, + hasPointerCapture: () => captured, + releasePointerCapture: () => { + captured = false; + }, +}; +const style = { + cursor: "", + userSelect: "", + removeProperty(property: string) { + if (property === "cursor") this.cursor = ""; + if (property === "user-select") this.userSelect = ""; + }, +}; + +function pointer(clientX: number, clientY: number) { + return { + button: 0, + pointerId: 1, + clientX, + clientY, + currentTarget: target, + preventDefault() {}, + stopPropagation() {}, + } as unknown as PointerEvent; +} + +function ResizeHarness({ + resize, + finish, +}: { + resize: (value: number) => number; + finish: () => void; +}) { + const nextHandlers = useResizeDrag(() => ({ + width: 200, + axis: "y", + edge: "right", + resize, + finish, + })); + useLayoutEffect(() => { + handlers = nextHandlers; + }); + return null; +} + +beforeEach(() => { + captured = false; + frame = undefined; + style.cursor = ""; + style.userSelect = ""; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener() {}, removeEventListener() {} }); + vi.stubGlobal("document", { body: { style } }); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frame = callback; + return 42; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); +}); + +afterEach(async () => { + await act(() => renderer.unmount()); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("vertical resize drag", () => { + it("uses clientY and the row resize cursor", async () => { + const resize = vi.fn((value: number) => value); + const finish = vi.fn(); + await act(() => { + renderer = create(); + }); + + await act(() => { + handlers.onPointerDown(pointer(100, 300)); + handlers.onPointerMove(pointer(500, 325)); + }); + expect(style.cursor).toBe("row-resize"); + await act(() => frame?.(0)); + expect(resize).toHaveBeenLastCalledWith(225); + + await act(() => handlers.onPointerUp(pointer(900, 340))); + expect(resize).toHaveBeenLastCalledWith(240); + expect(finish).toHaveBeenCalledWith(240, true); + }); +}); diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts index 6509a61767d0..8c8c35cf311d 100644 --- a/apps/web/src/hooks/useResizeDrag.ts +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -2,6 +2,8 @@ import { type PointerEvent, useCallback, useEffect, useLayoutEffect, useRef } fr interface ResizeSession { width: number; + axis?: "x" | "y"; + // Edge meaning is unchanged across axes: left subtracts the pointer delta, right adds it. edge: "left" | "right"; resize: (width: number) => number; finish: (width: number, moved: boolean) => void; @@ -17,8 +19,8 @@ export function useResizeDrag( session: ResizeSession; target: T; pointerId: number; - startX: number; - pendingX: number; + startPosition: number; + pendingPosition: number; width: number; moved: boolean; frame: number | null; @@ -27,7 +29,8 @@ export function useResizeDrag( const flush = useCallback(() => { const active = drag.current; if (!active) return; - const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1); + const delta = + (active.pendingPosition - active.startPosition) * (active.session.edge === "left" ? -1 : 1); active.moved ||= Math.abs(delta) > 2; active.width = active.session.resize(active.session.width + delta); }, []); @@ -76,7 +79,9 @@ export function useResizeDrag( const active = drag.current; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); - if (usePosition) active.pendingX = event.clientX; + if (usePosition) { + active.pendingPosition = active.session.axis === "y" ? event.clientY : event.clientX; + } finish(); }; @@ -93,25 +98,27 @@ export function useResizeDrag( } event.preventDefault(); event.stopPropagation(); + const position = session.axis === "y" ? event.clientY : event.clientX; drag.current = { session, target: event.currentTarget, pointerId: event.pointerId, - startX: event.clientX, - pendingX: event.clientX, + startPosition: position, + pendingPosition: position, width: session.width, moved: false, frame: null, }; - document.body.style.cursor = "col-resize"; + document.body.style.cursor = session.axis === "y" ? "row-resize" : "col-resize"; document.body.style.userSelect = "none"; }, onPointerMove(event: PointerEvent) { const active = drag.current; if (!active || active.pointerId !== event.pointerId) return; event.preventDefault(); - active.pendingX = event.clientX; - active.moved ||= Math.abs(event.clientX - active.startX) > 2; + const position = active.session.axis === "y" ? event.clientY : event.clientX; + active.pendingPosition = position; + active.moved ||= Math.abs(position - active.startPosition) > 2; if (active.frame !== null) return; active.frame = requestAnimationFrame(() => { active.frame = null; From 99d06f5ddf3f870f9ef5af0d94547ab827169c03 Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 12:13:56 -0600 Subject: [PATCH 4/7] fix(web): stop split terminal text from rewrapping mid-drag Dragging a split-pane boundary looked like blinking. Frame captures (CPU and GPU compositing, 1x and 2x DPR) showed no blank or stretched frames: every drag step changed the pane width, the surface's ResizeObserver called fit(), and core.resize reflowed soft-wrapped lines, so wrapped text jumped between rows on every frame. GhosttyTerminalSurface gains setReflowDeferred(). While deferred, fit() still sizes and repaints the canvas but keeps the current grid, so text is clipped at the moving edge instead of rewrapping, and the PTY is not notified. Clearing the flag refits once to the settled size. The first fit still establishes the grid. TerminalSplitPanes defers reflow for its terminals only between drag start and release; keyboard steps and double-click resets stay immediate. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/TerminalSplitPanes.tsx | 7 +- .../src/components/ThreadTerminalDrawer.tsx | 13 +++- apps/web/src/terminal/ghostty/surface.test.ts | 64 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 28 ++++++-- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/TerminalSplitPanes.tsx b/apps/web/src/components/TerminalSplitPanes.tsx index 16f5c9b5efcd..9d09dafd4d39 100644 --- a/apps/web/src/components/TerminalSplitPanes.tsx +++ b/apps/web/src/components/TerminalSplitPanes.tsx @@ -27,7 +27,7 @@ interface TerminalSplitPanesProps { onSizesChange: (sizes: number[]) => void; onPaneActivate: (terminalId: string) => void; onResizeEnd: () => void; - renderTerminal: (terminalId: string) => ReactNode; + renderTerminal: (terminalId: string, reflowDeferred: boolean) => ReactNode; } function sizesDiffer(left: readonly number[], right: readonly number[]) { @@ -51,6 +51,7 @@ export function TerminalSplitPanes({ const [containerPx, setContainerPx] = useState(0); const latestSizesRef = useRef([]); const draggingRef = useRef(false); + const [reflowDeferred, setReflowDeferred] = useState(false); const handleStateRef = useRef>([]); const callbacksRef = useRef({ onSizesChange, onResizeEnd }); useLayoutEffect(() => { @@ -110,6 +111,7 @@ export function TerminalSplitPanes({ const startSizes = displayed; const boundaryStartPx = paneBoundaryOffsets(startSizes)[handleIndex]! * dragContainerPx; draggingRef.current = true; + setReflowDeferred(true); latestSizesRef.current = startSizes; handle.dataset.dragging = "true"; @@ -141,6 +143,7 @@ export function TerminalSplitPanes({ }, cleanup() { draggingRef.current = false; + setReflowDeferred(false); handle.removeAttribute("data-dragging"); }, }; @@ -217,7 +220,7 @@ export function TerminalSplitPanes({ if (terminalId !== activeTerminalId) onPaneActivate(terminalId); }} > -
{renderTerminal(terminalId)}
+
{renderTerminal(terminalId, reflowDeferred)}
))} {offsets.map((offset: number, handleIndex: number) => ( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 4e7825d38823..a66c5a9eeba3 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -325,6 +325,7 @@ interface TerminalViewportProps { resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; + reflowDeferred?: boolean; } interface TerminalLaunchLocation { @@ -351,6 +352,7 @@ export function TerminalViewport({ resizeEpoch, drawerHeight, keybindings, + reflowDeferred = false, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -466,6 +468,13 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); + // A surface that finishes loading mid-drag reads the latest value from the ref. + const reflowDeferredRef = useRef(reflowDeferred); + useLayoutEffect(() => { + reflowDeferredRef.current = reflowDeferred; + terminalRef.current?.setReflowDeferred(reflowDeferred); + }, [reflowDeferred]); + useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); @@ -515,6 +524,7 @@ export function TerminalViewport({ return null; } terminal.setVisible(visibleRef.current); + terminal.setReflowDeferred(reflowDeferredRef.current); // The theme observer is not installed yet, so re-read the theme in case // the app toggled light/dark while the WASM surface was loading. terminal.setTheme(terminalThemeFromApp(mount)); @@ -1514,7 +1524,7 @@ export default function ThreadTerminalDrawer({ }} onPaneActivate={onActiveTerminalChange} onResizeEnd={() => setResizeEpoch((value) => value + 1)} - renderTerminal={(terminalId) => { + renderTerminal={(terminalId, reflowDeferred) => { const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); return ( ); }} diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 59150ee320ae..5606e36e752c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -145,14 +145,17 @@ describe("GhosttyTerminalSurface visibility", () => { }, ); const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot"); + const coreResize = vi.spyOn(GhosttyTerminalCore.prototype, "resize"); const onData = vi.fn<(data: string) => void>(); return { mount, + canvas, frames, paint, requestFrame, snapshot, + coreResize, onData, get renderedSnapshot() { const result = snapshot.mock.results.at(-1); @@ -408,6 +411,67 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.paint).toHaveBeenCalled(); }); + it("repaints a resized canvas without changing the grid while reflow is deferred", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + const initialCols = harness.renderedSnapshot.cols; + harness.snapshot.mockClear(); + harness.paint.mockClear(); + + surface.setReflowDeferred(true); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + + expect(harness.canvas.width).toBe(88); + expect(harness.snapshot).toHaveBeenCalledOnce(); + expect(harness.paint).toHaveBeenCalled(); + expect(harness.renderedSnapshot.cols).toBe(initialCols); + expect(onResize).not.toHaveBeenCalled(); + }); + + it("reflows once to the settled size when deferred reflow ends", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + harness.coreResize.mockClear(); + + surface.setReflowDeferred(true); + harness.mount.clientWidth = 88; + harness.resize(); + harness.mount.clientWidth = 104; + harness.resize(); + surface.setReflowDeferred(false); + + expect(harness.coreResize).toHaveBeenCalledOnce(); + expect(harness.renderedSnapshot.cols).toBe(12); + expect(onResize).not.toHaveBeenCalled(); + vi.advanceTimersByTime(150); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(12, 6); + }); + + it("establishes the first visible grid when reflow is already deferred", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ visible: false, onResize }); + harness.coreResize.mockClear(); + + surface.setReflowDeferred(true); + surface.setVisible(true); + + expect(harness.coreResize).toHaveBeenCalledOnce(); + expect(harness.renderedSnapshot).toMatchObject({ cols: 20, rows: 6 }); + vi.advanceTimersByTime(150); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(20, 6); + }); + it.each([false, true])( "uses visibility %s if it changes while WASM initializes", async (visible) => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index be62ede4d065..5fd364fdb9cd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -621,6 +621,7 @@ export class GhosttyTerminalSurface { private composing = false; private focused = false; private resizeNotified = false; + private reflowDeferred = false; private canvasConfigured = false; private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); @@ -890,11 +891,16 @@ export class GhosttyTerminalSurface { this.mountHeight = height; // onResize is the only PTY resize channel, so the first successful fit must // notify even when the measured grid equals the 1x1 construction sentinel. + // During split-pane drag, defer grid reflow to avoid wrapped text jumping every frame. + // Keep canvas-sized repaint; reflow happens once when drag ends via setReflowDeferred(false). if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { - this.cols = grid.cols; - this.rows = grid.rows; - this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); - this.notifyResize(); + const shouldSkipReflow = this.reflowDeferred && this.resizeNotified; + if (!shouldSkipReflow) { + this.cols = grid.cols; + this.rows = grid.rows; + this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); + this.notifyResize(); + } this.forceFullRender = true; this.scrollbarDirty = true; shouldRender = true; @@ -906,6 +912,20 @@ export class GhosttyTerminalSurface { return true; } + /** + * Defer grid reflow during split-pane drag. Canvas repaints every frame, but grid + * stays fixed until setReflowDeferred(false) reflows to the settled size. Prevents + * wrapped text from jumping ~60×/s as the boundary moves during user interaction. + */ + setReflowDeferred(deferred: boolean): void { + if (this.disposed || deferred === this.reflowDeferred) return; + this.reflowDeferred = deferred; + if (!deferred) { + // Reflow to settled size now that drag has ended + this.fit(); + } + } + /** * The local grid reflows immediately, but the PTY only hears about settled * dimensions: notifying on every drag step makes the shell reprint its From a942ee2db54704f188a4c5e727a828efc7e205c9 Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 13:57:58 -0600 Subject: [PATCH 5/7] fix(web): stop split pane text shake and duplicated prompts Two problems when dragging split terminal panes. Text shook while dragging. Device-resolution captures showed the moving pane's text stepping 2 or 4 device pixels while its border stepped 3: grid tracks were fractional `fr` values, pane boxes landed on sub-pixel offsets, and the terminal canvas is painted on whole CSS pixels, so text and border drifted against each other. During a drag the grid now uses whole-pixel tracks and handle positions, and keyboard steps snap to whole pixels. The same capture now shows text moving exactly with its pane on every step. Releasing a pane narrower than the shell prompt left a duplicated prompt row. The grid reflowed first, splitting the prompt across rows, then the shell redrew it assuming one row and orphaned the first half. On release the surface now sends the PTY the new size immediately and holds the grid reflow until shell output arrives or 250 ms pass; refits that land in that window (such as the drawer's post-drag refit) only retarget the pending reflow instead of reflowing early. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/TerminalSplitPanes.tsx | 40 +++++-- apps/web/src/terminal/ghostty/surface.test.ts | 112 ++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 79 ++++++++++-- apps/web/src/terminal/splitPaneSizes.test.ts | 56 +++++++++ apps/web/src/terminal/splitPaneSizes.ts | 35 ++++++ 5 files changed, 303 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/TerminalSplitPanes.tsx b/apps/web/src/components/TerminalSplitPanes.tsx index 9d09dafd4d39..fac419fa3b21 100644 --- a/apps/web/src/components/TerminalSplitPanes.tsx +++ b/apps/web/src/components/TerminalSplitPanes.tsx @@ -14,8 +14,10 @@ import { MIN_TERMINAL_PANE_PX, paneBoundaryOffsets, paneGridTemplate, + panePixelBoundaries, resizeAdjacentPanes, resolvePaneSizes, + snapPaneSizesToWholePixels, type TerminalSplitDirection, } from "~/terminal/splitPaneSizes"; @@ -83,17 +85,28 @@ export function TerminalSplitPanes({ }, [direction, sizes, terminalIds.length]); const writeSizesToDom = useCallback( - (nextSizes: readonly number[]) => { + (nextSizes: readonly number[], extentPx?: number) => { const container = containerRef.current; if (!container) return; - const template = paneGridTemplate(nextSizes); const horizontal = direction === "horizontal"; + const boundaries = extentPx && extentPx > 0 ? panePixelBoundaries(nextSizes, extentPx) : null; + // Terminal canvases paint on whole CSS pixels; fractional tracks make text and borders drift. + const template = boundaries + ? [ + ...boundaries.map((boundary, index) => { + const previousBoundary = boundaries[index - 1] ?? 0; + return `${boundary - previousBoundary}px`; + }), + "minmax(0, 1fr)", + ].join(" ") + : paneGridTemplate(nextSizes); container.style.gridTemplateColumns = horizontal ? template : ""; container.style.gridTemplateRows = horizontal ? "" : template; - for (const [index, offset] of paneBoundaryOffsets(nextSizes).entries()) { + const positions = boundaries ?? paneBoundaryOffsets(nextSizes).map((offset) => offset * 100); + for (const [index, positionValue] of positions.entries()) { const handle = handleStateRef.current[index]; if (!handle) continue; - const position = `calc(${offset * 100}%)`; + const position = boundaries ? `${positionValue}px` : `calc(${positionValue}%)`; handle.style.left = horizontal ? position : ""; handle.style.top = horizontal ? "" : position; } @@ -127,9 +140,10 @@ export function TerminalSplitPanes({ containerPx: dragContainerPx, minPanePx: MIN_TERMINAL_PANE_PX[direction], }); - writeSizesToDom(next); - latestSizesRef.current = next; - return paneBoundaryOffsets(next)[handleIndex]! * dragContainerPx; + const snapped = snapPaneSizesToWholePixels(next, dragContainerPx); + writeSizesToDom(snapped, dragContainerPx); + latestSizesRef.current = snapped; + return panePixelBoundaries(snapped, dragContainerPx)[handleIndex]!; }, finish(_value, moved) { const changed = sizesDiffer(startSizes, latestSizesRef.current); @@ -153,9 +167,12 @@ export function TerminalSplitPanes({ useLayoutEffect(() => { resolvedRef.current = resolved; - const displayedSizes = draggingRef.current ? latestSizesRef.current : displayed; - latestSizesRef.current = displayedSizes; - writeSizesToDom(displayedSizes); + if (draggingRef.current) { + writeSizesToDom(latestSizesRef.current, containerPxRef.current); + return; + } + latestSizesRef.current = displayed; + writeSizesToDom(displayed); }); const handleKeyDown = (event: ReactKeyboardEvent, handleIndex: number) => { @@ -193,7 +210,8 @@ export function TerminalSplitPanes({ containerPx: containerPxRef.current, minPanePx: MIN_TERMINAL_PANE_PX[direction], }); - callbacksRef.current.onSizesChange(next); + const snapped = snapPaneSizesToWholePixels(next, containerPxRef.current); + callbacksRef.current.onSizesChange(snapped); callbacksRef.current.onResizeEnd(); }; diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 5606e36e752c..70517e436715 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -448,12 +448,124 @@ describe("GhosttyTerminalSurface visibility", () => { harness.resize(); surface.setReflowDeferred(false); + expect(harness.coreResize).not.toHaveBeenCalled(); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(12, 6); + vi.advanceTimersByTime(250); + harness.flushFrame(); expect(harness.coreResize).toHaveBeenCalledOnce(); expect(harness.renderedSnapshot.cols).toBe(12); + }); + + it("releasing deferral after narrowing resize calls onResize immediately and does not resize core grid yet", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + harness.coreResize.mockClear(); + const initialCols = harness.renderedSnapshot.cols; + + surface.setReflowDeferred(true); + harness.mount.clientWidth = 88; + harness.resize(); + surface.setReflowDeferred(false); + + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(10, 6); + expect(harness.coreResize).not.toHaveBeenCalled(); + expect(harness.renderedSnapshot.cols).toBe(initialCols); + }); + + it("next PTY write reflows grid once and does not double onResize", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + + surface.setReflowDeferred(true); + harness.mount.clientWidth = 88; + harness.resize(); + surface.setReflowDeferred(false); + expect(onResize).toHaveBeenCalledOnce(); + onResize.mockClear(); + harness.coreResize.mockClear(); + + surface.write("text"); + harness.flushFrame(); + + expect(harness.coreResize).toHaveBeenCalledOnce(); + expect(harness.renderedSnapshot.cols).toBe(10); + vi.advanceTimersByTime(150); expect(onResize).not.toHaveBeenCalled(); + }); + + it("without PTY output, grid reflows after 250ms", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); vi.advanceTimersByTime(150); + + surface.setReflowDeferred(true); + harness.mount.clientWidth = 88; + harness.resize(); + surface.setReflowDeferred(false); + expect(onResize).toHaveBeenLastCalledWith(10, 6); + harness.coreResize.mockClear(); + + vi.advanceTimersByTime(249); + expect(harness.coreResize).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + harness.flushFrame(); + + expect(harness.coreResize).toHaveBeenCalledOnce(); + expect(harness.renderedSnapshot.cols).toBe(10); + }); + + it("dispose while pending does not reflow or throw later", async () => { + const harness = createHarness(); + const surface = await harness.create(); + vi.advanceTimersByTime(150); + + surface.setReflowDeferred(true); + harness.mount.clientWidth = 88; + harness.resize(); + surface.setReflowDeferred(false); + harness.coreResize.mockClear(); + surface.dispose(); + + expect(() => vi.advanceTimersByTime(300)).not.toThrow(); + expect(harness.coreResize).not.toHaveBeenCalled(); + }); + + it("keeps the pending reflow when the host refits before shell output arrives", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + + surface.setReflowDeferred(true); + harness.mount.clientWidth = 88; + harness.resize(); + surface.setReflowDeferred(false); + onResize.mockClear(); + harness.coreResize.mockClear(); + + // The drawer refits after a drag ends; that must not reflow before the shell redraws. + surface.fit(); + expect(harness.coreResize).not.toHaveBeenCalled(); + + harness.mount.clientWidth = 104; + harness.resize(); expect(onResize).toHaveBeenCalledOnce(); expect(onResize).toHaveBeenCalledWith(12, 6); + expect(harness.coreResize).not.toHaveBeenCalled(); + + surface.write("prompt"); + harness.flushFrame(); + expect(harness.coreResize).toHaveBeenCalledOnce(); + expect(harness.renderedSnapshot.cols).toBe(12); }); it("establishes the first visible grid when reflow is already deferred", async () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 5fd364fdb9cd..52f29862bd4c 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -590,6 +590,9 @@ export class GhosttyTerminalSurface { private scrollbarPointerOffset = 0; private disposed = false; private resizeNotifyTimer: number | null = null; + private pendingReflowTimer: number | null = null; + private pendingReflowDims: { cols: number; rows: number } | null = null; + private lastResizeDims: { cols: number; rows: number } | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; private selectionEnd: { x: number; y: number } | null = null; @@ -764,7 +767,9 @@ export class GhosttyTerminalSurface { write(data: string): void { if (this.disposed) return; + // Shell prompt output assumes the pre-resize layout; parse it before reflowing. this.core.write(data); + this.applyPendingReflow(); this.synchronizeMouseTrackingState(); // Restart the blink cycle from the visible phase so the cursor never sits // invisible through a stream of output or a burst of typing echo. @@ -776,7 +781,9 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; this.lastMouseMotionData = ""; + // Replayed prompt output also needs the pre-resize grid before its final reflow. this.core.resetAndWrite(data); + this.applyPendingReflow(); this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. @@ -894,7 +901,14 @@ export class GhosttyTerminalSurface { // During split-pane drag, defer grid reflow to avoid wrapped text jumping every frame. // Keep canvas-sized repaint; reflow happens once when drag ends via setReflowDeferred(false). if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { - const shouldSkipReflow = this.reflowDeferred && this.resizeNotified; + const reflowPending = this.pendingReflowDims !== null; + const shouldSkipReflow = (this.reflowDeferred || reflowPending) && this.resizeNotified; + if (reflowPending && this.resizeNotified) { + // A refit while the shell redraws for the released size must not reflow early; it + // only retargets the pending reflow and tells the shell about any newer size. + this.pendingReflowDims = grid; + this.notifyResizeNow(grid.cols, grid.rows); + } if (!shouldSkipReflow) { this.cols = grid.cols; this.rows = grid.rows; @@ -920,23 +934,68 @@ export class GhosttyTerminalSurface { setReflowDeferred(deferred: boolean): void { if (this.disposed || deferred === this.reflowDeferred) return; this.reflowDeferred = deferred; - if (!deferred) { - // Reflow to settled size now that drag has ended + if (deferred) { + this.clearPendingReflow(); + return; + } + const target = terminalGridSize( + this.mount.clientWidth, + this.mount.clientHeight, + this.metrics, + CONTENT_PADDING, + ); + if ((target.cols === this.cols && target.rows === this.rows) || !this.resizeNotified) { this.fit(); + return; } + if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); + this.resizeNotifyTimer = null; + this.notifyResizeNow(target.cols, target.rows); + // Shells redraw the prompt assuming the pre-resize layout, so reflow waits for output. + this.pendingReflowDims = target; + this.pendingReflowTimer = window.setTimeout(() => this.applyPendingReflow(), 250); + } + + private clearPendingReflow(): void { + if (this.pendingReflowTimer !== null) window.clearTimeout(this.pendingReflowTimer); + this.pendingReflowTimer = null; + this.pendingReflowDims = null; + } + + private applyPendingReflow(): void { + const target = this.pendingReflowDims; + if (target === null) return; + this.clearPendingReflow(); + if (target.cols === this.cols && target.rows === this.rows) return; + this.cols = target.cols; + this.rows = target.rows; + this.core.resize(target.cols, target.rows, this.metrics.width, this.metrics.height); + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + + private notifyResizeNow(cols: number, rows: number): void { + if ( + this.disposed || + (this.lastResizeDims?.cols === cols && this.lastResizeDims.rows === rows) + ) { + return; + } + this.options.onResize(cols, rows); + this.lastResizeDims = { cols, rows }; } /** - * The local grid reflows immediately, but the PTY only hears about settled - * dimensions: notifying on every drag step makes the shell reprint its - * prompt mid-drag, which reads as jitter. + * Outside deferred reflow, the PTY only hears about settled dimensions: + * notifying on every drag step makes the shell reprint its prompt mid-drag. */ private notifyResize(): void { this.resizeNotified = true; if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = window.setTimeout(() => { this.resizeNotifyTimer = null; - if (!this.disposed) this.options.onResize(this.cols, this.rows); + this.notifyResizeNow(this.cols, this.rows); }, 150); } @@ -1048,12 +1107,16 @@ export class GhosttyTerminalSurface { this.dprMedia = null; this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + this.clearPendingReflow(); if (this.resizeNotifyTimer !== null) { window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; // Flush the settled dimensions so the PTY keeps the final size even when // the surface unmounts inside the debounce window. - this.options.onResize(this.cols, this.rows); + if (this.lastResizeDims?.cols !== this.cols || this.lastResizeDims.rows !== this.rows) { + this.options.onResize(this.cols, this.rows); + this.lastResizeDims = { cols: this.cols, rows: this.rows }; + } } this.cancelRender(); if (this.compositionSuppressionTimer !== null) { diff --git a/apps/web/src/terminal/splitPaneSizes.test.ts b/apps/web/src/terminal/splitPaneSizes.test.ts index 1645cd42755e..18a7196908e2 100644 --- a/apps/web/src/terminal/splitPaneSizes.test.ts +++ b/apps/web/src/terminal/splitPaneSizes.test.ts @@ -5,8 +5,10 @@ import { equalPaneSizes, paneBoundaryOffsets, paneGridTemplate, + panePixelBoundaries, resizeAdjacentPanes, resolvePaneSizes, + snapPaneSizesToWholePixels, } from "./splitPaneSizes"; describe("constrainPaneSizes", () => { @@ -358,3 +360,57 @@ describe("paneBoundaryOffsets", () => { } }); }); + +describe("snapPaneSizesToWholePixels", () => { + it("snaps boundaries to increasing whole pixels and preserves the total", () => { + const result = snapPaneSizesToWholePixels([0.333333, 0.333333, 0.333334], 101); + const boundaries = panePixelBoundaries(result, 101); + + expect(boundaries).toEqual([34, 67]); + expect(boundaries.every(Number.isInteger)).toBe(true); + expect(boundaries[1]!).toBeGreaterThan(boundaries[0]!); + expect(result.reduce((total, size) => total + size, 0)).toBe(1); + }); + + it("returns a copy unchanged when containerPx <= 0", () => { + const sizes = [0.25, 0.75]; + const result = snapPaneSizesToWholePixels(sizes, 0); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("returns a copy unchanged for a single pane", () => { + const sizes = [1]; + const result = snapPaneSizesToWholePixels(sizes, 100); + expect(result).toEqual(sizes); + expect(result).not.toBe(sizes); + }); + + it("does not mutate the input", () => { + const sizes = [0.333333, 0.333333, 0.333334]; + const copy = [...sizes]; + snapPaneSizesToWholePixels(sizes, 101); + expect(sizes).toEqual(copy); + }); + + it("clamps boundaries within the valid pixel range", () => { + const result = snapPaneSizesToWholePixels([0.001, 0.001, 0.998], 100); + expect(panePixelBoundaries(result, 100)).toEqual([1, 2]); + + const endClamped = snapPaneSizesToWholePixels([0.998, 0.001, 0.001], 100); + expect(panePixelBoundaries(endClamped, 100)).toEqual([98, 99]); + }); +}); + +describe("panePixelBoundaries", () => { + it("returns increasing whole-pixel internal boundaries in range", () => { + const boundaries = panePixelBoundaries([0.25, 0.25, 0.5], 101); + expect(boundaries).toHaveLength(2); + expect(boundaries.every(Number.isInteger)).toBe(true); + expect(boundaries[1]!).toBeGreaterThan(boundaries[0]!); + for (const boundary of boundaries) { + expect(boundary).toBeGreaterThanOrEqual(1); + expect(boundary).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/apps/web/src/terminal/splitPaneSizes.ts b/apps/web/src/terminal/splitPaneSizes.ts index e325fc82bc91..29d9861e8dcd 100644 --- a/apps/web/src/terminal/splitPaneSizes.ts +++ b/apps/web/src/terminal/splitPaneSizes.ts @@ -132,3 +132,38 @@ export function paneBoundaryOffsets(sizes: readonly number[]): number[] { return result; } + +/** Internal pane boundaries rounded to whole CSS pixels for the given container extent. */ +export function panePixelBoundaries(sizes: readonly number[], containerPx: number): number[] { + return paneBoundaryOffsets(sizes).map((offset) => Math.round(offset * containerPx)); +} + +/** + * Returns pane fractions whose internal boundaries land on whole CSS pixels. This keeps terminal + * canvases and their borders aligned while a split is dragged. + */ +export function snapPaneSizesToWholePixels( + sizes: readonly number[], + containerPx: number, +): number[] { + if (containerPx <= 0 || sizes.length <= 1) return Array.from(sizes); + + const boundaryCount = sizes.length - 1; + const boundaries = panePixelBoundaries(sizes, containerPx); + let previous = 0; + + for (let index = 0; index < boundaries.length; index++) { + const upper = Math.ceil(containerPx) - (boundaryCount - index); + const boundary = boundaries[index] ?? previous + 1; + boundaries[index] = Math.max(previous + 1, Math.min(boundary, upper)); + previous = boundaries[index]!; + } + + const result = boundaries.map((boundary, index) => { + const previousBoundary = boundaries[index - 1] ?? 0; + return (boundary - previousBoundary) / containerPx; + }); + const allocated = result.reduce((total, size) => total + size, 0); + result.push(1 - allocated); + return result; +} From be93445e3c93d92cec1c829b66ac8e808f091c3b Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 14:34:34 -0600 Subject: [PATCH 6/7] fix(web): rewrap split terminal text live while dragging Holding the grid until release made the terminal update only after the drag ended, which felt worse than the original flicker. The flicker was a painting problem, fixed separately by whole-pixel grid tracks during the drag, so text can reflow live again. This removes GhosttyTerminalSurface.setReflowDeferred(), the release-time pending reflow, and the drawer's reflowDeferred prop; surface.ts is back to main. Device-resolution captures with live reflow still show text moving exactly with its pane on every drag step. Releasing a pane narrower than the shell prompt can again leave a duplicated prompt row, the same as shrinking the window on main. Co-Authored-By: Claude Opus 5 (1M context) --- .../web/src/components/TerminalSplitPanes.tsx | 7 +- .../src/components/ThreadTerminalDrawer.tsx | 13 +- apps/web/src/terminal/ghostty/surface.test.ts | 176 ------------------ apps/web/src/terminal/ghostty/surface.ts | 101 +--------- 4 files changed, 12 insertions(+), 285 deletions(-) diff --git a/apps/web/src/components/TerminalSplitPanes.tsx b/apps/web/src/components/TerminalSplitPanes.tsx index fac419fa3b21..3b3c509cbd4e 100644 --- a/apps/web/src/components/TerminalSplitPanes.tsx +++ b/apps/web/src/components/TerminalSplitPanes.tsx @@ -29,7 +29,7 @@ interface TerminalSplitPanesProps { onSizesChange: (sizes: number[]) => void; onPaneActivate: (terminalId: string) => void; onResizeEnd: () => void; - renderTerminal: (terminalId: string, reflowDeferred: boolean) => ReactNode; + renderTerminal: (terminalId: string) => ReactNode; } function sizesDiffer(left: readonly number[], right: readonly number[]) { @@ -53,7 +53,6 @@ export function TerminalSplitPanes({ const [containerPx, setContainerPx] = useState(0); const latestSizesRef = useRef([]); const draggingRef = useRef(false); - const [reflowDeferred, setReflowDeferred] = useState(false); const handleStateRef = useRef>([]); const callbacksRef = useRef({ onSizesChange, onResizeEnd }); useLayoutEffect(() => { @@ -124,7 +123,6 @@ export function TerminalSplitPanes({ const startSizes = displayed; const boundaryStartPx = paneBoundaryOffsets(startSizes)[handleIndex]! * dragContainerPx; draggingRef.current = true; - setReflowDeferred(true); latestSizesRef.current = startSizes; handle.dataset.dragging = "true"; @@ -157,7 +155,6 @@ export function TerminalSplitPanes({ }, cleanup() { draggingRef.current = false; - setReflowDeferred(false); handle.removeAttribute("data-dragging"); }, }; @@ -238,7 +235,7 @@ export function TerminalSplitPanes({ if (terminalId !== activeTerminalId) onPaneActivate(terminalId); }} > -
{renderTerminal(terminalId, reflowDeferred)}
+
{renderTerminal(terminalId)}
))} {offsets.map((offset: number, handleIndex: number) => ( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index a66c5a9eeba3..4e7825d38823 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -325,7 +325,6 @@ interface TerminalViewportProps { resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; - reflowDeferred?: boolean; } interface TerminalLaunchLocation { @@ -352,7 +351,6 @@ export function TerminalViewport({ resizeEpoch, drawerHeight, keybindings, - reflowDeferred = false, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -468,13 +466,6 @@ export function TerminalViewport({ keybindingsRef.current = keybindings; }, [keybindings]); - // A surface that finishes loading mid-drag reads the latest value from the ref. - const reflowDeferredRef = useRef(reflowDeferred); - useLayoutEffect(() => { - reflowDeferredRef.current = reflowDeferred; - terminalRef.current?.setReflowDeferred(reflowDeferred); - }, [reflowDeferred]); - useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); @@ -524,7 +515,6 @@ export function TerminalViewport({ return null; } terminal.setVisible(visibleRef.current); - terminal.setReflowDeferred(reflowDeferredRef.current); // The theme observer is not installed yet, so re-read the theme in case // the app toggled light/dark while the WASM surface was loading. terminal.setTheme(terminalThemeFromApp(mount)); @@ -1524,7 +1514,7 @@ export default function ThreadTerminalDrawer({ }} onPaneActivate={onActiveTerminalChange} onResizeEnd={() => setResizeEpoch((value) => value + 1)} - renderTerminal={(terminalId, reflowDeferred) => { + renderTerminal={(terminalId) => { const terminalLaunchLocation = resolveTerminalLaunchLocation(terminalId); return ( ); }} diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 70517e436715..59150ee320ae 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -145,17 +145,14 @@ describe("GhosttyTerminalSurface visibility", () => { }, ); const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot"); - const coreResize = vi.spyOn(GhosttyTerminalCore.prototype, "resize"); const onData = vi.fn<(data: string) => void>(); return { mount, - canvas, frames, paint, requestFrame, snapshot, - coreResize, onData, get renderedSnapshot() { const result = snapshot.mock.results.at(-1); @@ -411,179 +408,6 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.paint).toHaveBeenCalled(); }); - it("repaints a resized canvas without changing the grid while reflow is deferred", async () => { - const harness = createHarness(); - const onResize = vi.fn(); - const surface = await harness.create({ onResize }); - vi.advanceTimersByTime(150); - onResize.mockClear(); - const initialCols = harness.renderedSnapshot.cols; - harness.snapshot.mockClear(); - harness.paint.mockClear(); - - surface.setReflowDeferred(true); - harness.mount.clientWidth = 88; - harness.resize(); - vi.advanceTimersByTime(150); - - expect(harness.canvas.width).toBe(88); - expect(harness.snapshot).toHaveBeenCalledOnce(); - expect(harness.paint).toHaveBeenCalled(); - expect(harness.renderedSnapshot.cols).toBe(initialCols); - expect(onResize).not.toHaveBeenCalled(); - }); - - it("reflows once to the settled size when deferred reflow ends", async () => { - const harness = createHarness(); - const onResize = vi.fn(); - const surface = await harness.create({ onResize }); - vi.advanceTimersByTime(150); - onResize.mockClear(); - harness.coreResize.mockClear(); - - surface.setReflowDeferred(true); - harness.mount.clientWidth = 88; - harness.resize(); - harness.mount.clientWidth = 104; - harness.resize(); - surface.setReflowDeferred(false); - - expect(harness.coreResize).not.toHaveBeenCalled(); - expect(onResize).toHaveBeenCalledOnce(); - expect(onResize).toHaveBeenCalledWith(12, 6); - vi.advanceTimersByTime(250); - harness.flushFrame(); - expect(harness.coreResize).toHaveBeenCalledOnce(); - expect(harness.renderedSnapshot.cols).toBe(12); - }); - - it("releasing deferral after narrowing resize calls onResize immediately and does not resize core grid yet", async () => { - const harness = createHarness(); - const onResize = vi.fn(); - const surface = await harness.create({ onResize }); - vi.advanceTimersByTime(150); - onResize.mockClear(); - harness.coreResize.mockClear(); - const initialCols = harness.renderedSnapshot.cols; - - surface.setReflowDeferred(true); - harness.mount.clientWidth = 88; - harness.resize(); - surface.setReflowDeferred(false); - - expect(onResize).toHaveBeenCalledOnce(); - expect(onResize).toHaveBeenCalledWith(10, 6); - expect(harness.coreResize).not.toHaveBeenCalled(); - expect(harness.renderedSnapshot.cols).toBe(initialCols); - }); - - it("next PTY write reflows grid once and does not double onResize", async () => { - const harness = createHarness(); - const onResize = vi.fn(); - const surface = await harness.create({ onResize }); - vi.advanceTimersByTime(150); - onResize.mockClear(); - - surface.setReflowDeferred(true); - harness.mount.clientWidth = 88; - harness.resize(); - surface.setReflowDeferred(false); - expect(onResize).toHaveBeenCalledOnce(); - onResize.mockClear(); - harness.coreResize.mockClear(); - - surface.write("text"); - harness.flushFrame(); - - expect(harness.coreResize).toHaveBeenCalledOnce(); - expect(harness.renderedSnapshot.cols).toBe(10); - vi.advanceTimersByTime(150); - expect(onResize).not.toHaveBeenCalled(); - }); - - it("without PTY output, grid reflows after 250ms", async () => { - const harness = createHarness(); - const onResize = vi.fn(); - const surface = await harness.create({ onResize }); - vi.advanceTimersByTime(150); - - surface.setReflowDeferred(true); - harness.mount.clientWidth = 88; - harness.resize(); - surface.setReflowDeferred(false); - expect(onResize).toHaveBeenLastCalledWith(10, 6); - harness.coreResize.mockClear(); - - vi.advanceTimersByTime(249); - expect(harness.coreResize).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); - harness.flushFrame(); - - expect(harness.coreResize).toHaveBeenCalledOnce(); - expect(harness.renderedSnapshot.cols).toBe(10); - }); - - it("dispose while pending does not reflow or throw later", async () => { - const harness = createHarness(); - const surface = await harness.create(); - vi.advanceTimersByTime(150); - - surface.setReflowDeferred(true); - harness.mount.clientWidth = 88; - harness.resize(); - surface.setReflowDeferred(false); - harness.coreResize.mockClear(); - surface.dispose(); - - expect(() => vi.advanceTimersByTime(300)).not.toThrow(); - expect(harness.coreResize).not.toHaveBeenCalled(); - }); - - it("keeps the pending reflow when the host refits before shell output arrives", async () => { - const harness = createHarness(); - const onResize = vi.fn(); - const surface = await harness.create({ onResize }); - vi.advanceTimersByTime(150); - - surface.setReflowDeferred(true); - harness.mount.clientWidth = 88; - harness.resize(); - surface.setReflowDeferred(false); - onResize.mockClear(); - harness.coreResize.mockClear(); - - // The drawer refits after a drag ends; that must not reflow before the shell redraws. - surface.fit(); - expect(harness.coreResize).not.toHaveBeenCalled(); - - harness.mount.clientWidth = 104; - harness.resize(); - expect(onResize).toHaveBeenCalledOnce(); - expect(onResize).toHaveBeenCalledWith(12, 6); - expect(harness.coreResize).not.toHaveBeenCalled(); - - surface.write("prompt"); - harness.flushFrame(); - expect(harness.coreResize).toHaveBeenCalledOnce(); - expect(harness.renderedSnapshot.cols).toBe(12); - }); - - it("establishes the first visible grid when reflow is already deferred", async () => { - const harness = createHarness(); - const onResize = vi.fn(); - const surface = await harness.create({ visible: false, onResize }); - harness.coreResize.mockClear(); - - surface.setReflowDeferred(true); - surface.setVisible(true); - - expect(harness.coreResize).toHaveBeenCalledOnce(); - expect(harness.renderedSnapshot).toMatchObject({ cols: 20, rows: 6 }); - vi.advanceTimersByTime(150); - expect(onResize).toHaveBeenCalledOnce(); - expect(onResize).toHaveBeenCalledWith(20, 6); - }); - it.each([false, true])( "uses visibility %s if it changes while WASM initializes", async (visible) => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 52f29862bd4c..be62ede4d065 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -590,9 +590,6 @@ export class GhosttyTerminalSurface { private scrollbarPointerOffset = 0; private disposed = false; private resizeNotifyTimer: number | null = null; - private pendingReflowTimer: number | null = null; - private pendingReflowDims: { cols: number; rows: number } | null = null; - private lastResizeDims: { cols: number; rows: number } | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; private selectionEnd: { x: number; y: number } | null = null; @@ -624,7 +621,6 @@ export class GhosttyTerminalSurface { private composing = false; private focused = false; private resizeNotified = false; - private reflowDeferred = false; private canvasConfigured = false; private theme: GhosttyTheme; private readonly suppressedKeyCodes = new Set(); @@ -767,9 +763,7 @@ export class GhosttyTerminalSurface { write(data: string): void { if (this.disposed) return; - // Shell prompt output assumes the pre-resize layout; parse it before reflowing. this.core.write(data); - this.applyPendingReflow(); this.synchronizeMouseTrackingState(); // Restart the blink cycle from the visible phase so the cursor never sits // invisible through a stream of output or a burst of typing echo. @@ -781,9 +775,7 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; this.lastMouseMotionData = ""; - // Replayed prompt output also needs the pre-resize grid before its final reflow. this.core.resetAndWrite(data); - this.applyPendingReflow(); this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. @@ -898,23 +890,11 @@ export class GhosttyTerminalSurface { this.mountHeight = height; // onResize is the only PTY resize channel, so the first successful fit must // notify even when the measured grid equals the 1x1 construction sentinel. - // During split-pane drag, defer grid reflow to avoid wrapped text jumping every frame. - // Keep canvas-sized repaint; reflow happens once when drag ends via setReflowDeferred(false). if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { - const reflowPending = this.pendingReflowDims !== null; - const shouldSkipReflow = (this.reflowDeferred || reflowPending) && this.resizeNotified; - if (reflowPending && this.resizeNotified) { - // A refit while the shell redraws for the released size must not reflow early; it - // only retargets the pending reflow and tells the shell about any newer size. - this.pendingReflowDims = grid; - this.notifyResizeNow(grid.cols, grid.rows); - } - if (!shouldSkipReflow) { - this.cols = grid.cols; - this.rows = grid.rows; - this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); - this.notifyResize(); - } + this.cols = grid.cols; + this.rows = grid.rows; + this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); + this.notifyResize(); this.forceFullRender = true; this.scrollbarDirty = true; shouldRender = true; @@ -927,75 +907,16 @@ export class GhosttyTerminalSurface { } /** - * Defer grid reflow during split-pane drag. Canvas repaints every frame, but grid - * stays fixed until setReflowDeferred(false) reflows to the settled size. Prevents - * wrapped text from jumping ~60×/s as the boundary moves during user interaction. - */ - setReflowDeferred(deferred: boolean): void { - if (this.disposed || deferred === this.reflowDeferred) return; - this.reflowDeferred = deferred; - if (deferred) { - this.clearPendingReflow(); - return; - } - const target = terminalGridSize( - this.mount.clientWidth, - this.mount.clientHeight, - this.metrics, - CONTENT_PADDING, - ); - if ((target.cols === this.cols && target.rows === this.rows) || !this.resizeNotified) { - this.fit(); - return; - } - if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); - this.resizeNotifyTimer = null; - this.notifyResizeNow(target.cols, target.rows); - // Shells redraw the prompt assuming the pre-resize layout, so reflow waits for output. - this.pendingReflowDims = target; - this.pendingReflowTimer = window.setTimeout(() => this.applyPendingReflow(), 250); - } - - private clearPendingReflow(): void { - if (this.pendingReflowTimer !== null) window.clearTimeout(this.pendingReflowTimer); - this.pendingReflowTimer = null; - this.pendingReflowDims = null; - } - - private applyPendingReflow(): void { - const target = this.pendingReflowDims; - if (target === null) return; - this.clearPendingReflow(); - if (target.cols === this.cols && target.rows === this.rows) return; - this.cols = target.cols; - this.rows = target.rows; - this.core.resize(target.cols, target.rows, this.metrics.width, this.metrics.height); - this.forceFullRender = true; - this.scrollbarDirty = true; - this.requestRender(); - } - - private notifyResizeNow(cols: number, rows: number): void { - if ( - this.disposed || - (this.lastResizeDims?.cols === cols && this.lastResizeDims.rows === rows) - ) { - return; - } - this.options.onResize(cols, rows); - this.lastResizeDims = { cols, rows }; - } - - /** - * Outside deferred reflow, the PTY only hears about settled dimensions: - * notifying on every drag step makes the shell reprint its prompt mid-drag. + * The local grid reflows immediately, but the PTY only hears about settled + * dimensions: notifying on every drag step makes the shell reprint its + * prompt mid-drag, which reads as jitter. */ private notifyResize(): void { this.resizeNotified = true; if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = window.setTimeout(() => { this.resizeNotifyTimer = null; - this.notifyResizeNow(this.cols, this.rows); + if (!this.disposed) this.options.onResize(this.cols, this.rows); }, 150); } @@ -1107,16 +1028,12 @@ export class GhosttyTerminalSurface { this.dprMedia = null; this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); - this.clearPendingReflow(); if (this.resizeNotifyTimer !== null) { window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; // Flush the settled dimensions so the PTY keeps the final size even when // the surface unmounts inside the debounce window. - if (this.lastResizeDims?.cols !== this.cols || this.lastResizeDims.rows !== this.rows) { - this.options.onResize(this.cols, this.rows); - this.lastResizeDims = { cols: this.cols, rows: this.rows }; - } + this.options.onResize(this.cols, this.rows); } this.cancelRender(); if (this.compositionSuppressionTimer !== null) { From 4063c0f7028d2cf2d009bee7463d6faa86bf82e4 Mon Sep 17 00:00:00 2001 From: Pwntastickev Date: Tue, 15 Sep 2026 15:24:36 -0600 Subject: [PATCH 7/7] fix(web): keep shell prompts intact when terminals resize Shells redraw the prompt on SIGWINCH by moving up the rows it occupied at the width they last knew. The terminal reflows live on every resize but tells the PTY the settled size 150 ms later, so a narrower grid had already split the prompt across more rows and the redraw left an orphaned copy of its first row. This affected split-pane drags and plain window resizes. When the settled size reaches the PTY with a column change on the primary screen, the surface now pauses painting (the live-reflowed frame stays on screen), resizes the grid back to the columns the shell last knew, sends the new size, and restores the new grid as soon as the shell's redraw has been written into it, or after 250 ms. The alternate screen, rows-only changes, and the first notification keep the old path. A resize that resumes during that window restores first and keeps reflowing and painting live, so resuming a drag never shows a cleared canvas. Verified in a real client: the prompt appears once after dragging a pane to its minimum and after shrinking the window, and resuming a drag 220 ms after a pause produced no blank frames. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/terminal/ghostty/surface.test.ts | 147 ++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 70 ++++++++- 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 59150ee320ae..b5d7e12f1092 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -145,6 +145,7 @@ describe("GhosttyTerminalSurface visibility", () => { }, ); const snapshot = vi.spyOn(GhosttyTerminalCore.prototype, "snapshot"); + const coreResize = vi.spyOn(GhosttyTerminalCore.prototype, "resize"); const onData = vi.fn<(data: string) => void>(); return { @@ -153,6 +154,7 @@ describe("GhosttyTerminalSurface visibility", () => { paint, requestFrame, snapshot, + coreResize, onData, get renderedSnapshot() { const result = snapshot.mock.results.at(-1); @@ -210,6 +212,151 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + it("restores the PTY layout and suspends paint after a settled narrowing", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + await harness.create({ onResize }); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + onResize.mockClear(); + + harness.mount.clientWidth = 88; + harness.resize(); + harness.snapshot.mockClear(); + harness.paint.mockClear(); + vi.advanceTimersByTime(150); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([ + [10, 6], + [20, 6], + ]); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(10, 6); + expect(harness.snapshot).not.toHaveBeenCalled(); + expect(harness.paint).not.toHaveBeenCalled(); + }); + + it("restores the new grid once on output without notifying twice", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + onResize.mockClear(); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + + surface.write("redrawn"); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(onResize).toHaveBeenCalledOnce(); + expect(harness.snapshot).toHaveBeenCalledOnce(); + }); + + it("restores and repaints after the output fallback timeout", async () => { + const harness = createHarness(); + await harness.create(); + vi.advanceTimersByTime(150); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + + vi.advanceTimersByTime(250); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(harness.snapshot).toHaveBeenCalledOnce(); + }); + + it("notifies an alternate-screen resize without restoring the old grid", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + surface.write("\x1b[?1049h"); + harness.flushFrame(); + harness.coreResize.mockClear(); + onResize.mockClear(); + + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(10, 6); + }); + + it("notifies a rows-only resize without restoring the old grid", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + await harness.create({ onResize }); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + onResize.mockClear(); + + harness.mount.clientHeight = 120; + harness.resize(); + vi.advanceTimersByTime(150); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[20, 7]]); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(20, 7); + }); + + it("resumes live reflow and paint when a resize continues during a pending restore", async () => { + const harness = createHarness(); + const onResize = vi.fn(); + const surface = await harness.create({ onResize }); + vi.advanceTimersByTime(150); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + onResize.mockClear(); + + // Resizing the canvas clears it, so a resumed drag must repaint instead of staying blank. + harness.mount.clientWidth = 128; + harness.resize(); + harness.flushFrame(); + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([ + [10, 6], + [15, 6], + ]); + expect(harness.snapshot).toHaveBeenCalled(); + + vi.advanceTimersByTime(150); + expect(onResize).toHaveBeenCalledOnce(); + expect(onResize).toHaveBeenCalledWith(15, 6); + harness.coreResize.mockClear(); + surface.write("redrawn again"); + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[15, 6]]); + }); + + it("disposes a pending restore without painting or throwing", async () => { + const harness = createHarness(); + const surface = await harness.create(); + vi.advanceTimersByTime(150); + harness.mount.clientWidth = 88; + harness.resize(); + vi.advanceTimersByTime(150); + harness.coreResize.mockClear(); + harness.snapshot.mockClear(); + + expect(() => surface.dispose()).not.toThrow(); + harness.flushFrame(); + + expect(harness.coreResize.mock.calls.map(([cols, rows]) => [cols, rows])).toEqual([[10, 6]]); + expect(harness.snapshot).not.toHaveBeenCalled(); + }); + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { const harness = createHarness(); const surface = await harness.create(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index be62ede4d065..dfde54822726 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -589,6 +589,11 @@ export class GhosttyTerminalSurface { private scrollbarPointerId: number | null = null; private scrollbarPointerOffset = 0; private disposed = false; + private ptyCols: number | null = null; + private ptyRows: number | null = null; + private paintSuspended = false; + private pendingRestore: { cols: number; rows: number } | null = null; + private pendingRestoreTimer: number | null = null; private resizeNotifyTimer: number | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; @@ -764,6 +769,7 @@ export class GhosttyTerminalSurface { write(data: string): void { if (this.disposed) return; this.core.write(data); + this.applyPendingRestore(); this.synchronizeMouseTrackingState(); // Restart the blink cycle from the visible phase so the cursor never sits // invisible through a stream of output or a burst of typing echo. @@ -776,6 +782,7 @@ export class GhosttyTerminalSurface { if (this.disposed) return; this.lastMouseMotionData = ""; this.core.resetAndWrite(data); + this.applyPendingRestore(); this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. @@ -873,6 +880,13 @@ export class GhosttyTerminalSurface { // The DPR transform must be installed even when the target size happens to // equal the canvas default 300x150 backing store, so the first fit always // schedules a canvas configuration. + // Resizing the canvas clears it, so a resize during a pending restore resumes live paint first. + if ( + this.pendingRestore !== null && + (this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight) + ) { + this.applyPendingRestore(); + } if ( this.canvas.width !== pixelWidth || this.canvas.height !== pixelHeight || @@ -893,6 +907,9 @@ export class GhosttyTerminalSurface { if (grid.cols !== this.cols || grid.rows !== this.rows || !this.resizeNotified) { this.cols = grid.cols; this.rows = grid.rows; + // A resize that resumes while the shell redraws must keep reflowing and painting live; + // the next settle restores the shell's layout again. + if (this.pendingRestore !== null) this.applyPendingRestore(); this.core.resize(grid.cols, grid.rows, this.metrics.width, this.metrics.height); this.notifyResize(); this.forceFullRender = true; @@ -916,10 +933,53 @@ export class GhosttyTerminalSurface { if (this.resizeNotifyTimer !== null) window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = window.setTimeout(() => { this.resizeNotifyTimer = null; - if (!this.disposed) this.options.onResize(this.cols, this.rows); + if (this.disposed) return; + const newCols = this.cols; + const newRows = this.rows; + if (newCols !== this.ptyCols && !this.core.isAlternateScreen() && this.ptyCols !== null) { + // Shells redraw assuming their last known width. + this.paintSuspended = true; + this.core.resize(this.ptyCols, this.ptyRows!, this.metrics.width, this.metrics.height); + this.ptyCols = newCols; + this.ptyRows = newRows; + this.options.onResize(newCols, newRows); + this.pendingRestore = { cols: newCols, rows: newRows }; + if (this.pendingRestoreTimer !== null) { + window.clearTimeout(this.pendingRestoreTimer); + } + this.pendingRestoreTimer = window.setTimeout(() => this.applyPendingRestore(), 250); + return; + } + this.ptyCols = this.cols; + this.ptyRows = this.rows; + this.options.onResize(this.cols, this.rows); + if (this.pendingRestore !== null && this.pendingRestoreTimer === null) { + this.pendingRestoreTimer = window.setTimeout(() => this.applyPendingRestore(), 250); + } }, 150); } + private applyPendingRestore(): void { + if (this.pendingRestore === null || this.ptyCols === null || this.ptyRows === null) { + return; + } + if (this.pendingRestoreTimer !== null) { + window.clearTimeout(this.pendingRestoreTimer); + } + this.core.resize( + this.pendingRestore.cols, + this.pendingRestore.rows, + this.metrics.width, + this.metrics.height, + ); + this.pendingRestore = null; + this.pendingRestoreTimer = null; + this.paintSuspended = false; + this.forceFullRender = true; + this.scrollbarDirty = true; + this.requestRender(); + } + focus(): void { if (this.disposed || !this.visible) return; this.input.focus({ preventScroll: true }); @@ -1028,6 +1088,10 @@ export class GhosttyTerminalSurface { this.dprMedia = null; this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); + if (this.pendingRestoreTimer !== null) { + window.clearTimeout(this.pendingRestoreTimer); + this.pendingRestoreTimer = null; + } if (this.resizeNotifyTimer !== null) { window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; @@ -1035,6 +1099,8 @@ export class GhosttyTerminalSurface { // the surface unmounts inside the debounce window. this.options.onResize(this.cols, this.rows); } + if (this.pendingRestore !== null) this.applyPendingRestore(); + this.pendingRestore = null; this.cancelRender(); if (this.compositionSuppressionTimer !== null) { window.clearTimeout(this.compositionSuppressionTimer); @@ -1781,6 +1847,7 @@ export class GhosttyTerminalSurface { } private requestRender(): void { + if (this.paintSuspended) return; if (this.disposed || !this.visible || !this.hasSize || this.frame !== 0) return; this.frame = window.requestAnimationFrame(() => { this.frame = 0; @@ -1800,6 +1867,7 @@ export class GhosttyTerminalSurface { } private renderFrame(): void { + if (this.paintSuspended) return; if (this.disposed || !this.visible) return; if (this.frame !== 0) { window.cancelAnimationFrame(this.frame);