Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
28ec412
test: cover hidden tiny terminal focus reapply
austinywang Jun 17, 2026
d7e073a
fix: retry terminal focus after tiny responder handoff
austinywang Jun 17, 2026
f0f1d43
fix: keep terminal focus retry pending until geometry settles
austinywang Jun 17, 2026
75749de
fix: require surface geometry before focus retry
austinywang Jun 17, 2026
6e87af7
fix: skip redundant focus retry scans
austinywang Jun 17, 2026
3f0d5dc
fix: scope surface geometry guard to suppressed focus
austinywang Jun 17, 2026
f0778cc
test: install focus retry workspace in tab manager
austinywang Jun 17, 2026
d4aa997
test: register focus retry window context
austinywang Jun 17, 2026
f6f8746
test: move focus recovery coverage to swift testing
austinywang Jun 17, 2026
eeea661
test: normalize focus recovery project wiring
austinywang Jun 17, 2026
de8dc90
fix: honor deferred focus during automatic apply
austinywang Jun 18, 2026
5fa4488
fix: preserve deferred focus through find restore
austinywang Jun 18, 2026
aae717a
fix: document forced focus geometry gate
austinywang Jun 18, 2026
442ea2d
fix: compile focus recovery tests
austinywang Jun 18, 2026
71f33e7
fix: include dock terminals in focus recovery
austinywang Jun 18, 2026
38379c3
fix: constrain dock focus recovery retry
austinywang Jun 18, 2026
ac7ba97
fix: retry forced focus reassert after geometry settles
austinywang Jun 18, 2026
3b5c1b3
fix: cover reparent focus recovery ordering
austinywang Jun 18, 2026
da62d2e
fix: defer forced reparent focus until geometry is usable
austinywang Jun 18, 2026
9df36b5
chore: clarify focus reassertion retry helper
austinywang Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
14225 Sources/TerminalController.swift
12646 Sources/Workspace.swift
12115 cmuxTests/AppDelegateShortcutRoutingTests.swift
11731 Sources/GhosttyTerminalView.swift
11833 Sources/GhosttyTerminalView.swift
11413 Sources/Panels/BrowserPanel.swift
9331 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
7953 Sources/Panels/BrowserPanelView.swift
Expand Down
112 changes: 107 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3908,6 +3908,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
updateSurfaceSize()
syncKeyboardCopyModeCursorOverlay()
invalidateTextInputCoordinates()
terminalSurface?.hostedView.scheduleSuppressedFirstResponderFocusReapplyIfReady(
reason: "becomeFirstResponder.hiddenOrTiny.layout"
)
}

override var isOpaque: Bool { false }
Expand Down Expand Up @@ -4902,6 +4905,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
) == false {
desiredFocus = false
terminalSurface.recordExternalFocusState(false)
terminalSurface.hostedView.cancelSuppressedFirstResponderFocusReapply()
#if DEBUG
dlog("focus.firstResponder SUPPRESSED (coordinator) surface=\(terminalSurface.id.uuidString.prefix(5))")
#endif
Expand All @@ -4916,6 +4920,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// becomeFirstResponder. Suppress onFocus + ghostty_surface_set_focus to prevent
// the old view from stealing focus and creating model/surface divergence.
if suppressingReparentFocus {
let hiddenInHierarchy = isHiddenOrHasHiddenAncestor
if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) {
terminalSurface?.hostedView.scheduleSuppressedFirstResponderFocusReapply(
reason: "becomeFirstResponder.reparent.hiddenOrTiny"
)
}
#if DEBUG
cmuxDebugLog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
Expand All @@ -4937,6 +4947,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)"
)
#endif
terminalSurface?.hostedView.scheduleSuppressedFirstResponderFocusReapply(
reason: "becomeFirstResponder.hiddenOrTiny"
)
}
}
if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() {
Expand All @@ -4963,6 +4976,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
}
terminalSurface?.recordExternalFocusState(true)
terminalSurface?.hostedView.cancelSuppressedFirstResponderFocusReapply()
ghostty_surface_set_focus(surface, true)

