Merge branch 'main' into issue-151-ssh-remote-port-proxying

This commit is contained in:
Lawrence Chen 2026-03-11 15:56:47 -07:00
commit d67090994e
61 changed files with 11220 additions and 614 deletions

View file

@ -708,6 +708,7 @@ class GhosttyApp {
private let backgroundLogLock = NSLock()
private var backgroundLogSequence: UInt64 = 0
private var appObservers: [NSObjectProtocol] = []
private var bellAudioSound: NSSound?
private var backgroundEventCounter: UInt64 = 0
private var defaultBackgroundUpdateScope: GhosttyDefaultBackgroundUpdateScope = .unscoped
private var defaultBackgroundScopeSource: String = "initialize"
@ -1524,6 +1525,75 @@ class GhosttyApp {
return found && enabled
}
func appleScriptAutomationEnabled() -> Bool {
guard let config else { return false }
var enabled = false
let key = "macos-applescript"
_ = ghostty_config_get(config, &enabled, key, UInt(key.lengthOfBytes(using: .utf8)))
return enabled
}
fileprivate func shellIntegrationMode() -> String {
guard let config else { return "detect" }
var value: UnsafePointer<Int8>?
let key = "shell-integration"
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
let value else {
return "detect"
}
return String(cString: value)
}
private func bellFeatures() -> CUnsignedInt {
guard let config else { return 0 }
var features: CUnsignedInt = 0
let key = "bell-features"
_ = ghostty_config_get(config, &features, key, UInt(key.lengthOfBytes(using: .utf8)))
return features
}
private func bellAudioPath() -> String? {
guard let config else { return nil }
var value = ghostty_config_path_s()
let key = "bell-audio-path"
guard ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8))),
let rawPath = value.path else {
return nil
}
let path = String(cString: rawPath)
return path.isEmpty ? nil : path
}
private func bellAudioVolume() -> Float {
guard let config else { return 0.5 }
var value: Double = 0.5
let key = "bell-audio-volume"
_ = ghostty_config_get(config, &value, key, UInt(key.lengthOfBytes(using: .utf8)))
return Float(min(1.0, max(0.0, value)))
}
private func ringBell() {
let features = bellFeatures()
if (features & (1 << 0)) != 0 {
NSSound.beep()
}
if (features & (1 << 1)) != 0,
let path = bellAudioPath(),
let sound = NSSound(contentsOfFile: path, byReference: false) {
sound.volume = bellAudioVolume()
bellAudioSound = sound
if !sound.play() {
bellAudioSound = nil
}
}
if (features & (1 << 2)) != 0 {
NSApp.requestUserAttention(.informationalRequest)
}
}
private func applyDefaultBackground(
color: NSColor,
opacity: Double,
@ -1690,6 +1760,13 @@ class GhosttyApp {
}
}
if action.tag == GHOSTTY_ACTION_RING_BELL {
performOnMain {
self.ringBell()
}
return true
}
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
let soft = action.action.reload_config.soft
logThemeAction("reload request target=app soft=\(soft)")
@ -1797,6 +1874,11 @@ class GhosttyApp {
guard let tabManager = AppDelegate.shared?.tabManager else { return false }
return tabManager.newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil
}
case GHOSTTY_ACTION_RING_BELL:
performOnMain {
self.ringBell()
}
return true
case GHOSTTY_ACTION_GOTO_SPLIT:
guard let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id,
@ -2766,6 +2848,9 @@ final class TerminalSurface: Identifiable, ObservableObject {
?? "/bin/zsh"
let shellName = URL(fileURLWithPath: shell).lastPathComponent
if shellName == "zsh" {
if GhosttyApp.shared.shellIntegrationMode() != "none" {
env["CMUX_LOAD_GHOSTTY_ZSH_INTEGRATION"] = "1"
}
let candidateZdotdir = (env["ZDOTDIR"]?.isEmpty == false ? env["ZDOTDIR"] : nil)
?? getenv("ZDOTDIR").map { String(cString: $0) }
?? (ProcessInfo.processInfo.environment["ZDOTDIR"]?.isEmpty == false ? ProcessInfo.processInfo.environment["ZDOTDIR"] : nil)
@ -3310,9 +3395,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var eventMonitor: Any?
private var trackingArea: NSTrackingArea?
private var windowObserver: NSObjectProtocol?
private var lastScrollEventTime: CFTimeInterval = 0
private var lastScrollEventTime: CFTimeInterval = 0
private var visibleInUI: Bool = true
private var pendingSurfaceSize: CGSize?
private var deferredSurfaceSizeRetryQueued = false
private var lastDrawableSize: CGSize = .zero
private var isFindEscapeSuppressionArmed = false
#if DEBUG
@ -3610,11 +3696,39 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return currentBounds
}
private static func hasActiveTabDragPasteboard() -> Bool {
private static func hasTabDragPasteboardTypes() -> Bool {
let types = NSPasteboard(name: .drag).types ?? []
return types.contains(tabTransferPasteboardType) || types.contains(sidebarTabReorderPasteboardType)
}
private static func isDragResizeEvent(_ eventType: NSEvent.EventType?) -> Bool {
switch eventType {
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return true
default:
return false
}
}
private static func shouldDeferSurfaceResizeForActiveDrag() -> Bool {
// The drag pasteboard can retain tab-transfer UTIs briefly after a split command
// or other layout churn. Only defer terminal resizes while an actual drag event
// is in flight; otherwise pre-existing panes can stay stuck at their old size.
guard hasTabDragPasteboardTypes() else { return false }
return isDragResizeEvent(NSApp.currentEvent?.type)
}
private func scheduleDeferredSurfaceSizeRetryIfNeeded() {
guard window != nil else { return }
guard !deferredSurfaceSizeRetryQueued else { return }
deferredSurfaceSizeRetryQueued = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.deferredSurfaceSizeRetryQueued = false
_ = self.updateSurfaceSize()
}
}
@discardableResult
private func updateSurfaceSize(size: CGSize? = nil) -> Bool {
guard let terminalSurface = terminalSurface else { return false }
@ -3634,7 +3748,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return false
}
pendingSurfaceSize = size
guard !Self.hasActiveTabDragPasteboard() else {
guard !Self.shouldDeferSurfaceResizeForActiveDrag() else {
scheduleDeferredSurfaceSizeRetryIfNeeded()
#if DEBUG
let signature = "tabDrag-\(Int(size.width.rounded()))x\(Int(size.height.rounded()))"
if lastSizeSkipSignature != signature {
@ -4376,6 +4491,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.keyDown(with: event)
return
}
if let terminalSurface {
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
tabId: terminalSurface.tabId,
surfaceId: terminalSurface.id
)
}
if event.keyCode != 53 {
endFindEscapeSuppression()
}
@ -4537,6 +4658,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
// Use accumulated text from insertText (for IME), or compute text for key
let accumulatedText = keyTextAccumulator ?? []
var shouldRefreshAfterTextInput = false
if !accumulatedText.isEmpty {
// Accumulated text comes from insertText (IME composition result).
// These never have "composing" set to true because these are the
@ -4544,6 +4666,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyEvent.composing = false
for text in accumulatedText {
if shouldSendText(text) {
shouldRefreshAfterTextInput = true
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
@ -4564,6 +4687,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
if let text = textForKeyEvent(translationEvent) {
if shouldSendText(text), !suppressShiftSpaceFallbackText {
shouldRefreshAfterTextInput = true
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
@ -4578,6 +4702,10 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
}
if shouldRefreshAfterTextInput {
terminalSurface?.forceRefresh(reason: "keyDown.textInput")
}
// Rendering is driven by Ghostty's wakeups/renderer.
}
@ -4817,11 +4945,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
#endif
private func requestPointerFocusRecovery() {
#if DEBUG
dlog("focus.pointerDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
#endif
onFocus?()
}
override func mouseDown(with event: NSEvent) {
#if DEBUG
let debugPoint = convert(event.locationInWindow, from: nil)
dlog("terminal.mouseDown surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") mods=[\(debugModifierString(event.modifierFlags))] clickCount=\(event.clickCount) point=(\(String(format: "%.0f", debugPoint.x)),\(String(format: "%.0f", debugPoint.y)))")
#endif
// Split reparent/layout churn can suppress the later `becomeFirstResponder -> onFocus`
// callback. Treat pointer-down as explicit focus intent so clicking a ghost pane still
// repairs workspace/pane active state before key routing runs.
requestPointerFocusRecovery()
window?.makeFirstResponder(self)
if let terminalSurface {
AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
@ -4846,10 +4985,12 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
override func rightMouseDown(with event: NSEvent) {
guard let surface = surface else { return }
if !ghostty_surface_mouse_captured(surface) {
requestPointerFocusRecovery()
super.rightMouseDown(with: event)
return
}
requestPointerFocusRecovery()
window?.makeFirstResponder(self)
let point = convert(event.locationInWindow, from: nil)
ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
@ -4871,6 +5012,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
super.otherMouseDown(with: event)
return
}
requestPointerFocusRecovery()
window?.makeFirstResponder(self)
guard let surface = surface else { return }
let point = convert(event.locationInWindow, from: nil)
@ -5330,6 +5472,7 @@ final class GhosttySurfaceScrollView: NSView {
private var isLiveScrolling = false
private var lastSentRow: Int?
private var isActive = true
private var lastFocusRefreshAt: CFTimeInterval = 0
private var activeDropZone: DropZone?
private var pendingDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
@ -6286,6 +6429,15 @@ final class GhosttySurfaceScrollView: NSView {
}
}
var debugPortalVisibleInUI: Bool {
surfaceView.isVisibleInUI
}
var debugPortalFrameInWindow: CGRect {
guard window != nil else { return .zero }
return convert(bounds, to: nil)
}
func setActive(_ active: Bool) {
let wasActive = isActive
isActive = active
@ -6301,10 +6453,8 @@ final class GhosttySurfaceScrollView: NSView {
#endif
if active {
scheduleAutomaticFirstResponderApply(reason: "setActive")
} else if let window,
let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
window.makeFirstResponder(nil)
} else {
resignOwnedFirstResponderIfNeeded(reason: "setActive(false)")
}
}
@ -6354,15 +6504,29 @@ final class GhosttySurfaceScrollView: NSView {
#if DEBUG
let surfaceShort = self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
let searchActive = self.surfaceView.terminalSurface?.searchState != nil
dlog("find.moveFocus to=\(surfaceShort) searchState=\(searchActive ? "active" : "nil")")
dlog(
"find.moveFocus to=\(surfaceShort) " +
"from=\(previous?.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"searchState=\(searchActive ? "active" : "nil") " +
"delayMs=\(Int((delay ?? 0) * 1000))"
)
#endif
let work = { [weak self] in
guard let self else { return }
guard let window = self.window else { return }
#if DEBUG
let before = String(describing: window.firstResponder)
#endif
if let previous, previous !== self {
_ = previous.surfaceView.resignFirstResponder()
}
window.makeFirstResponder(self.surfaceView)
let result = window.makeFirstResponder(self.surfaceView)
#if DEBUG
dlog(
"find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
)
#endif
}
if let delay, delay > 0 {
@ -6506,6 +6670,12 @@ final class GhosttySurfaceScrollView: NSView {
guard isActive else { return }
guard let window else { return }
guard surfaceView.isVisibleInUI else {
#if DEBUG
dlog(
"focus.ensure.defer surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=not_visible attempts=\(attemptsRemaining)"
)
#endif
retry()
return
}
@ -6545,25 +6715,59 @@ final class GhosttySurfaceScrollView: NSView {
// Search focus restoration only after confirming this is the active tab/pane.
if surfaceView.terminalSurface?.searchState != nil {
#if DEBUG
dlog(
"focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"attempts=\(attemptsRemaining) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
restoreSearchFocus(window: window)
return
}
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
return
}
if !window.isKeyWindow {
window.makeKeyAndOrderFront(nil)
}
_ = window.makeFirstResponder(surfaceView)
let result = window.makeFirstResponder(surfaceView)
#if DEBUG
dlog(
"focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder)) " +
"attempts=\(attemptsRemaining)"
)
#endif
if !isSurfaceViewFirstResponder() {
retry()
} else {
reassertTerminalSurfaceFocus(reason: "ensureFocus.afterMakeFirstResponder")
}
}
private func matchesCurrentTerminalFocusTarget(tabId: UUID, surfaceId: UUID) -> Bool {
guard let delegate = AppDelegate.shared,
let tabManager = delegate.tabManagerFor(tabId: tabId) ?? delegate.tabManager,
tabManager.selectedTabId == tabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
}) else {
return false
}
return tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface &&
tab.bonsplitController.focusedPaneId == paneId
}
/// Suppress the surface view's onFocus callback and ghostty_surface_set_focus during
/// SwiftUI reparenting (programmatic splits). Call clearSuppressReparentFocus() after layout settles.
func suppressReparentFocus() {
@ -6596,6 +6800,33 @@ final class GhosttySurfaceScrollView: NSView {
}
}
private func reassertTerminalSurfaceFocus(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface else { return }
#if DEBUG
dlog("focus.surface.reassert surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif
terminalSurface.setFocus(true)
refreshSurfaceAfterFocusIfNeeded(reason: reason)
}
private func refreshSurfaceAfterFocusIfNeeded(reason: String) {
guard let terminalSurface = surfaceView.terminalSurface,
isActive,
let window,
window.isKeyWindow,
surfaceView.isVisibleInUI else { return }
let now = CACurrentMediaTime()
if now - lastFocusRefreshAt < 0.05 {
return
}
lastFocusRefreshAt = now
#if DEBUG
dlog("focus.surface.refresh surface=\(terminalSurface.id.uuidString.prefix(5)) reason=\(reason)")
#endif
terminalSurface.forceRefresh(reason: "focus.surface.\(reason)")
}
private func applyFirstResponderIfNeeded() {
let hasUsablePortalGeometry: Bool = {
let size = bounds.size
@ -6616,6 +6847,14 @@ final class GhosttySurfaceScrollView: NSView {
return
}
guard let window, window.isKeyWindow else { return }
guard let tabId = surfaceView.tabId,
let panelId = surfaceView.terminalSurface?.id,
matchesCurrentTerminalFocusTarget(tabId: tabId, surfaceId: panelId) else {
#if DEBUG
dlog("focus.apply.skip surface=\(surfaceShort) reason=stale_target")
#endif
return
}
if surfaceView.terminalSurface?.searchState != nil {
// Find bar is open. Restore focus based on what the user last intended.
restoreSearchFocus(window: window)
@ -6623,6 +6862,7 @@ final class GhosttySurfaceScrollView: NSView {
}
if let fr = window.firstResponder as? NSView,
fr === surfaceView || fr.isDescendant(of: surfaceView) {
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
return
}
// Don't steal focus from a search overlay on another surface in this window.
@ -6636,6 +6876,9 @@ final class GhosttySurfaceScrollView: NSView {
dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
#endif
window.makeFirstResponder(surfaceView)
if isSurfaceViewFirstResponder() {
reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
}
}
/// Restore focus when window becomes key and the find bar is open.
@ -6644,6 +6887,18 @@ final class GhosttySurfaceScrollView: NSView {
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
switch searchFocusTarget {
case .searchField:
if let firstResponder = window.firstResponder,
isSearchOverlayOrDescendant(firstResponder),
!isCurrentSurfaceSearchResponder(firstResponder) {
surfaceView.terminalSurface?.setFocus(false)
#if DEBUG
dlog(
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
"reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
)
#endif
return
}
// Explicitly unfocus the terminal so cursor stops blinking immediately.
// The notification observer also does this, but it runs async when posted from main.
surfaceView.terminalSurface?.setFocus(false)
@ -6653,16 +6908,152 @@ final class GhosttySurfaceScrollView: NSView {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
}
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=searchField via=notification")
dlog(
"find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
"via=notification firstResponder=\(String(describing: window.firstResponder))"
)
#endif
case .terminal:
window.makeFirstResponder(surfaceView)
let result = window.makeFirstResponder(surfaceView)
#if DEBUG
dlog("find.restoreSearchFocus surface=\(surfaceShort) target=terminal")
dlog(
"find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
"result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
)
#endif
}
}
func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
if surfaceView.terminalSurface?.searchState != nil {
if let firstResponder = window?.firstResponder as? NSView,
(firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
return .surface
}
if let firstResponder = window?.firstResponder,
isCurrentSurfaceSearchResponder(firstResponder) {
return .findField
}
if searchFocusTarget == .searchField {
return .findField
}
}
return .surface
}
func preferredPanelFocusIntentForActivation() -> TerminalPanelFocusIntent {
if surfaceView.terminalSurface?.searchState != nil, searchFocusTarget == .searchField {
return .findField
}
return .surface
}
func preparePanelFocusIntentForActivation(_ intent: TerminalPanelFocusIntent) {
switch intent {
case .surface:
searchFocusTarget = .terminal
case .findField:
guard surfaceView.terminalSurface?.searchState != nil else { return }
searchFocusTarget = .searchField
}
#if DEBUG
dlog(
"find.preparePanelFocusIntent surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"target=\(intent == .findField ? "searchField" : "terminal")"
)
#endif
}
@discardableResult
func restorePanelFocusIntent(_ intent: TerminalPanelFocusIntent) -> Bool {
switch intent {
case .surface:
searchFocusTarget = .terminal
setActive(true)
applyFirstResponderIfNeeded()
return true
case .findField:
guard let terminalSurface = surfaceView.terminalSurface,
terminalSurface.searchState != nil else {
return false
}
searchFocusTarget = .searchField
setActive(true)
if let window {
restoreSearchFocus(window: window)
} else {
terminalSurface.setFocus(false)
NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
}
#if DEBUG
dlog(
"find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
"target=searchField firstResponder=\(String(describing: window?.firstResponder))"
)
#endif
return true
}
}
func ownedPanelFocusIntent(for responder: NSResponder) -> TerminalPanelFocusIntent? {
if isCurrentSurfaceSearchResponder(responder) {
return .findField
}
let resolvedResponder: NSResponder
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
resolvedResponder = editedView
} else {
resolvedResponder = responder
}
guard let view = resolvedResponder as? NSView else { return nil }
if view === surfaceView || view.isDescendant(of: surfaceView) {
return .surface
}
return nil
}
@discardableResult
func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
guard let firstResponder = window.firstResponder,
ownedPanelFocusIntent(for: firstResponder) == intent else {
return false
}
surfaceView.terminalSurface?.setFocus(false)
resignOwnedFirstResponderIfNeeded(reason: "yieldPanelFocusIntent")
#if DEBUG
dlog(
"focus.handoff.yield surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"target=\(intent == .findField ? "searchField" : "terminal")"
)
#endif
return true
}
private func resignOwnedFirstResponderIfNeeded(reason: String) {
guard let window,
let firstResponder = window.firstResponder else { return }
let ownsSurfaceResponder: Bool = {
guard let view = firstResponder as? NSView else { return false }
return view === surfaceView || view.isDescendant(of: surfaceView)
}()
guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
#if DEBUG
dlog(
"focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"reason=\(reason) firstResponder=\(String(describing: firstResponder))"
)
#endif
window.makeFirstResponder(nil)
}
/// Check if a responder is inside a search overlay hosting view.
/// Handles the AppKit field-editor case: when an NSTextField is being edited,
/// window.firstResponder is the shared NSTextView field editor, not the text field.
@ -6678,11 +7069,27 @@ final class GhosttySurfaceScrollView: NSView {
var current: NSView? = view
while let v = current {
if v is NSHostingView<SurfaceSearchOverlay> { return true }
let typeName = String(describing: type(of: v))
if typeName.contains("BrowserSearchOverlay") { return true }
current = v.superview
}
return false
}
private func isCurrentSurfaceSearchResponder(_ responder: NSResponder) -> Bool {
let resolvedResponder: NSResponder
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSView {
resolvedResponder = editedView
} else {
resolvedResponder = responder
}
guard let view = resolvedResponder as? NSView else { return false }
return view.isDescendant(of: self)
}
#if DEBUG
struct DebugRenderStats {
let drawCount: Int