From d1ace4634b229997f20413305cc237566149afc6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 21:03:01 -0700 Subject: [PATCH 1/8] chore: open issue 3386 split window blank pr From cc32549fc2b163a4c87a2fe700177233e8142ffd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:31:27 -0700 Subject: [PATCH 2/8] test: cover split terminal first render --- tests/test_split_flash_and_layout.py | 61 ++++++++++++++++++++++++- tests_v2/test_split_flash_and_layout.py | 61 ++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/tests/test_split_flash_and_layout.py b/tests/test_split_flash_and_layout.py index ca668c390355..e200a8674fde 100644 --- a/tests/test_split_flash_and_layout.py +++ b/tests/test_split_flash_and_layout.py @@ -119,6 +119,64 @@ def _assert_no_transient_detach_or_hide( ) +def _panel_snapshot_retry(c: cmux, panel_id: str, label: str, timeout_s: float = 3.0) -> dict: + start = time.time() + last_err: Exception | None = None + while time.time() - start < timeout_s: + try: + return dict(c.panel_snapshot(panel_id, label=label) or {}) + except Exception as e: + last_err = e + if "Failed to capture panel image" not in str(e): + raise + time.sleep(0.05) + raise cmuxError(f"Timed out waiting for panel_snapshot: panel_id={panel_id} label={label}: {last_err!r}") + + +def _snapshot_ratio(snapshot: dict) -> float: + changed = int(snapshot.get("changed_pixels") or 0) + width = int(snapshot.get("width") or 0) + height = int(snapshot.get("height") or 0) + return float(max(0, changed)) / float(max(1, width * height)) + + +def _assert_split_terminal_renders_output(c: cmux, panel_id: str) -> None: + if not panel_id: + terminal_rows = [row for row in c.surface_health() if row.get("type") == "terminal"] + if len(terminal_rows) < 2: + raise cmuxError(f"Expected split terminal surface, got health={terminal_rows}") + panel_id = terminal_rows[-1]["id"] + + c.panel_snapshot_reset(panel_id) + s0 = _panel_snapshot_retry(c, panel_id, "split_render_baseline0") + time.sleep(0.2) + s1 = _panel_snapshot_retry(c, panel_id, "split_render_baseline1") + + draw_cmd = "i=0; while [ $i -lt 30 ]; do echo CMUX_SPLIT_RENDER_$i; i=$((i+1)); done\n" + c.send_surface(panel_id, draw_cmd) + time.sleep(0.6) + s2 = _panel_snapshot_retry(c, panel_id, "split_render_after") + + dims1 = (int(s1.get("width") or 0), int(s1.get("height") or 0)) + dims2 = (int(s2.get("width") or 0), int(s2.get("height") or 0)) + if dims1[0] <= 0 or dims1[1] <= 0 or dims1 != dims2: + raise cmuxError( + f"panel_snapshot dims differ: {dims1} {dims2}; " + f"paths: {s1.get('path')} {s2.get('path')}" + ) + + noise = _snapshot_ratio(s1) + change = _snapshot_ratio(s2) + threshold = max(0.01, noise * 5.0) + if change <= threshold: + raise cmuxError( + "New split terminal did not render output immediately.\n" + f" noise_ratio={noise:.5f}\n" + f" change_ratio={change:.5f} (threshold={threshold:.5f})\n" + f" snapshots: {s0.get('path')} {s1.get('path')} {s2.get('path')}" + ) + + def main() -> int: with cmux(SOCKET_PATH) as c: # Run on a fresh workspace to avoid state carry-over from restored sessions. @@ -134,7 +192,7 @@ def main() -> int: # Programmatic split should not show EmptyPanelView even briefly. c.reset_empty_panel_count() - c.new_split("right") + new_split_panel_id = c.new_split("right") time.sleep(0.3) flashes = c.empty_panel_count() if flashes != 0: @@ -146,6 +204,7 @@ def main() -> int: if len(panes) < 2: raise cmuxError(f"Expected >= 2 panes after split, got {len(panes)}") _assert_selected_panels_healthy(after) + _assert_split_terminal_renders_output(c, new_split_panel_id) # Drag-to-split from a single-surface pane should also avoid EmptyPanelView flashes. drag_workspace = c.new_workspace() diff --git a/tests_v2/test_split_flash_and_layout.py b/tests_v2/test_split_flash_and_layout.py index 0f48eae3609c..57fdd0fd3a9e 100644 --- a/tests_v2/test_split_flash_and_layout.py +++ b/tests_v2/test_split_flash_and_layout.py @@ -119,6 +119,64 @@ def _assert_no_transient_detach_or_hide( ) +def _panel_snapshot_retry(c: cmux, panel_id: str, label: str, timeout_s: float = 3.0) -> dict: + start = time.time() + last_err: Exception | None = None + while time.time() - start < timeout_s: + try: + return dict(c.panel_snapshot(panel_id, label=label) or {}) + except Exception as e: + last_err = e + if "Failed to capture panel image" not in str(e): + raise + time.sleep(0.05) + raise cmuxError(f"Timed out waiting for panel_snapshot: panel_id={panel_id} label={label}: {last_err!r}") + + +def _snapshot_ratio(snapshot: dict) -> float: + changed = int(snapshot.get("changed_pixels") or 0) + width = int(snapshot.get("width") or 0) + height = int(snapshot.get("height") or 0) + return float(max(0, changed)) / float(max(1, width * height)) + + +def _assert_split_terminal_renders_output(c: cmux, panel_id: str) -> None: + if not panel_id: + terminal_rows = [row for row in c.surface_health() if row.get("type") == "terminal"] + if len(terminal_rows) < 2: + raise cmuxError(f"Expected split terminal surface, got health={terminal_rows}") + panel_id = terminal_rows[-1]["id"] + + c.panel_snapshot_reset(panel_id) + s0 = _panel_snapshot_retry(c, panel_id, "split_render_baseline0") + time.sleep(0.2) + s1 = _panel_snapshot_retry(c, panel_id, "split_render_baseline1") + + draw_cmd = "i=0; while [ $i -lt 30 ]; do echo CMUX_SPLIT_RENDER_$i; i=$((i+1)); done\n" + c.send_surface(panel_id, draw_cmd) + time.sleep(0.6) + s2 = _panel_snapshot_retry(c, panel_id, "split_render_after") + + dims1 = (int(s1.get("width") or 0), int(s1.get("height") or 0)) + dims2 = (int(s2.get("width") or 0), int(s2.get("height") or 0)) + if dims1[0] <= 0 or dims1[1] <= 0 or dims1 != dims2: + raise cmuxError( + f"panel_snapshot dims differ: {dims1} {dims2}; " + f"paths: {s1.get('path')} {s2.get('path')}" + ) + + noise = _snapshot_ratio(s1) + change = _snapshot_ratio(s2) + threshold = max(0.01, noise * 5.0) + if change <= threshold: + raise cmuxError( + "New split terminal did not render output immediately.\n" + f" noise_ratio={noise:.5f}\n" + f" change_ratio={change:.5f} (threshold={threshold:.5f})\n" + f" snapshots: {s0.get('path')} {s1.get('path')} {s2.get('path')}" + ) + + def main() -> int: with cmux(SOCKET_PATH) as c: # Run on a fresh workspace to avoid state carry-over from restored sessions. @@ -134,7 +192,7 @@ def main() -> int: # Programmatic split should not show EmptyPanelView even briefly. c.reset_empty_panel_count() - c.new_split("right") + new_split_panel_id = c.new_split("right") time.sleep(0.3) flashes = c.empty_panel_count() if flashes != 0: @@ -146,6 +204,7 @@ def main() -> int: if len(panes) < 2: raise cmuxError(f"Expected >= 2 panes after split, got {len(panes)}") _assert_selected_panels_healthy(after) + _assert_split_terminal_renders_output(c, new_split_panel_id) # Drag-to-split from a single-surface pane should also avoid EmptyPanelView flashes. drag_workspace = c.new_workspace() From 92ed4a6eb66213476115a9874221fa5dfbc900ec Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:32:52 -0700 Subject: [PATCH 3/8] fix: reconcile new split terminal rendering --- Sources/Workspace.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index aa31e382af7b..7672224acb98 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10014,6 +10014,11 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView: previousHostedView ) } + beginEventDrivenLayoutFollowUp( + reason: "terminal.split", + terminalFocusPanelId: focus ? newPanel.id : nil, + includeGeometry: true + ) #if DEBUG dlog( "split.timing workspace=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + @@ -12727,6 +12732,11 @@ final class Workspace: Identifiable, ObservableObject { bonsplitController.selectTab(newTab.id) newPanel.focus() + beginEventDrivenLayoutFollowUp( + reason: "terminal.splitPaneWithNewTerminal", + terminalFocusPanelId: newPanel.id, + includeGeometry: true + ) return newPanel } From 96e0f01f60806cd8970f40e2064231bec1a59bcb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 02:46:18 -0700 Subject: [PATCH 4/8] fix: centralize terminal split render follow-up --- Sources/Workspace.swift | 112 ++++++++++++++++++++---- tests/split_render_helpers.py | 77 ++++++++++++++++ tests/test_split_flash_and_layout.py | 66 ++------------ tests_v2/test_split_flash_and_layout.py | 66 ++------------ 4 files changed, 184 insertions(+), 137 deletions(-) create mode 100644 tests/split_render_helpers.py diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 7672224acb98..0207ada055fc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9850,6 +9850,68 @@ final class Workspace: Identifiable, ObservableObject { return nil } + private func beginTerminalSplitPaneLifecycle( + newPaneId: PaneID, + sourcePaneId: PaneID, + orientation: SplitOrientation, + panelId: UUID, + origin: String, + focused: Bool, + layoutReason: String, + terminalFocusPanelId: UUID? + ) { + publishCmuxSplitCreated( + newPaneId, + sourcePaneId: sourcePaneId, + orientation: orientation, + surfaceId: panelId, + kind: "terminal", + origin: origin, + focused: focused + ) + // Brand-new terminal split panes must enter the shared layout follow-up here so + // their portal attachment, Ghostty geometry, and optional focus converge together. + beginEventDrivenLayoutFollowUp( + reason: layoutReason, + terminalFocusPanelId: terminalFocusPanelId, + includeGeometry: true + ) + } + + private func commitNewTerminalSplitPane( + sourcePaneId: PaneID, + orientation: SplitOrientation, + newTab: Bonsplit.Tab, + panelId: UUID, + insertFirst: Bool, + initialDividerPosition: CGFloat? = nil, + origin: String, + focused: Bool, + layoutReason: String, + terminalFocusPanelId: UUID? + ) -> PaneID? { + guard let newPaneId = bonsplitController.splitPane( + sourcePaneId, + orientation: orientation, + withTab: newTab, + insertFirst: insertFirst + ) else { + return nil + } + applyInitialSplitDividerPosition(initialDividerPosition, sourcePaneId: sourcePaneId, newPaneId: newPaneId) + beginTerminalSplitPaneLifecycle( + newPaneId: newPaneId, + sourcePaneId: sourcePaneId, + orientation: orientation, + panelId: panelId, + origin: origin, + focused: focused, + layoutReason: layoutReason, + terminalFocusPanelId: terminalFocusPanelId + ) + return newPaneId + } + /// Create a new split with a terminal panel @discardableResult func newTerminalSplit( @@ -9976,7 +10038,18 @@ final class Workspace: Identifiable, ObservableObject { // Create the split with the new tab already present in the new pane. isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else { + guard let newPaneId = commitNewTerminalSplitPane( + sourcePaneId: paneId, + orientation: orientation, + newTab: newTab, + panelId: newPanel.id, + insertFirst: insertFirst, + initialDividerPosition: initialDividerPosition, + origin: "terminal_split", + focused: focus, + layoutReason: "terminal.split", + terminalFocusPanelId: focus ? newPanel.id : nil + ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) @@ -9986,8 +10059,6 @@ final class Workspace: Identifiable, ObservableObject { terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } - applyInitialSplitDividerPosition(initialDividerPosition, sourcePaneId: paneId, newPaneId: newPaneId) - publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "terminal_split", focused: focus) #if DEBUG cmuxDebugLog("split.created pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation)") @@ -10014,11 +10085,6 @@ final class Workspace: Identifiable, ObservableObject { previousHostedView: previousHostedView ) } - beginEventDrivenLayoutFollowUp( - reason: "terminal.split", - terminalFocusPanelId: focus ? newPanel.id : nil, - includeGeometry: true - ) #if DEBUG dlog( "split.timing workspace=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " + @@ -12721,22 +12787,26 @@ final class Workspace: Identifiable, ObservableObject { isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard let newPaneId = bonsplitController.splitPane(paneId, orientation: orientation, withTab: newTab, insertFirst: insertFirst) else { + guard commitNewTerminalSplitPane( + sourcePaneId: paneId, + orientation: orientation, + newTab: newTab, + panelId: newPanel.id, + insertFirst: insertFirst, + origin: "terminal_split", + focused: true, + layoutReason: "terminal.splitPaneWithNewTerminal", + terminalFocusPanelId: newPanel.id + ) != nil else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } - publishCmuxSplitCreated(newPaneId, sourcePaneId: paneId, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "terminal_split", focused: true) bonsplitController.selectTab(newTab.id) newPanel.focus() - beginEventDrivenLayoutFollowUp( - reason: "terminal.splitPaneWithNewTerminal", - terminalFocusPanelId: newPanel.id, - includeGeometry: true - ) return newPanel } @@ -13813,7 +13883,16 @@ extension Workspace: BonsplitDelegate { surfaceIdToPanelId[newTabId] = newPanel.id normalizePinnedTabs(in: newPane) - publishCmuxSplitCreated(newPane, sourcePaneId: originalPane, orientation: orientation, surfaceId: newPanel.id, kind: "terminal", origin: "ui_split", focused: true) + beginTerminalSplitPaneLifecycle( + newPaneId: newPane, + sourcePaneId: originalPane, + orientation: orientation, + panelId: newPanel.id, + origin: "ui_split", + focused: true, + layoutReason: "terminal.uiSplit", + terminalFocusPanelId: newPanel.id + ) #if DEBUG cmuxDebugLog( "split.didSplit.autoCreate.done pane=\(newPane.id.uuidString.prefix(5)) " + @@ -13828,7 +13907,6 @@ extension Workspace: BonsplitDelegate { if self.bonsplitController.focusedPaneId == newPane { self.bonsplitController.selectTab(newTabId) } - self.scheduleTerminalGeometryReconcile() self.scheduleFocusReconcile() } } diff --git a/tests/split_render_helpers.py b/tests/split_render_helpers.py new file mode 100644 index 000000000000..59ef0b1a0d89 --- /dev/null +++ b/tests/split_render_helpers.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Shared runtime assertions for terminal split rendering tests.""" + +from __future__ import annotations + +import time +from typing import Any + +from cmux import cmuxError + + +def _panel_snapshot_retry(c: Any, panel_id: str, label: str, timeout_s: float = 3.0) -> dict: + start = time.time() + last_err: Exception | None = None + while time.time() - start < timeout_s: + try: + return dict(c.panel_snapshot(panel_id, label=label) or {}) + except Exception as e: + last_err = e + if "Failed to capture panel image" not in str(e): + raise + time.sleep(0.05) + raise cmuxError(f"Timed out waiting for panel_snapshot: panel_id={panel_id} label={label}: {last_err!r}") + + +def _snapshot_ratio(snapshot: dict) -> float: + changed = int(snapshot.get("changed_pixels") or 0) + width = int(snapshot.get("width") or 0) + height = int(snapshot.get("height") or 0) + return float(max(0, changed)) / float(max(1, width * height)) + + +def assert_split_terminal_renders_output(c: Any, panel_id: str) -> None: + if not panel_id: + terminal_rows = [row for row in c.surface_health() if row.get("type") == "terminal"] + if len(terminal_rows) < 2: + raise cmuxError(f"Expected split terminal surface, got health={terminal_rows}") + panel_id = terminal_rows[-1]["id"] + + c.panel_snapshot_reset(panel_id) + _panel_snapshot_retry(c, panel_id, "split_render_noise_baseline") + time.sleep(0.2) + noise_snapshot = _panel_snapshot_retry(c, panel_id, "split_render_noise_sample") + noise = _snapshot_ratio(noise_snapshot) + + c.panel_snapshot_reset(panel_id) + pre_send = _panel_snapshot_retry(c, panel_id, "split_render_pre_send") + pre_dims = (int(pre_send.get("width") or 0), int(pre_send.get("height") or 0)) + if pre_dims[0] <= 0 or pre_dims[1] <= 0: + raise cmuxError(f"panel_snapshot has invalid dims before send: {pre_dims}; path={pre_send.get('path')}") + + draw_cmd = "i=0; while [ $i -lt 30 ]; do echo CMUX_SPLIT_RENDER_$i; i=$((i+1)); done\n" + c.send_surface(panel_id, draw_cmd) + + threshold = max(0.01, noise * 5.0) + deadline = time.time() + 3.0 + last_snapshot = pre_send + last_change = 0.0 + while time.time() < deadline: + last_snapshot = _panel_snapshot_retry(c, panel_id, "split_render_after", timeout_s=0.5) + dims = (int(last_snapshot.get("width") or 0), int(last_snapshot.get("height") or 0)) + if dims[0] <= 0 or dims[1] <= 0 or dims != pre_dims: + raise cmuxError( + f"panel_snapshot dims differ: {pre_dims} {dims}; " + f"paths: {pre_send.get('path')} {last_snapshot.get('path')}" + ) + last_change = _snapshot_ratio(last_snapshot) + if last_change > threshold: + return + time.sleep(0.1) + + raise cmuxError( + "New split terminal did not render output immediately.\n" + f" noise_ratio={noise:.5f}\n" + f" change_ratio={last_change:.5f} (threshold={threshold:.5f})\n" + f" snapshots: {pre_send.get('path')} {last_snapshot.get('path')}" + ) diff --git a/tests/test_split_flash_and_layout.py b/tests/test_split_flash_and_layout.py index e200a8674fde..d330f28a7ee6 100644 --- a/tests/test_split_flash_and_layout.py +++ b/tests/test_split_flash_and_layout.py @@ -12,8 +12,12 @@ import time from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent)) +TEST_DIR = Path(__file__).parent +REPO_TESTS_DIR = TEST_DIR.parent / "tests" +sys.path.insert(0, str(REPO_TESTS_DIR)) +sys.path.insert(0, str(TEST_DIR)) from cmux import cmux, cmuxError +from split_render_helpers import assert_split_terminal_renders_output SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") @@ -119,64 +123,6 @@ def _assert_no_transient_detach_or_hide( ) -def _panel_snapshot_retry(c: cmux, panel_id: str, label: str, timeout_s: float = 3.0) -> dict: - start = time.time() - last_err: Exception | None = None - while time.time() - start < timeout_s: - try: - return dict(c.panel_snapshot(panel_id, label=label) or {}) - except Exception as e: - last_err = e - if "Failed to capture panel image" not in str(e): - raise - time.sleep(0.05) - raise cmuxError(f"Timed out waiting for panel_snapshot: panel_id={panel_id} label={label}: {last_err!r}") - - -def _snapshot_ratio(snapshot: dict) -> float: - changed = int(snapshot.get("changed_pixels") or 0) - width = int(snapshot.get("width") or 0) - height = int(snapshot.get("height") or 0) - return float(max(0, changed)) / float(max(1, width * height)) - - -def _assert_split_terminal_renders_output(c: cmux, panel_id: str) -> None: - if not panel_id: - terminal_rows = [row for row in c.surface_health() if row.get("type") == "terminal"] - if len(terminal_rows) < 2: - raise cmuxError(f"Expected split terminal surface, got health={terminal_rows}") - panel_id = terminal_rows[-1]["id"] - - c.panel_snapshot_reset(panel_id) - s0 = _panel_snapshot_retry(c, panel_id, "split_render_baseline0") - time.sleep(0.2) - s1 = _panel_snapshot_retry(c, panel_id, "split_render_baseline1") - - draw_cmd = "i=0; while [ $i -lt 30 ]; do echo CMUX_SPLIT_RENDER_$i; i=$((i+1)); done\n" - c.send_surface(panel_id, draw_cmd) - time.sleep(0.6) - s2 = _panel_snapshot_retry(c, panel_id, "split_render_after") - - dims1 = (int(s1.get("width") or 0), int(s1.get("height") or 0)) - dims2 = (int(s2.get("width") or 0), int(s2.get("height") or 0)) - if dims1[0] <= 0 or dims1[1] <= 0 or dims1 != dims2: - raise cmuxError( - f"panel_snapshot dims differ: {dims1} {dims2}; " - f"paths: {s1.get('path')} {s2.get('path')}" - ) - - noise = _snapshot_ratio(s1) - change = _snapshot_ratio(s2) - threshold = max(0.01, noise * 5.0) - if change <= threshold: - raise cmuxError( - "New split terminal did not render output immediately.\n" - f" noise_ratio={noise:.5f}\n" - f" change_ratio={change:.5f} (threshold={threshold:.5f})\n" - f" snapshots: {s0.get('path')} {s1.get('path')} {s2.get('path')}" - ) - - def main() -> int: with cmux(SOCKET_PATH) as c: # Run on a fresh workspace to avoid state carry-over from restored sessions. @@ -204,7 +150,7 @@ def main() -> int: if len(panes) < 2: raise cmuxError(f"Expected >= 2 panes after split, got {len(panes)}") _assert_selected_panels_healthy(after) - _assert_split_terminal_renders_output(c, new_split_panel_id) + assert_split_terminal_renders_output(c, new_split_panel_id) # Drag-to-split from a single-surface pane should also avoid EmptyPanelView flashes. drag_workspace = c.new_workspace() diff --git a/tests_v2/test_split_flash_and_layout.py b/tests_v2/test_split_flash_and_layout.py index 57fdd0fd3a9e..6c7771019017 100644 --- a/tests_v2/test_split_flash_and_layout.py +++ b/tests_v2/test_split_flash_and_layout.py @@ -12,8 +12,12 @@ import time from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent)) +TEST_DIR = Path(__file__).parent +REPO_TESTS_DIR = TEST_DIR.parent / "tests" +sys.path.insert(0, str(REPO_TESTS_DIR)) +sys.path.insert(0, str(TEST_DIR)) from cmux import cmux, cmuxError +from split_render_helpers import assert_split_terminal_renders_output SOCKET_PATH = os.environ.get("CMUX_SOCKET_PATH", "/tmp/cmux-debug.sock") @@ -119,64 +123,6 @@ def _assert_no_transient_detach_or_hide( ) -def _panel_snapshot_retry(c: cmux, panel_id: str, label: str, timeout_s: float = 3.0) -> dict: - start = time.time() - last_err: Exception | None = None - while time.time() - start < timeout_s: - try: - return dict(c.panel_snapshot(panel_id, label=label) or {}) - except Exception as e: - last_err = e - if "Failed to capture panel image" not in str(e): - raise - time.sleep(0.05) - raise cmuxError(f"Timed out waiting for panel_snapshot: panel_id={panel_id} label={label}: {last_err!r}") - - -def _snapshot_ratio(snapshot: dict) -> float: - changed = int(snapshot.get("changed_pixels") or 0) - width = int(snapshot.get("width") or 0) - height = int(snapshot.get("height") or 0) - return float(max(0, changed)) / float(max(1, width * height)) - - -def _assert_split_terminal_renders_output(c: cmux, panel_id: str) -> None: - if not panel_id: - terminal_rows = [row for row in c.surface_health() if row.get("type") == "terminal"] - if len(terminal_rows) < 2: - raise cmuxError(f"Expected split terminal surface, got health={terminal_rows}") - panel_id = terminal_rows[-1]["id"] - - c.panel_snapshot_reset(panel_id) - s0 = _panel_snapshot_retry(c, panel_id, "split_render_baseline0") - time.sleep(0.2) - s1 = _panel_snapshot_retry(c, panel_id, "split_render_baseline1") - - draw_cmd = "i=0; while [ $i -lt 30 ]; do echo CMUX_SPLIT_RENDER_$i; i=$((i+1)); done\n" - c.send_surface(panel_id, draw_cmd) - time.sleep(0.6) - s2 = _panel_snapshot_retry(c, panel_id, "split_render_after") - - dims1 = (int(s1.get("width") or 0), int(s1.get("height") or 0)) - dims2 = (int(s2.get("width") or 0), int(s2.get("height") or 0)) - if dims1[0] <= 0 or dims1[1] <= 0 or dims1 != dims2: - raise cmuxError( - f"panel_snapshot dims differ: {dims1} {dims2}; " - f"paths: {s1.get('path')} {s2.get('path')}" - ) - - noise = _snapshot_ratio(s1) - change = _snapshot_ratio(s2) - threshold = max(0.01, noise * 5.0) - if change <= threshold: - raise cmuxError( - "New split terminal did not render output immediately.\n" - f" noise_ratio={noise:.5f}\n" - f" change_ratio={change:.5f} (threshold={threshold:.5f})\n" - f" snapshots: {s0.get('path')} {s1.get('path')} {s2.get('path')}" - ) - - def main() -> int: with cmux(SOCKET_PATH) as c: # Run on a fresh workspace to avoid state carry-over from restored sessions. @@ -204,7 +150,7 @@ def main() -> int: if len(panes) < 2: raise cmuxError(f"Expected >= 2 panes after split, got {len(panes)}") _assert_selected_panels_healthy(after) - _assert_split_terminal_renders_output(c, new_split_panel_id) + assert_split_terminal_renders_output(c, new_split_panel_id) # Drag-to-split from a single-surface pane should also avoid EmptyPanelView flashes. drag_workspace = c.new_workspace() From 1faf4e6572bdce11958e63e8d824b85b948beeaa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 03:12:38 -0700 Subject: [PATCH 5/8] fix: remove unused split pane binding --- Sources/Workspace.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0207ada055fc..0f052647f5c3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10038,7 +10038,7 @@ final class Workspace: Identifiable, ObservableObject { // Create the split with the new tab already present in the new pane. isProgrammaticSplit = true defer { isProgrammaticSplit = false } - guard let newPaneId = commitNewTerminalSplitPane( + guard commitNewTerminalSplitPane( sourcePaneId: paneId, orientation: orientation, newTab: newTab, @@ -10049,7 +10049,7 @@ final class Workspace: Identifiable, ObservableObject { focused: focus, layoutReason: "terminal.split", terminalFocusPanelId: focus ? newPanel.id : nil - ) else { + ) != nil else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) From fdad602d95a2fa57b1b05dc8c8a2f4a2bc8ada1b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:15:10 -0700 Subject: [PATCH 6/8] test: document split render helpers --- tests/split_render_helpers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/split_render_helpers.py b/tests/split_render_helpers.py index 59ef0b1a0d89..2b23fc480f1f 100644 --- a/tests/split_render_helpers.py +++ b/tests/split_render_helpers.py @@ -10,6 +10,7 @@ def _panel_snapshot_retry(c: Any, panel_id: str, label: str, timeout_s: float = 3.0) -> dict: + """Capture a panel snapshot, retrying through transient attachment churn.""" start = time.time() last_err: Exception | None = None while time.time() - start < timeout_s: @@ -24,6 +25,7 @@ def _panel_snapshot_retry(c: Any, panel_id: str, label: str, timeout_s: float = def _snapshot_ratio(snapshot: dict) -> float: + """Return the changed-pixel ratio for a snapshot diff payload.""" changed = int(snapshot.get("changed_pixels") or 0) width = int(snapshot.get("width") or 0) height = int(snapshot.get("height") or 0) @@ -31,6 +33,7 @@ def _snapshot_ratio(snapshot: dict) -> float: def assert_split_terminal_renders_output(c: Any, panel_id: str) -> None: + """Assert that a newly split terminal panel accepts input and visibly renders output.""" if not panel_id: terminal_rows = [row for row in c.surface_health() if row.get("type") == "terminal"] if len(terminal_rows) < 2: From 34c09322b5d55c3420989b5d8aa9132c5a5796e7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 13:22:59 -0700 Subject: [PATCH 7/8] fix: cover split placeholder terminal rendering --- ...inalController+MoveTabToNewWorkspace.swift | 10 +---- Sources/Workspace.swift | 41 ++++++++++++++++++- tests/test_split_flash_and_layout.py | 18 ++++++++ tests_v2/test_split_flash_and_layout.py | 18 ++++++++ 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/Sources/TerminalController+MoveTabToNewWorkspace.swift b/Sources/TerminalController+MoveTabToNewWorkspace.swift index d28e21bf9ab4..4d0f0c110b04 100644 --- a/Sources/TerminalController+MoveTabToNewWorkspace.swift +++ b/Sources/TerminalController+MoveTabToNewWorkspace.swift @@ -11,7 +11,6 @@ private enum SurfaceSplitOffMessage { static let surfaceNotFoundInWorkspace = String(localized: "socket.surfaceSplitOff.error.surfaceNotFoundInWorkspace", defaultValue: "Surface not found in workspace") static let surfaceNotFoundInWindow = String(localized: "socket.surfaceSplitOff.error.surfaceNotFoundInWindow", defaultValue: "Surface not found in window") static let sourcePaneNotFound = String(localized: "socket.surfaceSplitOff.error.sourcePaneNotFound", defaultValue: "Source pane not found") - static let wouldEmptySourcePane = String(localized: "socket.surfaceSplitOff.error.wouldEmptySourcePane", defaultValue: "splitting off would leave the source pane empty") static let splitPaneFailed = String(localized: "socket.surfaceSplitOff.error.splitPaneFailed", defaultValue: "Failed to split pane") static let moveSurfaceFailed = String(localized: "socket.surfaceSplitOff.error.moveSurfaceFailed", defaultValue: "Failed to move surface") } @@ -170,17 +169,10 @@ extension TerminalController { result = .err(code: "not_found", message: SurfaceSplitOffMessage.surfaceNotFound, data: ["surface_id": surfaceId.uuidString]) return } - guard let sourcePane = ws.paneId(forPanelId: surfaceId) else { + guard ws.paneId(forPanelId: surfaceId) != nil else { result = .err(code: "not_found", message: SurfaceSplitOffMessage.sourcePaneNotFound, data: ["surface_id": surfaceId.uuidString]) return } - guard ws.bonsplitController.tabs(inPane: sourcePane).count > 1 else { - result = .err(code: "invalid_state", message: SurfaceSplitOffMessage.wouldEmptySourcePane, data: [ - "surface_id": surfaceId.uuidString, - "pane_id": sourcePane.id.uuidString - ]) - return - } let previousFocusedPanelId = ws.focusedPanelId guard let newPaneId = ws.bonsplitController.splitPane( orientation: orientation, diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 0f052647f5c3..43337e46d8ec 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -9850,6 +9850,9 @@ final class Workspace: Identifiable, ObservableObject { return nil } + // Single terminal split lifecycle entry point: every TerminalPanel that enters a + // newly assigned split pane must publish its model event and start the layout + // follow-up here so portal attachment, Ghostty geometry, and focus converge together. private func beginTerminalSplitPaneLifecycle( newPaneId: PaneID, sourcePaneId: PaneID, @@ -9878,6 +9881,28 @@ final class Workspace: Identifiable, ObservableObject { ) } + private func beginTerminalSplitSurfaceLifecycle( + panelId: UUID, + paneId: PaneID, + origin: String, + focused: Bool, + layoutReason: String, + terminalFocusPanelId: UUID? + ) { + publishCmuxSurfaceCreated( + panelId, + paneId: paneId, + kind: "terminal", + origin: origin, + focused: focused + ) + beginEventDrivenLayoutFollowUp( + reason: layoutReason, + terminalFocusPanelId: terminalFocusPanelId, + includeGeometry: true + ) + } + private func commitNewTerminalSplitPane( sourcePaneId: PaneID, orientation: SplitOrientation, @@ -13812,7 +13837,14 @@ extension Workspace: BonsplitDelegate { isLoading: false, isPinned: false ) - publishCmuxSurfaceCreated(replacementPanel.id, paneId: originalPane, kind: "terminal", origin: "placeholder_repair", focused: false) + beginTerminalSplitSurfaceLifecycle( + panelId: replacementPanel.id, + paneId: originalPane, + origin: "placeholder_repair", + focused: false, + layoutReason: "terminal.placeholderRepair", + terminalFocusPanelId: nil + ) for extraPlaceholder in placeholderTabs.dropFirst() { bonsplitController.closeTab(extraPlaceholder.id) @@ -13824,7 +13856,12 @@ extension Workspace: BonsplitDelegate { "fallback=createTerminalAndDropPlaceholders" ) #endif - _ = newTerminalSurface(inPane: originalPane, focus: false) + if newTerminalSurface(inPane: originalPane, focus: false) != nil { + beginEventDrivenLayoutFollowUp( + reason: "terminal.placeholderRepairFallback", + includeGeometry: true + ) + } for tab in controller.tabs(inPane: originalPane) { if panelIdFromSurfaceId(tab.id) == nil { bonsplitController.closeTab(tab.id) diff --git a/tests/test_split_flash_and_layout.py b/tests/test_split_flash_and_layout.py index d330f28a7ee6..54dcf1b442ba 100644 --- a/tests/test_split_flash_and_layout.py +++ b/tests/test_split_flash_and_layout.py @@ -123,6 +123,14 @@ def _assert_no_transient_detach_or_hide( ) +def _terminal_panel_ids(c: cmux) -> set[str]: + return { + str(row.get("id")) + for row in c.surface_health() + if row.get("type") == "terminal" and row.get("id") + } + + def main() -> int: with cmux(SOCKET_PATH) as c: # Run on a fresh workspace to avoid state carry-over from restored sessions. @@ -165,6 +173,7 @@ def main() -> int: if not drag_panel_id: raise cmuxError("drag split setup selected panel has no panelId") drag_panes_before = len(drag_before.get("layout", {}).get("panes") or []) + drag_terminals_before = _terminal_panel_ids(c) c.reset_empty_panel_count() response = c._send_command(f"drag_surface_to_split {drag_panel_id} right") @@ -183,6 +192,15 @@ def main() -> int: f"Expected drag split to add a pane: before={drag_panes_before} after={drag_panes_after}" ) _assert_selected_panels_healthy(drag_after) + drag_terminals_after = _terminal_panel_ids(c) + replacement_terminal_ids = drag_terminals_after - drag_terminals_before + if len(replacement_terminal_ids) != 1: + raise cmuxError( + f"Expected drag split placeholder repair to create one terminal: " + f"before={sorted(drag_terminals_before)} after={sorted(drag_terminals_after)}" + ) + assert_split_terminal_renders_output(c, drag_panel_id) + assert_split_terminal_renders_output(c, next(iter(replacement_terminal_ids))) # Browser split should also avoid EmptyPanelView flashes. c.reset_empty_panel_count() diff --git a/tests_v2/test_split_flash_and_layout.py b/tests_v2/test_split_flash_and_layout.py index 6c7771019017..0a5a1733068f 100644 --- a/tests_v2/test_split_flash_and_layout.py +++ b/tests_v2/test_split_flash_and_layout.py @@ -123,6 +123,14 @@ def _assert_no_transient_detach_or_hide( ) +def _terminal_panel_ids(c: cmux) -> set[str]: + return { + str(row.get("id")) + for row in c.surface_health() + if row.get("type") == "terminal" and row.get("id") + } + + def main() -> int: with cmux(SOCKET_PATH) as c: # Run on a fresh workspace to avoid state carry-over from restored sessions. @@ -165,6 +173,7 @@ def main() -> int: if not drag_panel_id: raise cmuxError("drag split setup selected panel has no panelId") drag_panes_before = len(drag_before.get("layout", {}).get("panes") or []) + drag_terminals_before = _terminal_panel_ids(c) c.reset_empty_panel_count() c.drag_surface_to_split(drag_panel_id, "right") @@ -181,6 +190,15 @@ def main() -> int: f"Expected drag split to add a pane: before={drag_panes_before} after={drag_panes_after}" ) _assert_selected_panels_healthy(drag_after) + drag_terminals_after = _terminal_panel_ids(c) + replacement_terminal_ids = drag_terminals_after - drag_terminals_before + if len(replacement_terminal_ids) != 1: + raise cmuxError( + f"Expected drag split placeholder repair to create one terminal: " + f"before={sorted(drag_terminals_before)} after={sorted(drag_terminals_after)}" + ) + assert_split_terminal_renders_output(c, drag_panel_id) + assert_split_terminal_renders_output(c, next(iter(replacement_terminal_ids))) # Browser split should also avoid EmptyPanelView flashes. c.reset_empty_panel_count() From a07bc83c87e96d2cde06520f5fe9d82e28fd0369 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 17:35:51 -0700 Subject: [PATCH 8/8] fix: preserve split off source pane invariant --- GhosttyTabs.xcodeproj/project.pbxproj | 4 + ...inalController+MoveTabToNewWorkspace.swift | 10 ++- ...rminalControllerSurfaceSplitOffTests.swift | 89 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 cmuxTests/TerminalControllerSurfaceSplitOffTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 17546bed9fe9..e54819d3379d 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -36,6 +36,7 @@ C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */; }; C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; }; C0DE31390000000000000103 /* FilePreviewReviewFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000104 /* FilePreviewReviewFeedbackTests.swift */; }; + C33860010000000000000001 /* TerminalControllerSurfaceSplitOffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C33860010000000000000002 /* TerminalControllerSurfaceSplitOffTests.swift */; }; C0DEF0A10000000000000001 /* CmuxConfigUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A10000000000000002 /* CmuxConfigUI.swift */; }; C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; }; C0DEF0B10000000000000001 /* JSONCParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0B10000000000000002 /* JSONCParser.swift */; }; @@ -538,6 +539,7 @@ 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthEnvironment.swift; sourceTree = ""; }; C3677001000000000000002 /* CmuxSSHURLRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxSSHURLRequestTests.swift; sourceTree = ""; }; 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; + C33860010000000000000002 /* TerminalControllerSurfaceSplitOffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSurfaceSplitOffTests.swift; sourceTree = ""; }; 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = ""; }; D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropPlannerTests.swift; sourceTree = ""; }; D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalViewVisibilityPolicyTests.swift; sourceTree = ""; }; @@ -1462,6 +1464,7 @@ D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, C3677001000000000000002 /* CmuxSSHURLRequestTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, + C33860010000000000000002 /* TerminalControllerSurfaceSplitOffTests.swift */, 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */, E7E000000000000000000004 /* CmuxEventBusTests.swift */, A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */, @@ -2142,6 +2145,7 @@ D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, C3677001000000000000001 /* CmuxSSHURLRequestTests.swift in Sources */, 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, + C33860010000000000000001 /* TerminalControllerSurfaceSplitOffTests.swift in Sources */, 9C1BEA3D2E6F49709A71C020 /* TerminalControllerSocketWriteTests.swift in Sources */, E7E000000000000000000003 /* CmuxEventBusTests.swift in Sources */, A5A5A503A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift in Sources */, diff --git a/Sources/TerminalController+MoveTabToNewWorkspace.swift b/Sources/TerminalController+MoveTabToNewWorkspace.swift index 4d0f0c110b04..d28e21bf9ab4 100644 --- a/Sources/TerminalController+MoveTabToNewWorkspace.swift +++ b/Sources/TerminalController+MoveTabToNewWorkspace.swift @@ -11,6 +11,7 @@ private enum SurfaceSplitOffMessage { static let surfaceNotFoundInWorkspace = String(localized: "socket.surfaceSplitOff.error.surfaceNotFoundInWorkspace", defaultValue: "Surface not found in workspace") static let surfaceNotFoundInWindow = String(localized: "socket.surfaceSplitOff.error.surfaceNotFoundInWindow", defaultValue: "Surface not found in window") static let sourcePaneNotFound = String(localized: "socket.surfaceSplitOff.error.sourcePaneNotFound", defaultValue: "Source pane not found") + static let wouldEmptySourcePane = String(localized: "socket.surfaceSplitOff.error.wouldEmptySourcePane", defaultValue: "splitting off would leave the source pane empty") static let splitPaneFailed = String(localized: "socket.surfaceSplitOff.error.splitPaneFailed", defaultValue: "Failed to split pane") static let moveSurfaceFailed = String(localized: "socket.surfaceSplitOff.error.moveSurfaceFailed", defaultValue: "Failed to move surface") } @@ -169,10 +170,17 @@ extension TerminalController { result = .err(code: "not_found", message: SurfaceSplitOffMessage.surfaceNotFound, data: ["surface_id": surfaceId.uuidString]) return } - guard ws.paneId(forPanelId: surfaceId) != nil else { + guard let sourcePane = ws.paneId(forPanelId: surfaceId) else { result = .err(code: "not_found", message: SurfaceSplitOffMessage.sourcePaneNotFound, data: ["surface_id": surfaceId.uuidString]) return } + guard ws.bonsplitController.tabs(inPane: sourcePane).count > 1 else { + result = .err(code: "invalid_state", message: SurfaceSplitOffMessage.wouldEmptySourcePane, data: [ + "surface_id": surfaceId.uuidString, + "pane_id": sourcePane.id.uuidString + ]) + return + } let previousFocusedPanelId = ws.focusedPanelId guard let newPaneId = ws.bonsplitController.splitPane( orientation: orientation, diff --git a/cmuxTests/TerminalControllerSurfaceSplitOffTests.swift b/cmuxTests/TerminalControllerSurfaceSplitOffTests.swift new file mode 100644 index 000000000000..14859015f0eb --- /dev/null +++ b/cmuxTests/TerminalControllerSurfaceSplitOffTests.swift @@ -0,0 +1,89 @@ +import XCTest +import AppKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +final class TerminalControllerSurfaceSplitOffTests: XCTestCase { + func testSurfaceSplitOffRejectsOnlyTabSourcePane() throws { + _ = NSApplication.shared + let previousAppDelegate = AppDelegate.shared + let app = AppDelegate() + defer { AppDelegate.shared = previousAppDelegate } + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState(), + fileExplorerState: FileExplorerState() + ) + TerminalController.shared.setActiveTabManager(manager) + defer { + TerminalController.shared.setActiveTabManager(nil) + app.unregisterMainWindowContextForTesting(windowId: windowId) + window.orderOut(nil) + } + + let workspace = try XCTUnwrap(manager.selectedWorkspace) + let terminalPanel = try XCTUnwrap(workspace.focusedTerminalPanel) + let sourcePane = try XCTUnwrap(workspace.paneId(forPanelId: terminalPanel.id)) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: sourcePane).count, 1) + XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, 1) + + let envelope = try v2Envelope( + method: "surface.split_off", + params: [ + "surface_id": terminalPanel.id.uuidString, + "direction": "right", + "focus": false + ] + ) + + XCTAssertEqual(envelope["ok"] as? Bool, false) + let error = try XCTUnwrap(envelope["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "invalid_state") + let data = try XCTUnwrap(error["data"] as? [String: Any]) + XCTAssertEqual(data["surface_id"] as? String, terminalPanel.id.uuidString) + XCTAssertEqual(data["pane_id"] as? String, sourcePane.id.uuidString) + XCTAssertEqual(workspace.bonsplitController.tabs(inPane: sourcePane).count, 1) + XCTAssertEqual(workspace.bonsplitController.allPaneIds.count, 1) + } + + private func makeMainWindow(id: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") + return window + } + + private func v2Envelope( + method: String, + params: [String: Any], + file: StaticString = #filePath, + line: UInt = #line + ) throws -> [String: Any] { + let request: [String: Any] = [ + "id": method, + "method": method, + "params": params + ] + let requestData = try JSONSerialization.data(withJSONObject: request) + let requestLine = try XCTUnwrap(String(data: requestData, encoding: .utf8), file: file, line: line) + let response = TerminalController.shared.handleSocketLine(requestLine) + let responseData = try XCTUnwrap(response.data(using: .utf8), file: file, line: line) + return try XCTUnwrap(JSONSerialization.jsonObject(with: responseData) as? [String: Any], file: file, line: line) + } +}