From 2962ecad0bdb16ef27680f225a6efedaee4ae1fa Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 2 Apr 2026 19:10:44 +0300 Subject: [PATCH 1/7] fix terminal pane focus sync without auto-focus --- .../Models/WorktreeTerminalState.swift | 28 +++++++--- .../TerminalRenderingPolicyTests.swift | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index bf35ffb40..e1bb26792 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -263,7 +263,7 @@ final class WorktreeTerminalState { private func applySurfaceActivity() { let selectedTabId = tabManager.selectedTabId - var surfaceToFocus: GhosttySurfaceView? + let firstResponderSurfaceID = currentFirstResponderSurfaceID() for (tabId, tree) in trees { let focusedId = focusedSurfaceIdByTab[tabId] let isSelectedTab = (tabId == selectedTabId) @@ -275,18 +275,24 @@ final class WorktreeTerminalState { windowIsVisible: lastWindowIsVisible == true, windowIsKey: lastWindowIsKey == true, focusedSurfaceID: focusedId, + firstResponderSurfaceID: firstResponderSurfaceID, surfaceID: surface.id ) surface.setOcclusion(activity.isVisible) surface.focusDidChange(activity.isFocused) - if activity.isFocused { - surfaceToFocus = surface - } } } - if let surfaceToFocus, surfaceToFocus.window?.firstResponder is GhosttySurfaceView { - surfaceToFocus.window?.makeFirstResponder(surfaceToFocus) - } + } + + private func currentFirstResponderSurfaceID() -> UUID? { + let selectedWindow = + tabManager.selectedTabId + .flatMap { trees[$0] } + .flatMap { tree in + tree.leaves().compactMap(\.window).first + } + let window = selectedWindow ?? surfaces.values.compactMap(\.window).first + return (window?.firstResponder as? GhosttySurfaceView)?.id } static func surfaceActivity( @@ -295,10 +301,15 @@ final class WorktreeTerminalState { windowIsVisible: Bool, windowIsKey: Bool, focusedSurfaceID: UUID?, + firstResponderSurfaceID: UUID?, surfaceID: UUID ) -> SurfaceActivity { let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible - let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID + let isFocused = + isVisible + && windowIsKey + && focusedSurfaceID == surfaceID + && firstResponderSurfaceID == surfaceID return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) } @@ -949,6 +960,7 @@ final class WorktreeTerminalState { view.onFocusChange = { [weak self, weak view] focused in guard let self, let view, focused else { return } self.focusedSurfaceIdByTab[tabId] = view.id + self.syncFocusIfNeeded() self.markNotificationsRead(forSurfaceID: view.id) self.updateTabTitle(for: tabId) self.emitFocusChangedIfNeeded(view.id) diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index 06357bc28..afa2d0fbf 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -13,6 +13,7 @@ struct TerminalRenderingPolicyTests { windowIsVisible: true, windowIsKey: true, focusedSurfaceID: focusedID, + firstResponderSurfaceID: focusedID, surfaceID: focusedID ) #expect(activity.isVisible) @@ -26,6 +27,7 @@ struct TerminalRenderingPolicyTests { windowIsVisible: true, windowIsKey: true, focusedSurfaceID: UUID(), + firstResponderSurfaceID: UUID(), surfaceID: UUID() ) #expect(activity.isVisible) @@ -40,6 +42,7 @@ struct TerminalRenderingPolicyTests { windowIsVisible: true, windowIsKey: false, focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID, surfaceID: surfaceID ) #expect(activity.isVisible) @@ -54,6 +57,7 @@ struct TerminalRenderingPolicyTests { windowIsVisible: false, windowIsKey: true, focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID, surfaceID: surfaceID ) #expect(!activity.isVisible) @@ -68,6 +72,7 @@ struct TerminalRenderingPolicyTests { windowIsVisible: true, windowIsKey: true, focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID, surfaceID: surfaceID ) #expect(!activity.isVisible) @@ -82,12 +87,58 @@ struct TerminalRenderingPolicyTests { windowIsVisible: true, windowIsKey: true, focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID, surfaceID: surfaceID ) #expect(!activity.isVisible) #expect(!activity.isFocused) } + @Test func surfaceActivityRequiresTerminalFirstResponderToMatchFocusIntent() { + let intendedSurfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + isSelectedTab: true, + windowIsVisible: true, + windowIsKey: true, + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil, + surfaceID: intendedSurfaceID + ) + + #expect(activity.isVisible) + #expect(!activity.isFocused) + } + + @Test func worktreeSwitchWithoutTerminalAutoFocusKeepsAllVisiblePanesUnfocused() { + let intendedSurfaceID = UUID() + let siblingSurfaceID = UUID() + + let intendedActivity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + isSelectedTab: true, + windowIsVisible: true, + windowIsKey: true, + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil, + surfaceID: intendedSurfaceID + ) + let siblingActivity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + isSelectedTab: true, + windowIsVisible: true, + windowIsKey: true, + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil, + surfaceID: siblingSurfaceID + ) + + #expect(intendedActivity.isVisible) + #expect(!intendedActivity.isFocused) + #expect(siblingActivity.isVisible) + #expect(!siblingActivity.isFocused) + } + @Test func tabContentStackReturnsSelectedTabWhenItExists() { let selected = TerminalTabID() let tabs = [ From d92d9663dfd20eec20d05e2e2df9d002d91dedf0 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 2 Apr 2026 19:23:45 +0300 Subject: [PATCH 2/7] test pane focus reconciliation flow --- .../WorktreeTerminalManagerTests.swift | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 053759391..6c59322c7 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @@ -648,6 +649,56 @@ struct WorktreeTerminalManagerTests { #expect(state.tabManager.selectedTabId == selectedBefore) } + @Test func focusChangedEventReconcilesResponderDrivenPaneSwitch() async { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + state.pendingLayoutSnapshot = makeTwoPaneLayoutSnapshot() + state.ensureInitialTab(focusing: false) + + let stream = manager.eventStream() + + guard let tabId = state.tabManager.selectedTabId else { + Issue.record("Expected selected tab") + return + } + + let leaves = state.splitTree(for: tabId).visibleLeaves() + guard leaves.count == 2 else { + Issue.record("Expected two visible panes") + return + } + + let firstPane = leaves[0] + let secondPane = leaves[1] + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let container = NSView(frame: window.contentView?.bounds ?? .zero) + container.autoresizingMask = [.width, .height] + window.contentView = container + firstPane.frame = NSRect(x: 0, y: 0, width: 400, height: 600) + secondPane.frame = NSRect(x: 400, y: 0, width: 400, height: 600) + container.addSubview(firstPane) + container.addSubview(secondPane) + + #expect(window.makeFirstResponder(firstPane)) + state.syncFocus(windowIsKey: true, windowIsVisible: true) + + #expect(window.makeFirstResponder(secondPane)) + + let event = await nextEvent(stream) { event in + event == .focusChanged(worktreeID: worktree.id, surfaceID: secondPane.id) + } + + #expect(event == .focusChanged(worktreeID: worktree.id, surfaceID: secondPane.id)) + #expect((window.firstResponder as? GhosttySurfaceView) === secondPane) + #expect(state.captureLayoutSnapshot()?.tabs.first?.focusedLeafIndex == 1) + } + private func makeWorktree() -> Worktree { Worktree( id: "/tmp/repo/wt-1", @@ -698,4 +749,34 @@ struct WorktreeTerminalManagerTests { selectedTabIndex: 0 ) } + + private func makeTwoPaneLayoutSnapshot() -> TerminalLayoutSnapshot { + TerminalLayoutSnapshot( + tabs: [ + TerminalLayoutSnapshot.TabSnapshot( + title: "Terminal 1", + icon: nil, + tintColor: nil, + layout: .split( + TerminalLayoutSnapshot.SplitSnapshot( + direction: .horizontal, + ratio: 0.5, + left: .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot( + workingDirectory: "/tmp/repo/wt-1" + ) + ), + right: .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot( + workingDirectory: "/tmp/repo/wt-1" + ) + ) + ) + ), + focusedLeafIndex: 0 + ), + ], + selectedTabIndex: 0 + ) + } } From 029cea5fb2a59a3442549eb1d4c28202c0cab535 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 2 Apr 2026 19:39:35 +0300 Subject: [PATCH 3/7] linter applied --- supacodeTests/TerminalLayoutSnapshotTests.swift | 2 +- supacodeTests/WorktreeTerminalManagerTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/supacodeTests/TerminalLayoutSnapshotTests.swift b/supacodeTests/TerminalLayoutSnapshotTests.swift index 1bfe86008..da5c54faa 100644 --- a/supacodeTests/TerminalLayoutSnapshotTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotTests.swift @@ -86,7 +86,7 @@ struct TerminalLayoutSnapshotTests { tintColor: nil, layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/home")), focusedLeafIndex: 0 - ), + ) ], selectedTabIndex: 0 ) diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 6c59322c7..1e0be0900 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -744,7 +744,7 @@ struct WorktreeTerminalManagerTests { ) ), focusedLeafIndex: 0 - ), + ) ], selectedTabIndex: 0 ) @@ -774,7 +774,7 @@ struct WorktreeTerminalManagerTests { ) ), focusedLeafIndex: 0 - ), + ) ], selectedTabIndex: 0 ) From 1333a777b0b2775af0b426609c43268bb83f5445 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 2 Apr 2026 20:28:48 +0300 Subject: [PATCH 4/7] fix terminal focus lint context --- .../Models/WorktreeTerminalState.swift | 60 ++++++++--- .../TerminalRenderingPolicyTests.swift | 102 ++++++++++-------- 2 files changed, 101 insertions(+), 61 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index e1bb26792..9ce5e4ee4 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -16,6 +16,18 @@ final class WorktreeTerminalState { let isFocused: Bool } + struct SurfaceFocusContext: Equatable { + let focusedSurfaceID: UUID? + let firstResponderSurfaceID: UUID? + } + + struct SurfaceRenderContext: Equatable { + let isSelectedTab: Bool + let windowIsVisible: Bool + let windowIsKey: Bool + let focus: SurfaceFocusContext + } + let tabManager: TerminalTabManager private let runtime: GhosttyRuntime private let worktree: Worktree @@ -264,18 +276,35 @@ final class WorktreeTerminalState { private func applySurfaceActivity() { let selectedTabId = tabManager.selectedTabId let firstResponderSurfaceID = currentFirstResponderSurfaceID() + let windowRenderContext = SurfaceRenderContext( + isSelectedTab: false, + windowIsVisible: lastWindowIsVisible == true, + windowIsKey: lastWindowIsKey == true, + focus: SurfaceFocusContext( + focusedSurfaceID: nil, + firstResponderSurfaceID: firstResponderSurfaceID + ) + ) + let baseFocusContext = SurfaceFocusContext( + focusedSurfaceID: nil, + firstResponderSurfaceID: firstResponderSurfaceID + ) for (tabId, tree) in trees { - let focusedId = focusedSurfaceIdByTab[tabId] - let isSelectedTab = (tabId == selectedTabId) let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) + var renderContext = windowRenderContext + renderContext = SurfaceRenderContext( + isSelectedTab: tabId == selectedTabId, + windowIsVisible: renderContext.windowIsVisible, + windowIsKey: renderContext.windowIsKey, + focus: SurfaceFocusContext( + focusedSurfaceID: focusedSurfaceIdByTab[tabId], + firstResponderSurfaceID: baseFocusContext.firstResponderSurfaceID + ) + ) for surface in tree.leaves() { let activity = Self.surfaceActivity( isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), - isSelectedTab: isSelectedTab, - windowIsVisible: lastWindowIsVisible == true, - windowIsKey: lastWindowIsKey == true, - focusedSurfaceID: focusedId, - firstResponderSurfaceID: firstResponderSurfaceID, + renderContext: renderContext, surfaceID: surface.id ) surface.setOcclusion(activity.isVisible) @@ -297,19 +326,18 @@ final class WorktreeTerminalState { static func surfaceActivity( isSurfaceVisibleInTree: Bool = true, - isSelectedTab: Bool, - windowIsVisible: Bool, - windowIsKey: Bool, - focusedSurfaceID: UUID?, - firstResponderSurfaceID: UUID?, + renderContext: SurfaceRenderContext, surfaceID: UUID ) -> SurfaceActivity { - let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible + let isVisible = + isSurfaceVisibleInTree + && renderContext.isSelectedTab + && renderContext.windowIsVisible let isFocused = isVisible - && windowIsKey - && focusedSurfaceID == surfaceID - && firstResponderSurfaceID == surfaceID + && renderContext.windowIsKey + && renderContext.focus.focusedSurfaceID == surfaceID + && renderContext.focus.firstResponderSurfaceID == surfaceID return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) } diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index afa2d0fbf..87c5a2173 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -5,15 +5,32 @@ import Testing @MainActor struct TerminalRenderingPolicyTests { + private func renderContext( + isSelectedTab: Bool = true, + windowIsVisible: Bool = true, + windowIsKey: Bool = true, + focusedSurfaceID: UUID?, + firstResponderSurfaceID: UUID? + ) -> WorktreeTerminalState.SurfaceRenderContext { + WorktreeTerminalState.SurfaceRenderContext( + isSelectedTab: isSelectedTab, + windowIsVisible: windowIsVisible, + windowIsKey: windowIsKey, + focus: WorktreeTerminalState.SurfaceFocusContext( + focusedSurfaceID: focusedSurfaceID, + firstResponderSurfaceID: firstResponderSurfaceID + ) + ) + } + @Test func surfaceActivityForSelectedVisibleFocusedSurfaceIsFocused() { let focusedID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: focusedID, - firstResponderSurfaceID: focusedID, + renderContext: renderContext( + focusedSurfaceID: focusedID, + firstResponderSurfaceID: focusedID + ), surfaceID: focusedID ) #expect(activity.isVisible) @@ -23,11 +40,10 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForSelectedVisibleUnfocusedSurfaceIsNotFocused() { let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: UUID(), - firstResponderSurfaceID: UUID(), + renderContext: renderContext( + focusedSurfaceID: UUID(), + firstResponderSurfaceID: UUID() + ), surfaceID: UUID() ) #expect(activity.isVisible) @@ -38,11 +54,11 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: false, - focusedSurfaceID: surfaceID, - firstResponderSurfaceID: surfaceID, + renderContext: renderContext( + windowIsKey: false, + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(activity.isVisible) @@ -53,11 +69,11 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: false, - windowIsKey: true, - focusedSurfaceID: surfaceID, - firstResponderSurfaceID: surfaceID, + renderContext: renderContext( + windowIsVisible: false, + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(!activity.isVisible) @@ -68,11 +84,11 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: false, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: surfaceID, - firstResponderSurfaceID: surfaceID, + renderContext: renderContext( + isSelectedTab: false, + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(!activity.isVisible) @@ -83,11 +99,10 @@ struct TerminalRenderingPolicyTests { let surfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: false, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: surfaceID, - firstResponderSurfaceID: surfaceID, + renderContext: renderContext( + focusedSurfaceID: surfaceID, + firstResponderSurfaceID: surfaceID + ), surfaceID: surfaceID ) #expect(!activity.isVisible) @@ -98,11 +113,10 @@ struct TerminalRenderingPolicyTests { let intendedSurfaceID = UUID() let activity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: intendedSurfaceID, - firstResponderSurfaceID: nil, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil + ), surfaceID: intendedSurfaceID ) @@ -116,20 +130,18 @@ struct TerminalRenderingPolicyTests { let intendedActivity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: intendedSurfaceID, - firstResponderSurfaceID: nil, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil + ), surfaceID: intendedSurfaceID ) let siblingActivity = WorktreeTerminalState.surfaceActivity( isSurfaceVisibleInTree: true, - isSelectedTab: true, - windowIsVisible: true, - windowIsKey: true, - focusedSurfaceID: intendedSurfaceID, - firstResponderSurfaceID: nil, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: nil + ), surfaceID: siblingSurfaceID ) From d84e8ccd3d7c4e1ba2783d6b6abc71dc7ea560ee Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Fri, 3 Apr 2026 17:03:00 +0300 Subject: [PATCH 5/7] address terminal focus review --- .../Models/WorktreeTerminalState.swift | 57 ++++++++----------- .../TerminalRenderingPolicyTests.swift | 22 +++++-- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 9ce5e4ee4..108be8a8d 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -16,16 +16,12 @@ final class WorktreeTerminalState { let isFocused: Bool } - struct SurfaceFocusContext: Equatable { - let focusedSurfaceID: UUID? - let firstResponderSurfaceID: UUID? - } - struct SurfaceRenderContext: Equatable { let isSelectedTab: Bool let windowIsVisible: Bool let windowIsKey: Bool - let focus: SurfaceFocusContext + let focusedSurfaceID: UUID? + let firstResponderSurfaceID: UUID? } let tabManager: TerminalTabManager @@ -276,30 +272,16 @@ final class WorktreeTerminalState { private func applySurfaceActivity() { let selectedTabId = tabManager.selectedTabId let firstResponderSurfaceID = currentFirstResponderSurfaceID() - let windowRenderContext = SurfaceRenderContext( - isSelectedTab: false, - windowIsVisible: lastWindowIsVisible == true, - windowIsKey: lastWindowIsKey == true, - focus: SurfaceFocusContext( - focusedSurfaceID: nil, - firstResponderSurfaceID: firstResponderSurfaceID - ) - ) - let baseFocusContext = SurfaceFocusContext( - focusedSurfaceID: nil, - firstResponderSurfaceID: firstResponderSurfaceID - ) + let windowIsVisible = lastWindowIsVisible == true + let windowIsKey = lastWindowIsKey == true for (tabId, tree) in trees { let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) - var renderContext = windowRenderContext - renderContext = SurfaceRenderContext( + let renderContext = SurfaceRenderContext( isSelectedTab: tabId == selectedTabId, - windowIsVisible: renderContext.windowIsVisible, - windowIsKey: renderContext.windowIsKey, - focus: SurfaceFocusContext( - focusedSurfaceID: focusedSurfaceIdByTab[tabId], - firstResponderSurfaceID: baseFocusContext.firstResponderSurfaceID - ) + windowIsVisible: windowIsVisible, + windowIsKey: windowIsKey, + focusedSurfaceID: focusedSurfaceIdByTab[tabId], + firstResponderSurfaceID: firstResponderSurfaceID ) for surface in tree.leaves() { let activity = Self.surfaceActivity( @@ -314,12 +296,19 @@ final class WorktreeTerminalState { } private func currentFirstResponderSurfaceID() -> UUID? { - let selectedWindow = - tabManager.selectedTabId - .flatMap { trees[$0] } - .flatMap { tree in - tree.leaves().compactMap(\.window).first + let selectedWindow: NSWindow? + if let selectedTabId = tabManager.selectedTabId { + guard let selectedTree = trees[selectedTabId] else { + layoutLogger.warning( + "Missing split tree for selected tab \(selectedTabId.rawValue) in worktree \(worktree.id)" + ) + let window = surfaces.values.compactMap(\.window).first + return (window?.firstResponder as? GhosttySurfaceView)?.id } + selectedWindow = selectedTree.leaves().compactMap(\.window).first + } else { + selectedWindow = nil + } let window = selectedWindow ?? surfaces.values.compactMap(\.window).first return (window?.firstResponder as? GhosttySurfaceView)?.id } @@ -336,8 +325,8 @@ final class WorktreeTerminalState { let isFocused = isVisible && renderContext.windowIsKey - && renderContext.focus.focusedSurfaceID == surfaceID - && renderContext.focus.firstResponderSurfaceID == surfaceID + && renderContext.focusedSurfaceID == surfaceID + && renderContext.firstResponderSurfaceID == surfaceID return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) } diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index 87c5a2173..06c1c6189 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -16,10 +16,8 @@ struct TerminalRenderingPolicyTests { isSelectedTab: isSelectedTab, windowIsVisible: windowIsVisible, windowIsKey: windowIsKey, - focus: WorktreeTerminalState.SurfaceFocusContext( - focusedSurfaceID: focusedSurfaceID, - firstResponderSurfaceID: firstResponderSurfaceID - ) + focusedSurfaceID: focusedSurfaceID, + firstResponderSurfaceID: firstResponderSurfaceID ) } @@ -124,6 +122,22 @@ struct TerminalRenderingPolicyTests { #expect(!activity.isFocused) } + @Test func surfaceActivityDoesNotFocusWhenSiblingPaneOwnsFirstResponder() { + let intendedSurfaceID = UUID() + let siblingSurfaceID = UUID() + let activity = WorktreeTerminalState.surfaceActivity( + isSurfaceVisibleInTree: true, + renderContext: renderContext( + focusedSurfaceID: intendedSurfaceID, + firstResponderSurfaceID: siblingSurfaceID + ), + surfaceID: intendedSurfaceID + ) + + #expect(activity.isVisible) + #expect(!activity.isFocused) + } + @Test func worktreeSwitchWithoutTerminalAutoFocusKeepsAllVisiblePanesUnfocused() { let intendedSurfaceID = UUID() let siblingSurfaceID = UUID() From a6c734e2232b4f738c915f62e951e012d4e32fa2 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Sat, 4 Apr 2026 16:23:07 +0300 Subject: [PATCH 6/7] fix consistent worktree terminal autofocus --- .../Models/WorktreeTerminalState.swift | 3 ++ .../Views/WorktreeTerminalTabsView.swift | 28 ++++++++++++--- .../TerminalRenderingPolicyTests.swift | 34 +++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 108be8a8d..de52b1d5e 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -48,6 +48,9 @@ final class WorktreeTerminalState { var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } } + var surfaceIDs: Set { + Set(surfaces.keys) + } var isSelected: () -> Bool = { false } var onNotificationReceived: ((String, String) -> Void)? var onNotificationIndicatorChanged: (() -> Void)? diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 308712457..d911ddc63 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -53,14 +53,14 @@ struct WorktreeTerminalTabsView: View { ) .onAppear { state.ensureInitialTab(focusing: false) - if shouldAutoFocusTerminal { + if shouldAutoFocusTerminal(for: state) { state.focusSelectedTab() } let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } .onChange(of: state.tabManager.selectedTabId) { _, _ in - if shouldAutoFocusTerminal { + if shouldAutoFocusTerminal(for: state) { state.focusSelectedTab() } let activity = resolvedWindowActivity @@ -68,12 +68,30 @@ struct WorktreeTerminalTabsView: View { } } - private var shouldAutoFocusTerminal: Bool { + private func shouldAutoFocusTerminal(for state: WorktreeTerminalState) -> Bool { + Self.shouldAutoFocusTerminal( + forceAutoFocus: forceAutoFocus, + responder: NSApp.keyWindow?.firstResponder, + ownedSurfaceIDs: state.surfaceIDs + ) + } + + static func shouldAutoFocusTerminal( + forceAutoFocus: Bool, + responder: NSResponder?, + ownedSurfaceIDs: Set + ) -> Bool { if forceAutoFocus { return true } - guard let responder = NSApp.keyWindow?.firstResponder else { return true } - return !(responder is NSTableView) && !(responder is NSOutlineView) + guard let responder else { return true } + if responder is NSTableView || responder is NSOutlineView { + return false + } + guard let surface = responder as? GhosttySurfaceView else { + return true + } + return ownedSurfaceIDs.contains(surface.id) } private var resolvedWindowActivity: WindowActivityState { diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index 06c1c6189..c93d0dc15 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -1,3 +1,4 @@ +import GhosttyKit import SwiftUI import Testing @@ -165,6 +166,39 @@ struct TerminalRenderingPolicyTests { #expect(!siblingActivity.isFocused) } + @Test func worktreeSwitchDoesNotAutoFocusFromDifferentWorktreeTerminalResponder() { + let currentSurfaceID = UUID() + let foreignSurface = GhosttySurfaceView( + runtime: GhosttyRuntime(), + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + + let shouldAutoFocus = WorktreeTerminalTabsView.shouldAutoFocusTerminal( + forceAutoFocus: false, + responder: foreignSurface, + ownedSurfaceIDs: [currentSurfaceID] + ) + + #expect(!shouldAutoFocus) + } + + @Test func currentWorktreeTerminalResponderPreservesAutoFocusOnTabSwitch() { + let currentSurface = GhosttySurfaceView( + runtime: GhosttyRuntime(), + workingDirectory: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + + let shouldAutoFocus = WorktreeTerminalTabsView.shouldAutoFocusTerminal( + forceAutoFocus: false, + responder: currentSurface, + ownedSurfaceIDs: [currentSurface.id] + ) + + #expect(shouldAutoFocus) + } + @Test func tabContentStackReturnsSelectedTabWhenItExists() { let selected = TerminalTabID() let tabs = [ From 1ed68fa3288e513477df5b56534bc7787f842b4a Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Tue, 7 Apr 2026 14:58:25 +0300 Subject: [PATCH 7/7] fix focus follows mouse on worktree switch --- .../Models/WorktreeTerminalState.swift | 36 ++++++++++ .../Views/WorktreeTerminalTabsView.swift | 67 ++++++++++++++----- .../Ghostty/GhosttySurfaceView.swift | 3 + .../TerminalRenderingPolicyTests.swift | 42 ++++++++++-- 4 files changed, 125 insertions(+), 23 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 276c01fdb..6360a77f0 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -57,6 +57,12 @@ final class WorktreeTerminalState { var surfaceIDs: Set { Set(surfaces.keys) } + var focusFollowsMouseEnabled: Bool { + runtime.focusFollowsMouse() + } + var visibleSurfaceIDUnderMouse: UUID? { + selectedVisibleSurfaceIDUnderMouse() + } #if DEBUG var debugRecentHookCount: Int { recentHookBySurfaceID.count @@ -369,6 +375,36 @@ final class WorktreeTerminalState { return (window?.firstResponder as? GhosttySurfaceView)?.id } + private func selectedVisibleSurfaceIDUnderMouse() -> UUID? { + guard focusFollowsMouseEnabled, + let selectedTabId = tabManager.selectedTabId, + let tree = trees[selectedTabId] + else { + return nil + } + + let visibleLeaves = tree.visibleLeaves() + guard !visibleLeaves.isEmpty else { return nil } + + let window = visibleLeaves.compactMap(\.window).first ?? NSApp.keyWindow + guard let window, let contentView = window.contentView else { return nil } + + let locationInContent = contentView.convert(window.mouseLocationOutsideOfEventStream, from: nil) + var hitView = contentView.hitTest(locationInContent) + let visibleSurfaceIDs = Set(visibleLeaves.map(\.id)) + + while let currentView = hitView { + if let surface = currentView as? GhosttySurfaceView, + visibleSurfaceIDs.contains(surface.id) + { + return surface.id + } + hitView = currentView.superview + } + + return nil + } + static func surfaceActivity( isSurfaceVisibleInTree: Bool = true, renderContext: SurfaceRenderContext, diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 65a82edc1..ed856dcd4 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -2,6 +2,12 @@ import AppKit import SwiftUI struct WorktreeTerminalTabsView: View { + enum FocusTarget: Equatable { + case hoveredSurface(UUID) + case selectedTab + case none + } + let worktree: Worktree let manager: WorktreeTerminalManager let shouldRunSetupScript: Bool @@ -57,45 +63,72 @@ struct WorktreeTerminalTabsView: View { ) .onAppear { state.ensureInitialTab(focusing: false) - if shouldAutoFocusTerminal(for: state) { - state.focusSelectedTab() - } + applyKeyboardFocus(for: state) let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } .onChange(of: state.tabManager.selectedTabId) { _, _ in - if shouldAutoFocusTerminal(for: state) { - state.focusSelectedTab() - } + applyKeyboardFocus(for: state) let activity = resolvedWindowActivity state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) } } - private func shouldAutoFocusTerminal(for state: WorktreeTerminalState) -> Bool { - Self.shouldAutoFocusTerminal( + private func applyKeyboardFocus(for state: WorktreeTerminalState) { + apply(focusTarget(for: state), to: state) + + guard state.focusFollowsMouseEnabled else { return } + Task { @MainActor in + await Task.yield() + guard state.isSelected() else { return } + if case .hoveredSurface(let surfaceID) = focusTarget(for: state) { + _ = state.focusSurface(id: surfaceID) + } + } + } + + private func apply(_ focusTarget: FocusTarget, to state: WorktreeTerminalState) { + switch focusTarget { + case .hoveredSurface(let surfaceID): + _ = state.focusSurface(id: surfaceID) + case .selectedTab: + state.focusSelectedTab() + case .none: + break + } + } + + private func focusTarget(for state: WorktreeTerminalState) -> FocusTarget { + Self.focusTarget( forceAutoFocus: forceAutoFocus, responder: NSApp.keyWindow?.firstResponder, - ownedSurfaceIDs: state.surfaceIDs + ownedSurfaceIDs: state.surfaceIDs, + focusFollowsMouseEnabled: state.focusFollowsMouseEnabled, + hoveredSurfaceID: state.visibleSurfaceIDUnderMouse ) } - static func shouldAutoFocusTerminal( + static func focusTarget( forceAutoFocus: Bool, responder: NSResponder?, - ownedSurfaceIDs: Set - ) -> Bool { + ownedSurfaceIDs: Set, + focusFollowsMouseEnabled: Bool, + hoveredSurfaceID: UUID? + ) -> FocusTarget { + if focusFollowsMouseEnabled, let hoveredSurfaceID { + return .hoveredSurface(hoveredSurfaceID) + } if forceAutoFocus { - return true + return .selectedTab } - guard let responder else { return true } + guard let responder else { return .selectedTab } if responder is NSTableView || responder is NSOutlineView { - return false + return .none } guard let surface = responder as? GhosttySurfaceView else { - return true + return .selectedTab } - return ownedSurfaceIDs.contains(surface.id) + return ownedSurfaceIDs.contains(surface.id) ? .selectedTab : .none } private var resolvedWindowActivity: WindowActivityState { diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 230de30f0..76a9c56c8 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -654,6 +654,9 @@ final class GhosttySurfaceView: NSView, Identifiable { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) sendMousePosition(event) + if let window, window.isKeyWindow, !focused, runtime.focusFollowsMouse() { + requestFocus() + } } override func mouseExited(with event: NSEvent) { diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index acfb27aac..32f477962 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -1,3 +1,4 @@ +import AppKit import GhosttyKit import SwiftUI import Testing @@ -175,13 +176,15 @@ struct TerminalRenderingPolicyTests { context: GHOSTTY_SURFACE_CONTEXT_TAB ) - let shouldAutoFocus = WorktreeTerminalTabsView.shouldAutoFocusTerminal( + let focusTarget = WorktreeTerminalTabsView.focusTarget( forceAutoFocus: false, responder: foreignSurface, - ownedSurfaceIDs: [currentSurfaceID] + ownedSurfaceIDs: [currentSurfaceID], + focusFollowsMouseEnabled: false, + hoveredSurfaceID: nil ) - #expect(!shouldAutoFocus) + #expect(focusTarget == .none) } @Test func currentWorktreeTerminalResponderPreservesAutoFocusOnTabSwitch() { @@ -193,13 +196,40 @@ struct TerminalRenderingPolicyTests { context: GHOSTTY_SURFACE_CONTEXT_TAB ) - let shouldAutoFocus = WorktreeTerminalTabsView.shouldAutoFocusTerminal( + let focusTarget = WorktreeTerminalTabsView.focusTarget( forceAutoFocus: false, responder: currentSurface, - ownedSurfaceIDs: [currentSurfaceID] + ownedSurfaceIDs: [currentSurfaceID], + focusFollowsMouseEnabled: false, + hoveredSurfaceID: nil ) - #expect(shouldAutoFocus) + #expect(focusTarget == .selectedTab) + } + + @Test func sidebarSelectionTransfersFocusToHoveredPaneWhenMouseFocusIsEnabled() { + let hoveredSurfaceID = UUID() + let focusTarget = WorktreeTerminalTabsView.focusTarget( + forceAutoFocus: false, + responder: NSTableView(), + ownedSurfaceIDs: [hoveredSurfaceID], + focusFollowsMouseEnabled: true, + hoveredSurfaceID: hoveredSurfaceID + ) + + #expect(focusTarget == .hoveredSurface(hoveredSurfaceID)) + } + + @Test func sidebarSelectionKeepsKeyboardFocusWhenMouseFocusIsDisabled() { + let focusTarget = WorktreeTerminalTabsView.focusTarget( + forceAutoFocus: false, + responder: NSTableView(), + ownedSurfaceIDs: [UUID()], + focusFollowsMouseEnabled: false, + hoveredSurfaceID: UUID() + ) + + #expect(focusTarget == .none) } @Test func tabContentStackReturnsSelectedTabWhenItExists() {