// Ghostty only restarts its vsync display link on display-id changes while focused.
Expand All @@ -4983,6 +4997,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if result {
imeConsumedKeyUps.removeAll()
desiredFocus = false
terminalSurface?.hostedView.cancelSuppressedFirstResponderFocusReapply()
terminalSurface?.recordExternalFocusState(false)
}
if result, let surface = surface {
Expand Down Expand Up @@ -7611,7 +7626,8 @@ final class GhosttySurfaceScrollView: NSView {
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
private var pendingAutomaticFirstResponderApply = false
// Intentionally no focus retry loops: rely on AppKit first-responder and bonsplit selection.
private var pendingSuppressedFirstResponderFocusReapply = false
// Hidden/tiny focus retry is bounded by layout/visibility signals, not a timer loop.

/// Tracks whether keyboard focus should go to the search field or the terminal
/// when the window becomes key while the find bar is open.
Expand Down Expand Up @@ -8197,6 +8213,9 @@ final class GhosttySurfaceScrollView: NSView {
synchronizeGeometryAndContent()
_ = setFrameIfNeeded(paneDropTargetView, to: bounds)
bringPaneDropTargetToFrontIfNeeded()
scheduleSuppressedFirstResponderFocusReapplyIfReady(
reason: "becomeFirstResponder.hiddenOrTiny.layout"
)
}

override func viewDidMoveToSuperview() {
Expand Down Expand Up @@ -9680,6 +9699,7 @@ final class GhosttySurfaceScrollView: NSView {

func yieldTerminalSurfaceFocusForForeignResponder(reason: String) {
surfaceView.desiredFocus = false
pendingSuppressedFirstResponderFocusReapply = false
guard let terminalSurface = surfaceView.terminalSurface else { return }
terminalSurface.setFocus(false)
#if DEBUG
Expand Down Expand Up @@ -9740,7 +9760,6 @@ final class GhosttySurfaceScrollView: NSView {

guard surfaceView.desiredFocus || surfaceOwnsFirstResponder else { return }
guard surfaceView.isVisibleInUI else { return }
surfaceView.terminalSurface?.recordExternalFocusState(true)
guard let window = uiWindow, window.isKeyWindow else { return }
guard !isHiddenForFocus, hasUsablePortalGeometry else {
#if DEBUG
Expand Down Expand Up @@ -9769,6 +9788,39 @@ final class GhosttySurfaceScrollView: NSView {
reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus", force: true)
}

fileprivate func scheduleSuppressedFirstResponderFocusReapply(reason: String) {
pendingSuppressedFirstResponderFocusReapply = true
scheduleAutomaticFirstResponderApply(reason: reason)
}

fileprivate func cancelSuppressedFirstResponderFocusReapply() {
pendingSuppressedFirstResponderFocusReapply = false
}

fileprivate func scheduleSuppressedFirstResponderFocusReapplyIfReady(reason: String) {
guard pendingSuppressedFirstResponderFocusReapply else { return }
guard !pendingAutomaticFirstResponderApply else { return }
guard isActive, surfaceView.desiredFocus, surfaceView.isVisibleInUI else { return }
guard currentTerminalSurfaceOwnsFirstResponder() else { return }
let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor
guard !isHiddenForFocus,
bounds.width > 1,
bounds.height > 1,
surfaceView.bounds.width > 1,
surfaceView.bounds.height > 1 else {
return
}
guard let window = uiWindow, window.isKeyWindow else { return }
guard let tabId = surfaceView.tabId,
let panelId = surfaceView.terminalSurface?.id,
isRightSidebarDockSurface || matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId),
AppDelegate.shared?.allowsTerminalKeyboardFocus(workspaceId: tabId, panelId: panelId, in: window) != false,
AppDelegate.shared?.isCommandPaletteEffectivelyVisible(for: window) != true else {
return
}
scheduleAutomaticFirstResponderApply(reason: reason)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Returns true if the terminal's actual Ghostty surface view is (or contains) the window first responder.
/// This is stricter than checking `hostedView` descendants, since the scroll view can sometimes become
/// first responder transiently while focus is being applied.
Expand All @@ -9781,6 +9833,10 @@ final class GhosttySurfaceScrollView: NSView {
func debugIsSuppressingReparentFocusForTesting() -> Bool {
surfaceView.suppressingReparentFocus
}

func debugHasPendingAutomaticFirstResponderApplyForTesting() -> Bool {
pendingAutomaticFirstResponderApply
}
#endif

private func currentTerminalSurfaceOwnsFirstResponder() -> Bool {
Expand Down Expand Up @@ -9842,7 +9898,37 @@ final class GhosttySurfaceScrollView: NSView {
}
}

private func prepareTerminalSurfaceFocusReassertion(reason: String, force: Bool) -> Bool {
let requiresUsableGeometry = pendingSuppressedFirstResponderFocusReapply || force
guard requiresUsableGeometry else { return true }

// `force` bypasses TerminalSurface focus coalescing, not AppKit geometry readiness.
let portalSize = bounds.size
let surfaceSize = surfaceView.bounds.size
let hasUsablePortalGeometry = portalSize.width > 1 && portalSize.height > 1
let hasUsableSurfaceGeometry = surfaceSize.width > 1 && surfaceSize.height > 1
let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor
guard !isHiddenForFocus, hasUsablePortalGeometry, hasUsableSurfaceGeometry else {
#if DEBUG
let surfaceShort = String(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")
cmuxDebugLog(
"focus.surface.reassert.skip surface=\(surfaceShort) reason=\(reason).hidden_or_tiny " +
"hidden=\(isHiddenForFocus ? 1 : 0) " +
"force=\(force ? 1 : 0) " +
"frame=\(String(format: "%.1fx%.1f", portalSize.width, portalSize.height)) " +
"surfaceFrame=\(String(format: "%.1fx%.1f", surfaceSize.width, surfaceSize.height))"
)
#endif
pendingSuppressedFirstResponderFocusReapply = true
scheduleAutomaticFirstResponderApply(reason: "\(reason).hiddenOrTiny")
return false
}

return true
}

private func reassertTerminalSurfaceFocus(reason: String, force: Bool = false) {
guard prepareTerminalSurfaceFocusReassertion(reason: reason, force: force) else { return }
guard let terminalSurface = surfaceView.terminalSurface else { return }
if terminalSurface.surface == nil {
terminalSurface.requestBackgroundSurfaceStartIfNeeded()
Expand All @@ -9851,6 +9937,7 @@ final class GhosttySurfaceScrollView: NSView {
cmuxDebugLog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif
terminalSurface.setFocus(true, force: force)
pendingSuppressedFirstResponderFocusReapply = false
refreshSurfaceAfterFocusIfNeeded(reason: reason)
}

Expand All @@ -9877,24 +9964,33 @@ final class GhosttySurfaceScrollView: NSView {
let size = bounds.size
return size.width > 1 && size.height > 1
}()
let hasUsableSurfaceGeometry: Bool = {
let size = surfaceView.bounds.size
return size.width > 1 && size.height > 1
}()
let isHiddenForFocus = isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor
let surfaceShort = String(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")
let requiresSuppressedSurfaceGeometry = pendingSuppressedFirstResponderFocusReapply

guard isActive else { return }
guard surfaceView.isVisibleInUI else { return }
guard !isHiddenForFocus, hasUsablePortalGeometry else {
guard !isHiddenForFocus,
hasUsablePortalGeometry,
(!requiresSuppressedSurfaceGeometry || hasUsableSurfaceGeometry) else {
#if DEBUG
cmuxDebugLog(
"focus.apply.skip surface=\(surfaceShort) " +
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height))"
"reason=hidden_or_tiny hidden=\(isHiddenForFocus ? 1 : 0) " +
"frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) " +
"surfaceFrame=\(String(format: "%.1fx%.1f", surfaceView.bounds.width, surfaceView.bounds.height))"
)
#endif
return
}
guard let window = uiWindow, window.isKeyWindow else { return }
guard let tabId = surfaceView.tabId,
let panelId = surfaceView.terminalSurface?.id,
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else {
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) || (pendingSuppressedFirstResponderFocusReapply && isRightSidebarDockSurface && currentTerminalSurfaceOwnsFirstResponder()) else {
#if DEBUG
cmuxDebugLog("focus.apply.skip surface=\(surfaceShort) reason=stale_target")
#endif
Expand Down Expand Up @@ -9957,6 +10053,7 @@ final class GhosttySurfaceScrollView: NSView {
let surfaceShort = String(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")
switch searchFocusTarget {
case .searchField:
pendingSuppressedFirstResponderFocusReapply = false
if let firstResponder = window.firstResponder,
isCurrentSurfaceSearchFieldResponder(firstResponder) {
surfaceView.terminalSurface?.setFocus(false)
Expand Down Expand Up @@ -9999,6 +10096,9 @@ final class GhosttySurfaceScrollView: NSView {
#endif
case .terminal:
let result = requestSurfaceFirstResponder(in: window, reason: "restoreSearchFocus.terminal")
if result, isSurfaceViewFirstResponder() {
reassertTerminalSurfaceFocus(reason: "restoreSearchFocus.terminal")
}
#if DEBUG
cmuxDebugLog(
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
Expand Down Expand Up @@ -10170,6 +10270,7 @@ final class GhosttySurfaceScrollView: NSView {
}
if intent == .findField { _ = cmuxRememberFindSelection(in: searchOverlayHostingView) }
surfaceView.terminalSurface?.setFocus(false)
pendingSuppressedFirstResponderFocusReapply = false
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
#if DEBUG
cmuxDebugLog(
Expand All @@ -10191,6 +10292,7 @@ final class GhosttySurfaceScrollView: NSView {

guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }

pendingSuppressedFirstResponderFocusReapply = false
#if DEBUG
cmuxDebugLog(
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,7 @@
E3B7A4000000000000000003 /* WorkspaceTabColorEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A4000000000000000004 /* WorkspaceTabColorEntry.swift */; };
E30750000000000000000002 /* WorkspaceTabColorResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30750000000000000000001 /* WorkspaceTabColorResolution.swift */; };
E3B7A4000000000000000001 /* WorkspaceTabColorSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3B7A4000000000000000002 /* WorkspaceTabColorSettings.swift */; };
6342F0C10000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6342F0C20000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift */; };
6047C0DE6047C0DE60470001 /* WorkspaceTerminalTabWorkingDirectoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */; };
634AA6233A5BBF781FE83E89 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */; };
440AC5D24915463C9670F510 /* WorkspaceTitleProvenanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */; };
Expand Down Expand Up @@ -1893,6 +1894,7 @@
E3B7A4000000000000000004 /* WorkspaceTabColorEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorEntry.swift; sourceTree = "<group>"; };
E30750000000000000000001 /* WorkspaceTabColorResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorResolution.swift; sourceTree = "<group>"; };
E3B7A4000000000000000002 /* WorkspaceTabColorSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTabColorSettings.swift; sourceTree = "<group>"; };
6342F0C20000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTerminalFocusRecoveryTests.swift; sourceTree = "<group>"; };
6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTerminalTabWorkingDirectoryTests.swift; sourceTree = "<group>"; };
08850489C70ECFACA540C2F3 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTerminalWorkingDirectoryFallbackTests.swift; sourceTree = "<group>"; };
E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceTitleProvenanceTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2851,6 +2853,7 @@
71F8ED91A4B55D34BE6A0668 /* WorkspaceUnitTests.swift */,
CE74DFAC5946F9C2EEDBE577 /* WorkspaceCustomSidebarPullRequestContextTests.swift */,
71F8ED92A4B55D34BE6A0668 /* WorkspaceSplitStartupCommandTests.swift */,
6342F0C20000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift */,
6047C0DE6047C0DE60470002 /* WorkspaceTerminalTabWorkingDirectoryTests.swift */,
EF7347934AB1BD387686EE43 /* WorkspaceActionDispatcherTests.swift */,
E241BE1D74A7459C8D9C16BC /* WorkspaceTitleProvenanceTests.swift */,
Expand Down Expand Up @@ -4330,6 +4333,7 @@
6B524A0CB34FD46A771335AB /* WorkspaceSplitStartupCommandTests.swift in Sources */,
F6120000A1B2C3D4E5F60718 /* WorkspaceSSHFishShellTests.swift in Sources */,
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
6342F0C10000000000000001 /* WorkspaceTerminalFocusRecoveryTests.swift in Sources */,
6047C0DE6047C0DE60470001 /* WorkspaceTerminalTabWorkingDirectoryTests.swift in Sources */,
634AA6233A5BBF781FE83E89 /* WorkspaceTerminalWorkingDirectoryFallbackTests.swift in Sources */,
440AC5D24915463C9670F510 /* WorkspaceTitleProvenanceTests.swift in Sources */,
Expand Down
Loading
Loading