Common Issues with Nested Split Layouts in cmux: Focus, Drift, and Ordering Fixes
Nested split layouts in cmux suffer from stale focus states, divider position drift, and pane ordering mismatches, which are mitigated through multi-turn focus re-assertion, explicit divider synchronization, and recursive tree traversal algorithms.
cmux (from manaflow-ai/cmux) builds its workspace UI on top of Bonsplit, a split-pane library that supports nested splits inside splits. When developers create splits programmatically via newTerminalSplit or newTerminalSurface, subtle synchronization issues emerge between Bonsplit’s internal model and AppKit’s first-responder chain, requiring defensive coding patterns throughout Sources/Workspace.swift.
Stale Focus State and Re-entrancy Issues
When creating nested split layouts in cmux, the most common failure mode is focus divergence: Bonsplit’s splitPane immediately focuses the newly created pane, but delayed didSelect or didFocus callbacks can arrive one or two run-loop turns later, causing the AppKit first-responder to diverge from Bonsplit’s internal focus record.
Suppressing Re-parent Focus During Split Creation
When a pane is split, the old view is about to be re-parented. Without suppression, macOS sends a becomeFirstResponder event to the old view, stealing focus from the new pane. The solution in Sources/Workspace.swift temporarily suppresses focus events during the mutation:
// Workspace.swift – during split creation
if focus {
previousHostedView?.suppressReparentFocus()
focusPanel(newPanel.id, previousHostedView: previousHostedView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
previousHostedView?.clearSuppressReparentFocus()
}
}
Source: Sources/Workspace.swift (lines 25-33)
Preserving Focus After Non-Intent Splits
When newTerminalSplit is called with focus: false, cmux must keep the previously focused panel active rather than allowing the new split pane to steal focus. The preserveFocusAfterNonFocusSplit method schedules a focus-reconcile and a multi-turn re-assertion:
private func preserveFocusAfterNonFocusSplit(
preferredPanelId: UUID?,
splitPanelId: UUID,
previousHostedView: GhosttySurfaceScrollView?
) {
guard let preferredPanelId, panels[preferredPanelId] != nil else {
clearNonFocusSplitFocusReassert()
scheduleFocusReconcile()
return
}
let generation = beginNonFocusSplitFocusReassert(
preferredPanelId: preferredPanelId,
splitPanelId: splitPanelId
)
// Re-assert focus over three run-loop turns.
reassertFocusAfterNonFocusSplit(generation: generation, …)
DispatchQueue.main.async { … reassert … }
DispatchQueue.main.async { … reassert … scheduleFocusReconcile(); clear… }
}
Source: Sources/Workspace.swift (lines 88-136)
Re-asserting Focus Over Multiple Run-Loop Turns
The reassertFocusAfterNonFocusSplit method checks whether the split pane has mistakenly become the focused pane and flips focus back to the intended panel. This prevents the "highlighted but typing goes to the wrong pane" bug:
private func reassertFocusAfterNonFocusSplit(
generation: UInt64,
preferredPanelId: UUID,
splitPanelId: UUID,
previousHostedView: GhosttySurfaceScrollView?,
allowPreviousHostedView: Bool
) {
// If the split pane is currently focused, move focus back.
if focusedPanelId == splitPanelId {
focusPanel(
preferredPanelId,
previousHostedView: allowPreviousHostedView ? previousHostedView : nil
)
return
}
// Otherwise ensure the terminal’s view is the AppKit first responder.
if let terminalPanel = terminalPanel(for: preferredPanelId) {
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId)
}
}
Source: Sources/Workspace.swift (lines 140-166)
Divider Position Drift in Deeply Nested Trees
After a series of nested splits, divider positions can become out-of-sync with the visual layout because Bonsplit stores divider positions per split node, and programmatic changes must target exact split IDs.
Equalizing Split Dividers Programmatically
The TabManager.equalizeSplits method walks the Bonsplit tree, obtains the current snapshot via splitNodes(in:), and explicitly sets each divider position to 0.5:
let initialSplits = splitNodes(in: workspace.bonsplitController.treeSnapshot())
for (index, split) in initialSplits.enumerated() {
let target = index.isMultiple(of: 2) ? 0.2 : 0.8
workspace.bonsplitController.setDividerPosition(target, forSplit: splitId)
}
XCTAssertTrue(manager.equalizeSplits(tabId: workspace.id))
Source: cmuxTests/TabManagerUnitTests.swift (lines 84-100)
Resizing Specific Splits by ID
To resize a specific divider without drift, TabManager.resizeSplit fetches the current tree snapshot, updates the divider position explicitly, and verifies the change:
guard let split = splitNodes(in: workspace.bonsplitController.treeSnapshot()).first,
let splitId = UUID(uuidString: split.id) else { … }
workspace.bonsplitController.setDividerPosition(0.5, forSplit: splitId)
XCTAssertTrue(manager.resizeSplit(tabId: workspace.id,
surfaceId: leftPanelId,
direction: .right,
amount: 120))
Source: cmuxTests/TabManagerUnitTests.swift (lines 124-144)
Pane Ordering and Visual Layout Synchronization
Bonsplit’s split node stores first and second children, but a naïve traversal produces a left-to-right order that diverges from the UI after mixed horizontal/vertical splits. The orderedPaneIds function in Sources/Workspace.swift recursively walks the tree and concatenates IDs in visual order:
static func orderedPaneIds(tree: ExternalTreeNode) -> [String] {
switch tree {
case .pane(let pane):
return [pane.id]
case .split(let split):
// Bonsplit split order matches visual order for both horizontal and vertical splits.
return orderedPaneIds(tree: split.first) + orderedPaneIds(tree: split.second)
}
}
Source: Sources/Workspace.swift (lines 7776-7784)
Nested Surface Creation Workflow
A nested surface (e.g., a browser panel inside a split pane) requires careful coordination between the Panel model and Bonsplit’s tab creation. The newTerminalSurface method creates the Panel first, then calls bonsplitController.createTab, and finally forces deterministic selection and focus:
let newPanel = TerminalPanel(...)
panels[newPanel.id] = newPanel
let newTabId = bonsplitController.createTab(
title: newPanel.displayTitle,
icon: newPanel.displayIcon,
kind: .terminal,
isDirty: newPanel.isDirty,
isPinned: false,
inPane: paneId
)!
surfaceIdToPanelId[newTabId] = newPanel.id
// Force deterministic selection/focus so the surface is ready immediately.
if shouldFocusNewTab {
bonsplitController.focusPane(paneId)
bonsplitController.selectTab(newTabId)
newPanel.focus()
applyTabSelection(tabId: newTabId, inPane: paneId)
}
Source: Sources/Workspace.swift (lines 8050-8070)
Debugging Under-Flow Events
During rapid split mutations, Bonsplit may emit under-flow warnings when its arranged-subview count briefly drops to zero. cmux exposes a debug counter via the socket interface for troubleshooting:
# Query the under-flow count via the cmux socket REPL
> debug.bonsplit_underflow.count
# Reset after inspection
> debug.bonsplit_underflow.reset
The implementation in TerminalController handles these commands:
case "bonsplit_underflow_count":
return .integer(bonsplitUnderflowCount)
case "reset_bonsplit_underflow_count":
bonsplitUnderflowCount = 0
return .ok
Source: Sources/TerminalController.swift (lines 1887-1900)
Summary
- Stale focus is prevented by
suppressReparentFocus,preserveFocusAfterNonFocusSplit, and multi-turnreassertFocusAfterNonFocusSplitcalls that reconcile AppKit and Bonsplit state. - Re-entrant focus calls are skipped via
shouldSuppressReentrantRefocuschecks infocusPanel. - Divider drift is fixed by
TabManager.equalizeSplitsandresizeSplit, which explicitly set positions using current Bonsplit tree snapshots. - Pane ordering guarantees are enforced by
orderedPaneIds, which recursively traverses the split tree in visual order. - Nested surfaces are created by instantiating the
Panelbefore callingbonsplitController.createTab, then forcing immediate selection and focus. - Under-flow diagnostics are exposed via socket commands for CI and local debugging.
Frequently Asked Questions
Why does focus flicker when creating new splits in cmux?
Focus flicker occurs when Bonsplit's delayed didSelect callback arrives after the split creation, causing AppKit's first-responder to diverge from the intended pane. cmux mitigates this via suppressReparentFocus during the split operation and reassertFocusAfterNonFocusSplit across multiple run-loop turns to ensure focus converges on the correct panel.
How does cmux prevent divider positions from drifting in complex nested layouts?
cmux prevents drift by never assuming divider indices remain stable. The TabManager fetches a fresh tree snapshot via splitNodes(in:) before calling setDividerPosition, ensuring each resize targets the correct Bonsplit node ID rather than a positional index that might shift during mutations.
What causes the "under-flow" warning in cmux debug logs?
Under-flow warnings appear when Bonsplit's arranged-subview count temporarily drops to zero during rapid split mutations. This is a diagnostic signal used primarily in testing; cmux tracks these events via the bonsplitUnderflowCount counter accessible through the socket REPL's debug.bonsplit_underflow.* commands.
How does cmux determine the correct visual order of panels in a nested split tree?
The orderedPaneIds function in Sources/Workspace.swift recursively traverses the Bonsplit tree, concatenating split.first before split.second at each node. This produces a left-to-right, top-to-bottom ordering that matches the visual UI layout regardless of the split orientation (horizontal or vertical).
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →