diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 27371b3a53f6..d911c5f075dc 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10107,6 +10107,93 @@ 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, + 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 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, + 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( @@ -10233,7 +10320,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 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 + ) != nil else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) @@ -10243,8 +10341,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)") @@ -13283,7 +13379,17 @@ 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) @@ -13293,7 +13399,6 @@ final class Workspace: Identifiable, ObservableObject { 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() @@ -14406,7 +14511,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) @@ -14418,7 +14530,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) @@ -14477,7 +14594,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)) " + @@ -14492,7 +14618,6 @@ extension Workspace: BonsplitDelegate { if self.bonsplitController.focusedPaneId == newPane { self.bonsplitController.selectTab(newTabId) } - self.scheduleTerminalGeometryReconcile() self.scheduleFocusReconcile() } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 43fd8752410c..4884873b75f2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -44,6 +44,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 */; }; D3664002D3664002D3664002 /* MarkdownPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3664001D3664001D3664001 /* MarkdownPanelTests.swift */; }; C0DEF0A10000000000000001 /* CmuxConfigUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A10000000000000002 /* CmuxConfigUI.swift */; }; C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; }; @@ -595,6 +596,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 = ""; }; @@ -1601,6 +1603,7 @@ D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, C3677001000000000000002 /* CmuxSSHURLRequestTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, + C33860010000000000000002 /* TerminalControllerSurfaceSplitOffTests.swift */, 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */, E7E000000000000000000004 /* CmuxEventBusTests.swift */, A5A5A504A1B2C3D4E5F60718 /* TerminalNotificationQueueTests.swift */, @@ -2376,6 +2379,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/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) + } +} diff --git a/tests/split_render_helpers.py b/tests/split_render_helpers.py new file mode 100644 index 000000000000..2b23fc480f1f --- /dev/null +++ b/tests/split_render_helpers.py @@ -0,0 +1,80 @@ +#!/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: + """Capture a panel snapshot, retrying through transient attachment churn.""" + 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: + """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) + return float(max(0, changed)) / float(max(1, width * height)) + + +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: + 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 ca668c390355..54dcf1b442ba 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,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. @@ -134,7 +146,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 +158,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() @@ -160,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") @@ -178,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 0f48eae3609c..0a5a1733068f 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,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. @@ -134,7 +146,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 +158,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() @@ -160,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") @@ -176,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()