Skip to content
104 changes: 86 additions & 18 deletions supacode/Features/Terminal/Models/WorktreeTerminalState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ final class WorktreeTerminalState {
let isFocused: Bool
}

struct SurfaceRenderContext: Equatable {
let isSelectedTab: Bool
let windowIsVisible: Bool
let windowIsKey: Bool
let focusedSurfaceID: UUID?
let firstResponderSurfaceID: UUID?
}

let tabManager: TerminalTabManager
private let runtime: GhosttyRuntime
private let worktree: Worktree
Expand Down Expand Up @@ -46,6 +54,15 @@ final class WorktreeTerminalState {
var hasUnseenNotification: Bool {
notifications.contains { !$0.isRead }
}
var surfaceIDs: Set<UUID> {
Set(surfaces.keys)
}
var focusFollowsMouseEnabled: Bool {
runtime.focusFollowsMouse()
}
var visibleSurfaceIDUnderMouse: UUID? {
selectedVisibleSurfaceIDUnderMouse()
}
#if DEBUG
var debugRecentHookCount: Int {
recentHookBySurfaceID.count
Expand Down Expand Up @@ -316,42 +333,92 @@ final class WorktreeTerminalState {

private func applySurfaceActivity() {
let selectedTabId = tabManager.selectedTabId
var surfaceToFocus: GhosttySurfaceView?
let firstResponderSurfaceID = currentFirstResponderSurfaceID()
let windowIsVisible = lastWindowIsVisible == true
let windowIsKey = lastWindowIsKey == true
for (tabId, tree) in trees {
let focusedId = focusedSurfaceIdByTab[tabId]
let isSelectedTab = (tabId == selectedTabId)
let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id))
let renderContext = SurfaceRenderContext(
isSelectedTab: tabId == selectedTabId,
windowIsVisible: windowIsVisible,
windowIsKey: windowIsKey,
focusedSurfaceID: focusedSurfaceIdByTab[tabId],
firstResponderSurfaceID: 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,
renderContext: renderContext,
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: 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
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT — this chains a few optional lookups and any of them returning nil means no surface gets focused in the entire worktree. Totally fine for the firstResponder as? GhosttySurfaceView cast (expected when focus is on a non-terminal view), but the case where trees[selectedTabId] is nil would mean a data inconsistency that'd be super hard to debug with no logging.

Could we add a SupaLogger.warning just for that one case? We already log for layout and blocking scripts, so it'd be consistent. Let me know if you think it's overkill tbh.


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,
isSelectedTab: Bool,
windowIsVisible: Bool,
windowIsKey: Bool,
focusedSurfaceID: UUID?,
renderContext: SurfaceRenderContext,
surfaceID: UUID
) -> SurfaceActivity {
let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible
let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID
let isVisible =
isSurfaceVisibleInTree
&& renderContext.isSelectedTab
&& renderContext.windowIsVisible
let isFocused =
isVisible
&& renderContext.windowIsKey
&& renderContext.focusedSurfaceID == surfaceID
&& renderContext.firstResponderSurfaceID == surfaceID
return SurfaceActivity(isVisible: isVisible, isFocused: isFocused)
}

Expand Down Expand Up @@ -1027,6 +1094,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)
Expand Down
71 changes: 61 additions & 10 deletions supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,27 +63,72 @@ struct WorktreeTerminalTabsView: View {
)
.onAppear {
state.ensureInitialTab(focusing: false)
if shouldAutoFocusTerminal {
state.focusSelectedTab()
}
applyKeyboardFocus(for: state)
let activity = resolvedWindowActivity
state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible)
}
.onChange(of: state.tabManager.selectedTabId) { _, _ in
if shouldAutoFocusTerminal {
state.focusSelectedTab()
}
applyKeyboardFocus(for: state)
let activity = resolvedWindowActivity
state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible)
}
}

private var shouldAutoFocusTerminal: Bool {
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,
focusFollowsMouseEnabled: state.focusFollowsMouseEnabled,
hoveredSurfaceID: state.visibleSurfaceIDUnderMouse
)
}

static func focusTarget(
forceAutoFocus: Bool,
responder: NSResponder?,
ownedSurfaceIDs: Set<UUID>,
focusFollowsMouseEnabled: Bool,
hoveredSurfaceID: UUID?
) -> FocusTarget {
if focusFollowsMouseEnabled, let hoveredSurfaceID {
return .hoveredSurface(hoveredSurfaceID)
}
if forceAutoFocus {
return true
return .selectedTab
}
guard let responder else { return .selectedTab }
if responder is NSTableView || responder is NSOutlineView {
return .none
}
guard let surface = responder as? GhosttySurfaceView else {
return .selectedTab
}
guard let responder = NSApp.keyWindow?.firstResponder else { return true }
return !(responder is NSTableView) && !(responder is NSOutlineView)
return ownedSurfaceIDs.contains(surface.id) ? .selectedTab : .none
}

private var resolvedWindowActivity: WindowActivityState {
Expand Down
3 changes: 3 additions & 0 deletions supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion supacodeTests/TerminalLayoutSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ struct TerminalLayoutSnapshotTests {
tintColor: nil,
layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(workingDirectory: "/home")),
focusedLeafIndex: 0
),
)
],
selectedTabIndex: 0
)
Expand Down
Loading
Loading