macOS: fix focus update when using search or command palette (#11978)

This fixes two things:

1. Surface focus state is not consistent with first responder state when
the search bar is open.
> Reproduce: Open search, switch to another app and back, observe the
cursor state of the surface.
> And after switching back, `cmd+shift+f` will close the search bar,
surface will become focused but not first responder, so it will not
accept any input
2. Command palette is not focused when built with Xcode 26.4 (26.3 works
fine).
> This is weird to me, because the tip (and built with 26.3) works fine.
I guess it's related to the SDK update? I couldn’t be sure what went
wrong, but dispatching it to the next loop works as previously.
  > Also cleaned some previous checks when quickly open and reopen.
  > This fix works great both with 26.4 and 26.3



https://github.com/user-attachments/assets/c9cf4c1b-60d9-4c71-802c-55f82e40eec7
This commit is contained in:
Mitchell Hashimoto
2026-03-30 09:18:28 -07:00
committed by GitHub
3 changed files with 21 additions and 22 deletions

View File

@@ -64,7 +64,6 @@ struct CommandPaletteView: View {
@State private var query = ""
@State private var selectedIndex: UInt?
@State private var hoveredOptionID: UUID?
@FocusState private var isTextFieldFocused: Bool
// The options that we should show, taking into account any filtering from
// the query. Options with matching leadingColor are ranked higher.
@@ -105,7 +104,7 @@ struct CommandPaletteView: View {
}
VStack(alignment: .leading, spacing: 0) {
CommandPaletteQuery(query: $query, isTextFieldFocused: _isTextFieldFocused) { event in
CommandPaletteQuery(query: $query) { event in
switch event {
case .exit:
isPresented = false
@@ -178,27 +177,13 @@ struct CommandPaletteView: View {
.padding()
.environment(\.colorScheme, scheme)
.onChange(of: isPresented) { newValue in
// Reset focus when quickly showing and hiding.
// macOS will destroy this view after a while,
// so task/onAppear will not be called again.
// If you toggle it rather quickly, we reset
// it here when dismissing.
isTextFieldFocused = newValue
if !isPresented {
if !newValue {
// This is optional, since most of the time
// there will be a delay before the next use.
// To keep behavior the same as before, we reset it.
query = ""
}
}
.task {
// Grab focus on the first appearance.
// This happens right after onAppear,
// so we dont need to dispatch it again.
// Fixes: https://github.com/ghostty-org/ghostty/issues/8497
// Also fixes initial focus while animating.
isTextFieldFocused = isPresented
}
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
@@ -234,10 +219,9 @@ private struct CommandPaletteQuery: View {
var onEvent: ((KeyboardEvent) -> Void)?
@FocusState private var isTextFieldFocused: Bool
init(query: Binding<String>, isTextFieldFocused: FocusState<Bool>, onEvent: ((KeyboardEvent) -> Void)? = nil) {
init(query: Binding<String>, onEvent: ((KeyboardEvent) -> Void)? = nil) {
_query = query
self.onEvent = onEvent
_isTextFieldFocused = isTextFieldFocused
}
enum KeyboardEvent {
@@ -280,6 +264,17 @@ private struct CommandPaletteQuery: View {
.onExitCommand { onEvent?(.exit) }
.onMoveCommand { onEvent?(.move($0)) }
.onSubmit { onEvent?(.submit) }
.onAppear {
// Grab focus on the first appearance.
// Debug and Release build using Xcode 26.4,
// has same issue again
// Fixes: https://github.com/ghostty-org/ghostty/issues/8497
// SearchOverlay works magically as expected, I don't know
// why it's different here, but dispatching to next loop fixes it
DispatchQueue.main.async {
isTextFieldFocused = true
}
}
}
}
}

View File

@@ -301,9 +301,8 @@ class BaseTerminalController: NSWindowController,
// Our focus state requires that this window is key and our currently
// focused surface is the surface in this view.
let focused: Bool = (window?.isKeyWindow ?? false) &&
!commandPaletteIsShowing &&
focusedSurface != nil &&
surfaceView == focusedSurface!
surfaceView == focusedSurface &&
surfaceView.isFirstResponder
surfaceView.focusDidChange(focused)
}
}

View File

@@ -14,6 +14,11 @@ extension NSView {
return false
}
/// Returns true if this view is currently the first responder
var isFirstResponder: Bool {
window?.firstResponder === self
}
}
// MARK: Screenshot