Merge pull request #419 from manaflow-ai/task-bonsplit-tab-drag-lag-debug-logging

Add debug timing logs for bonsplit tab transfer lag repro
This commit is contained in:
Lawrence Chen 2026-02-23 23:59:01 -08:00 committed by GitHub
commit 6a3c0bd6f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 600 additions and 61 deletions

View file

@ -77,6 +77,7 @@
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@ -198,10 +199,11 @@
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -401,17 +403,18 @@
path = cmuxUITests;
sourceTree = "<group>";
};
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup;
children = (
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
};
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
isa = PBXGroup;
children = (
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
);
path = cmuxTests;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -601,17 +604,18 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
F1000005A1B2C3D4E5F60718 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F1000005A1B2C3D4E5F60718 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B9000006A1B2C3D4E5F60719 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;

View file

@ -1113,19 +1113,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
focus: Bool = true,
focusWindow: Bool = true
) -> Bool {
guard let source = locateSurface(surfaceId: panelId),
let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }),
let destinationManager = tabManagerFor(tabId: targetWorkspaceId),
let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else {
#if DEBUG
let moveStart = ProcessInfo.processInfo.systemUptime
let splitLabel = splitTarget.map { split in
"\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)"
} ?? "none"
func elapsedMs(since start: TimeInterval) -> String {
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
return String(format: "%.2f", ms)
}
dlog(
"surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " +
"targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " +
"split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)"
)
#endif
guard let source = locateSurface(surfaceId: panelId) else {
#if DEBUG
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))")
#endif
return false
}
guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else {
#if DEBUG
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))")
#endif
return false
}
guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else {
#if DEBUG
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))")
#endif
return false
}
guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else {
#if DEBUG
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))")
#endif
return false
}
#if DEBUG
dlog(
"surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " +
"sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " +
"sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)"
)
#endif
let resolvedTargetPane = targetPane.flatMap { pane in
destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane })
} ?? destinationWorkspace.bonsplitController.focusedPaneId
?? destinationWorkspace.bonsplitController.allPaneIds.first
guard let resolvedTargetPane else { return false }
guard let resolvedTargetPane else {
#if DEBUG
dlog(
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " +
"destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return false
}
if destinationWorkspace.id == sourceWorkspace.id {
if let splitTarget {
@ -1136,26 +1184,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
movingTab: sourceTabId,
insertFirst: splitTarget.insertFirst
) != nil else {
#if DEBUG
dlog(
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " +
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " +
"elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return false
}
if focus {
source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true)
}
#if DEBUG
dlog(
"surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " +
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return true
}
return sourceWorkspace.moveSurface(
let moved = sourceWorkspace.moveSurface(
panelId: panelId,
toPane: resolvedTargetPane,
atIndex: targetIndex,
focus: focus
)
#if DEBUG
dlog(
"surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " +
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " +
"elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return moved
}
let sourcePane = sourceWorkspace.paneId(forPanelId: panelId)
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId)
#if DEBUG
let detachStart = ProcessInfo.processInfo.systemUptime
#endif
guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { return false }
guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else {
#if DEBUG
dlog(
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " +
"elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return false
}
#if DEBUG
let detachMs = elapsedMs(since: detachStart)
let attachStart = ProcessInfo.processInfo.systemUptime
#endif
guard destinationWorkspace.attachDetachedSurface(
detached,
inPane: resolvedTargetPane,
@ -1169,10 +1253,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
sourceIndex: sourceIndex,
focus: focus
)
#if DEBUG
dlog(
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " +
"detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return false
}
#if DEBUG
let attachMs = elapsedMs(since: attachStart)
var splitMs = "0.00"
#endif
if let splitTarget {
#if DEBUG
let splitStart = ProcessInfo.processInfo.systemUptime
#endif
guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId),
destinationWorkspace.bonsplitController.splitPane(
resolvedTargetPane,
@ -1189,15 +1286,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
focus: focus
)
}
#if DEBUG
dlog(
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " +
"detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return false
}
#if DEBUG
splitMs = elapsedMs(since: splitStart)
#endif
}
#if DEBUG
let cleanupStart = ProcessInfo.processInfo.systemUptime
#endif
cleanupEmptySourceWorkspaceAfterSurfaceMove(
sourceWorkspace: sourceWorkspace,
sourceManager: source.tabManager,
sourceWindowId: source.windowId
)
#if DEBUG
let cleanupMs = elapsedMs(since: cleanupStart)
let focusStart = ProcessInfo.processInfo.systemUptime
#endif
if focus {
let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil
@ -1215,6 +1328,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
}
#if DEBUG
let focusMs = elapsedMs(since: focusStart)
dlog(
"surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " +
"sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " +
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " +
"split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " +
"cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return true
}
@ -1229,8 +1352,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
focus: Bool = true,
focusWindow: Bool = true
) -> Bool {
guard let located = locateBonsplitSurface(tabId: tabId) else { return false }
return moveSurface(
#if DEBUG
let moveStart = ProcessInfo.processInfo.systemUptime
func elapsedMs(since start: TimeInterval) -> String {
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
return String(format: "%.2f", ms)
}
dlog(
"surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " +
"targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")"
)
#endif
guard let located = locateBonsplitSurface(tabId: tabId) else {
#if DEBUG
dlog(
"surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " +
"targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return false
}
#if DEBUG
dlog(
"surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " +
"sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))"
)
#endif
let moved = moveSurface(
panelId: located.panelId,
toWorkspace: targetWorkspaceId,
targetPane: targetPane,
@ -1239,6 +1387,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
focus: focus,
focusWindow: focusWindow
)
#if DEBUG
dlog(
"surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " +
"moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))"
)
#endif
return moved
}
func tabManagerFor(windowId: UUID) -> TabManager? {

View file

@ -2517,6 +2517,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
var shouldApplySurfaceFocus = false
if result {
// If we become first responder before the ghostty surface exists (e.g. during
// split/tab creation while the surface is still being created), record the desired focus.
@ -2538,6 +2539,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// stayed behind.
let hiddenInHierarchy = isHiddenOrHasHiddenAncestor
if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy {
shouldApplySurfaceFocus = true
onFocus?()
} else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) {
#if DEBUG
@ -2548,7 +2550,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#endif
}
}
if result, let surface = ensureSurfaceReadyForInput() {
if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() {
let now = CACurrentMediaTime()
let deltaMs = (now - lastScrollEventTime) * 1000
Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
@ -4102,9 +4104,11 @@ final class GhosttySurfaceScrollView: NSView {
isHidden = !visible
#if DEBUG
if wasVisible != visible {
let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)"
let suffix = debugVisibilityStateSuffix(transition: transition)
debugLogWorkspaceSwitchTiming(
event: "ws.term.visible",
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)"
suffix: suffix
)
}
#endif
@ -4124,9 +4128,11 @@ final class GhosttySurfaceScrollView: NSView {
isActive = active
#if DEBUG
if wasActive != active {
let transition = "\(wasActive ? 1 : 0)->\(active ? 1 : 0)"
let suffix = debugVisibilityStateSuffix(transition: transition)
debugLogWorkspaceSwitchTiming(
event: "ws.term.active",
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)"
suffix: suffix
)
}
#endif
@ -4148,6 +4154,37 @@ final class GhosttySurfaceScrollView: NSView {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)")
}
private func debugFirstResponderLabel() -> String {
guard let window, let firstResponder = window.firstResponder else { return "nil" }
if let view = firstResponder as? NSView {
if view === surfaceView {
return "surfaceView"
}
if view.isDescendant(of: surfaceView) {
return "surfaceDescendant"
}
return String(describing: type(of: view))
}
return String(describing: type(of: firstResponder))
}
private func debugVisibilityStateSuffix(transition: String) -> String {
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0
let inWindow = window != nil ? 1 : 0
let hasSuperview = superview != nil ? 1 : 0
let hostHidden = isHidden ? 1 : 0
let surfaceHidden = surfaceView.isHidden ? 1 : 0
let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height)
let frameText = String(format: "%.1fx%.1f", frame.width, frame.height)
let responder = debugFirstResponderLabel()
return
"surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " +
"visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " +
"hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " +
"bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)"
}
#endif
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
@ -4999,32 +5036,36 @@ struct GhosttyTerminalView: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) {
let hostedView = terminalSurface.hostedView
let coordinator = context.coordinator
#if DEBUG
let previousDesiredIsActive = coordinator.desiredIsActive
#endif
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing
let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority
let desiredStateChanged =
previousDesiredIsActive != isActive ||
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredPortalZPriority != portalZPriority
coordinator.desiredIsActive = isActive
coordinator.desiredIsVisibleInUI = isVisibleInUI
coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing
coordinator.desiredPortalZPriority = portalZPriority
coordinator.hostedView = hostedView
#if DEBUG
if previousDesiredIsActive != isActive ||
previousDesiredIsVisibleInUI != isVisibleInUI ||
previousDesiredPortalZPriority != portalZPriority {
if desiredStateChanged {
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " +
"surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) " +
"active=\(isActive ? 1 : 0) z=\(portalZPriority)"
"active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
"hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
"hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
)
} else {
dlog(
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)"
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
"hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
"hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
)
}
}
@ -5112,6 +5153,16 @@ struct GhosttyTerminalView: NSViewRepresentable {
// Bind is deferred until host moves into a window. Update the
// existing portal entry's visibleInUI now so that any portal sync
// that runs before the deferred bind completes won't hide the view.
#if DEBUG
if desiredStateChanged {
dlog(
"ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " +
"active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " +
"hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
)
}
#endif
TerminalWindowPortalRegistry.updateEntryVisibility(
for: hostedView,
visibleInUI: coordinator.desiredIsVisibleInUI
@ -5135,6 +5186,16 @@ struct GhosttyTerminalView: NSViewRepresentable {
} else {
// Preserve portal entry visibility while a stale host is still receiving SwiftUI updates.
// The currently bound host remains authoritative for immediate visible/active state.
#if DEBUG
if desiredStateChanged {
dlog(
"ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " +
"boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " +
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
)
}
#endif
TerminalWindowPortalRegistry.updateEntryVisibility(
for: hostedView,
visibleInUI: isVisibleInUI

View file

@ -347,6 +347,11 @@ final class Workspace: Identifiable, ObservableObject {
return panel
}
enum FocusPanelTrigger {
case standard
case terminalFirstResponder
}
/// Published directory for each panel
@Published var panelDirectories: [UUID: String] = [:]
@Published var panelTitles: [UUID: String] = [:]
@ -568,6 +573,8 @@ final class Workspace: Identifiable, ObservableObject {
private var focusReconcileScheduled = false
#if DEBUG
private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0
private var debugLastDidMoveTabTimestamp: TimeInterval = 0
private var debugDidMoveTabEventCount: UInt64 = 0
#endif
private var geometryReconcileScheduled = false
private var isNormalizingPinnedTabOrder = false
@ -600,6 +607,13 @@ final class Workspace: Identifiable, ObservableObject {
private var activeDetachCloseTransactions: Int = 0
private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 }
#if DEBUG
private func debugElapsedMs(since start: TimeInterval) -> String {
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
return String(format: "%.2f", ms)
}
#endif
func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? {
surfaceIdToPanelId[surfaceId]
}
@ -1686,6 +1700,14 @@ final class Workspace: Identifiable, ObservableObject {
func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? {
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
guard panels[panelId] != nil else { return nil }
#if DEBUG
let detachStart = ProcessInfo.processInfo.systemUptime
dlog(
"split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " +
"pendingDetached=\(pendingDetachedSurfaces.count)"
)
#endif
detachingTabIds.insert(tabId)
forceCloseTabIds.insert(tabId)
@ -1695,10 +1717,24 @@ final class Workspace: Identifiable, ObservableObject {
detachingTabIds.remove(tabId)
pendingDetachedSurfaces.removeValue(forKey: tabId)
forceCloseTabIds.remove(tabId)
#if DEBUG
dlog(
"split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))"
)
#endif
return nil
}
return pendingDetachedSurfaces.removeValue(forKey: tabId)
let detached = pendingDetachedSurfaces.removeValue(forKey: tabId)
#if DEBUG
dlog(
"split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
"tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " +
"elapsedMs=\(debugElapsedMs(since: detachStart))"
)
#endif
return detached
}
@discardableResult
@ -1708,8 +1744,31 @@ final class Workspace: Identifiable, ObservableObject {
atIndex index: Int? = nil,
focus: Bool = true
) -> UUID? {
guard bonsplitController.allPaneIds.contains(paneId) else { return nil }
guard panels[detached.panelId] == nil else { return nil }
#if DEBUG
let attachStart = ProcessInfo.processInfo.systemUptime
dlog(
"split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)"
)
#endif
guard bonsplitController.allPaneIds.contains(paneId) else {
#if DEBUG
dlog(
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return nil
}
guard panels[detached.panelId] == nil else {
#if DEBUG
dlog(
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return nil
}
panels[detached.panelId] = detached.panel
if let terminalPanel = detached.panel as? TerminalPanel {
@ -1760,6 +1819,12 @@ final class Workspace: Identifiable, ObservableObject {
manualUnreadPanelIds.remove(detached.panelId)
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
panelSubscriptions.removeValue(forKey: detached.panelId)
#if DEBUG
dlog(
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return nil
}
@ -1781,6 +1846,14 @@ final class Workspace: Identifiable, ObservableObject {
}
scheduleTerminalGeometryReconcile()
#if DEBUG
dlog(
"split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
"tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " +
"index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " +
"elapsedMs=\(debugElapsedMs(since: attachStart))"
)
#endif
return detached.panelId
}
// MARK: - Focus Management
@ -1872,12 +1945,19 @@ final class Workspace: Identifiable, ObservableObject {
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId)
}
func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) {
func focusPanel(
_ panelId: UUID,
previousHostedView: GhosttySurfaceScrollView? = nil,
trigger: FocusPanelTrigger = .standard
) {
markExplicitFocusIntent(on: panelId)
#if DEBUG
let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)")
FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)")
let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard"
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)")
FocusLogStore.shared.append(
"Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)"
)
#endif
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
let currentlyFocusedPanelId = focusedPanelId
@ -1900,6 +1980,15 @@ final class Workspace: Identifiable, ObservableObject {
return bonsplitController.focusedPaneId == targetPaneId &&
bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId
}()
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
#if DEBUG
if shouldSuppressReentrantRefocus {
dlog(
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
"reason=firstResponderAlreadyConverged"
)
}
#endif
if let targetPaneId, !selectionAlreadyConverged {
bonsplitController.focusPane(targetPaneId)
@ -1911,11 +2000,11 @@ final class Workspace: Identifiable, ObservableObject {
// Also focus the underlying panel
if let panel = panels[panelId] {
if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged {
if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus {
panel.focus()
}
if let terminalPanel = panel as? TerminalPanel {
if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel {
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
// (becomeFirstResponder -> onFocus -> focusPanel).
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
@ -1923,7 +2012,7 @@ final class Workspace: Identifiable, ObservableObject {
}
}
}
if let targetPaneId {
if let targetPaneId, !shouldSuppressReentrantRefocus {
applyTabSelection(tabId: tabId, inPane: targetPaneId)
}
}
@ -2329,23 +2418,41 @@ final class Workspace: Identifiable, ObservableObject {
private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool {
guard let app = AppDelegate.shared else { return false }
#if DEBUG
let dropStart = ProcessInfo.processInfo.systemUptime
#endif
let targetPane: PaneID
let targetIndex: Int?
let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)?
#if DEBUG
let destinationLabel: String
#endif
switch request.destination {
case .insert(let paneId, let index):
targetPane = paneId
targetIndex = index
splitTarget = nil
#if DEBUG
destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")"
#endif
case .split(let paneId, let orientation, let insertFirst):
targetPane = paneId
targetIndex = nil
splitTarget = (orientation, insertFirst)
#if DEBUG
destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)"
#endif
}
return app.moveBonsplitTab(
#if DEBUG
dlog(
"split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)"
)
#endif
let moved = app.moveBonsplitTab(
tabId: request.tabId.uuid,
toWorkspace: id,
targetPane: targetPane,
@ -2354,6 +2461,13 @@ final class Workspace: Identifiable, ObservableObject {
focus: true,
focusWindow: true
)
#if DEBUG
dlog(
"split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
"moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))"
)
#endif
return moved
}
}
@ -2647,17 +2761,19 @@ extension Workspace: BonsplitDelegate {
if isDetaching, let panel {
let browserPanel = panel as? BrowserPanel
let cachedTitle = panelTitles[panelId]
let transferFallbackTitle = cachedTitle ?? panel.displayTitle
pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer(
panelId: panelId,
panel: panel,
title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle),
title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle),
icon: panel.displayIcon,
iconImageData: browserPanel?.faviconPNGData,
kind: surfaceKind(for: panel),
isLoading: browserPanel?.isLoading ?? false,
isPinned: pinnedPanelIds.contains(panelId),
directory: panelDirectories[panelId],
cachedTitle: panelTitles[panelId],
cachedTitle: cachedTitle,
customTitle: panelCustomTitles[panelId],
manuallyUnread: manualUnreadPanelIds.contains(panelId)
)
@ -2736,14 +2852,44 @@ extension Workspace: BonsplitDelegate {
func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) {
#if DEBUG
let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown"
let now = ProcessInfo.processInfo.systemUptime
let sincePrev: String
if debugLastDidMoveTabTimestamp > 0 {
sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000)
} else {
sincePrev = "first"
}
debugLastDidMoveTabTimestamp = now
debugDidMoveTabEventCount += 1
let movedPanelId = panelIdFromSurfaceId(tab.id)
let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown"
let selectedBefore = controller.selectedTab(inPane: destination)
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil"
dlog(
"split.moveTab panel=\(movedPanel) " +
"split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " +
"from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " +
"sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)"
)
dlog(
"split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
"destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)"
)
#endif
applyTabSelection(tabId: tab.id, inPane: destination)
#if DEBUG
let selectedAfter = controller.selectedTab(inPane: destination)
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
let movedPanelFocused = (movedPanelId != nil && movedPanelId == focusedPanelId) ? 1 : 0
dlog(
"split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
"destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " +
"movedFocused=\(movedPanelFocused)"
)
#endif
normalizePinnedTabs(in: source)
normalizePinnedTabs(in: destination)
scheduleTerminalGeometryReconcile()

View file

@ -19,6 +19,17 @@ struct WorkspaceContentView: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var notificationStore: TerminalNotificationStore
static func panelVisibleInUI(
isWorkspaceVisible: Bool,
isSelectedInPane: Bool,
isFocused: Bool
) -> Bool {
guard isWorkspaceVisible else { return false }
// During pane/tab reparenting, Bonsplit can transiently report selected=false
// for the currently focused panel. Keep focused content visible to avoid blank frames.
return isSelectedInPane || isFocused
}
var body: some View {
let appearance = PanelAppearance.fromConfig(config)
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
@ -47,7 +58,11 @@ struct WorkspaceContentView: View {
if let panel = workspace.panel(for: tab.id) {
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
let isVisibleInUI = Self.panelVisibleInUI(
isWorkspaceVisible: isWorkspaceVisible,
isSelectedInPane: isSelectedInPane,
isFocused: isFocused
)
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
@ -67,7 +82,7 @@ struct WorkspaceContentView: View {
// indicator and where keyboard input/flash-focus actually lands.
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanel(panel.id)
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
},
onRequestPanelFocus: {
guard isWorkspaceInputActive else { return }

View file

@ -3081,6 +3081,51 @@ final class WorkspacePanelGitBranchTests: XCTestCase {
#endif
}
func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() {
let source = Workspace()
guard let panelId = source.focusedPanelId else {
XCTFail("Expected source focused panel")
return
}
XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title"))
guard let detached = source.detachSurface(panelId: panelId) else {
XCTFail("Expected detach to succeed")
return
}
XCTAssertEqual(detached.cachedTitle, "detached-runtime-title")
XCTAssertNil(detached.customTitle)
XCTAssertEqual(
detached.title,
"detached-runtime-title",
"Detached transfer should carry the cached non-custom title"
)
let destination = Workspace()
guard let destinationPane = destination.bonsplitController.allPaneIds.first else {
XCTFail("Expected destination pane")
return
}
let attachedPanelId = destination.attachDetachedSurface(
detached,
inPane: destinationPane,
focus: false
)
XCTAssertEqual(attachedPanelId, panelId)
XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title")
guard let attachedTabId = destination.surfaceIdFromPanelId(panelId),
let attachedTab = destination.bonsplitController.tab(attachedTabId) else {
XCTFail("Expected attached tab mapping")
return
}
XCTAssertEqual(attachedTab.title, "detached-runtime-title")
XCTAssertFalse(attachedTab.hasCustomTitle)
}
func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() {
let workspace = Workspace()
guard let originalFocusedPanelId = workspace.focusedPanelId else {

View file

@ -0,0 +1,49 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class WorkspaceContentViewVisibilityTests: XCTestCase {
func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() {
XCTAssertFalse(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: false,
isSelectedInPane: true,
isFocused: true
)
)
}
func testPanelVisibleInUIReturnsTrueForSelectedPanel() {
XCTAssertTrue(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: true,
isSelectedInPane: true,
isFocused: false
)
)
}
func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() {
XCTAssertTrue(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: true,
isSelectedInPane: false,
isFocused: true
)
)
}
func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() {
XCTAssertFalse(
WorkspaceContentView.panelVisibleInUI(
isWorkspaceVisible: true,
isSelectedInPane: false,
isFocused: false
)
)
}
}

View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Static regression checks for re-entrant terminal focus guard.
Guards the fix for split-drag focus churn where:
becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects
could repeatedly re-enter and spike CPU.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]
def main() -> int:
root = repo_root()
failures: list[str] = []
workspace_path = root / "Sources" / "Workspace.swift"
workspace_source = workspace_path.read_text(encoding="utf-8")
required_workspace_snippets = [
"enum FocusPanelTrigger {",
"case terminalFirstResponder",
"trigger: FocusPanelTrigger = .standard",
"let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged",
"if let targetPaneId, !shouldSuppressReentrantRefocus {",
"reason=firstResponderAlreadyConverged",
]
for snippet in required_workspace_snippets:
if snippet not in workspace_source:
failures.append(f"Workspace focus guard missing snippet: {snippet}")
workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift"
workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8")
focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)"
if focus_callback_snippet not in workspace_content_view_source:
failures.append(
"WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger"
)
if failures:
print("FAIL: focus-panel re-entrant guard regression checks failed")
for item in failures:
print(f" - {item}")
return 1
print("PASS: focus-panel re-entrant guard is in place")
return 0
if __name__ == "__main__":
raise SystemExit(main())