import SwiftUI struct CommandOption: Identifiable, Hashable { /// Unique identifier for this option. let id = UUID() /// The primary text displayed for this command. let title: String /// Secondary text displayed below the title. let subtitle: String? /// Tooltip text shown on hover. let description: String? /// Keyboard shortcut symbols to display. let symbols: [String]? /// SF Symbol name for the leading icon. let leadingIcon: String? /// Color for the leading indicator circle. let leadingColor: Color? /// Badge text displayed as a pill. let badge: String? /// Whether to visually emphasize this option. let emphasis: Bool /// Sort key for stable ordering when titles are equal. let sortKey: AnySortKey? /// The action to perform when this option is selected. let action: () -> Void init( title: String, subtitle: String? = nil, description: String? = nil, symbols: [String]? = nil, leadingIcon: String? = nil, leadingColor: Color? = nil, badge: String? = nil, emphasis: Bool = false, sortKey: AnySortKey? = nil, action: @escaping () -> Void ) { self.title = title self.subtitle = subtitle self.description = description self.symbols = symbols self.leadingIcon = leadingIcon self.leadingColor = leadingColor self.badge = badge self.emphasis = emphasis self.sortKey = sortKey self.action = action } static func == (lhs: CommandOption, rhs: CommandOption) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } } struct CommandPaletteView: View { @Binding var isPresented: Bool var backgroundColor: Color = Color(nsColor: .windowBackgroundColor) var options: [CommandOption] @State private var rawQuery = "" @State private var selectedIndex: UInt? @State private var hoveredOptionID: UUID? var query: String { rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) } // The options that we should show, taking into account any filtering from // the query. Options with matching leadingColor are ranked higher. var filteredOptions: [CommandOption] { if query.isEmpty { return options } else { // Filter by title/subtitle match OR color match let filtered = options.filter { $0.title.matchedIndices(for: query) != nil || ($0.subtitle?.matchedIndices(for: query) != nil) || colorMatchScore(for: $0.leadingColor, query: query) > 0 } // Sort by color match score (higher scores first), then maintain original order return filtered.sorted { a, b in let scoreA = colorMatchScore(for: a.leadingColor, query: query) let scoreB = colorMatchScore(for: b.leadingColor, query: query) return scoreA > scoreB } } } var selectedOption: CommandOption? { guard let selectedIndex else { return nil } return if selectedIndex < filteredOptions.count { filteredOptions[Int(selectedIndex)] } else { filteredOptions.last } } var body: some View { let scheme: ColorScheme = if OSColor(backgroundColor).isLightColor { .light } else { .dark } VStack(alignment: .leading, spacing: 0) { CommandPaletteQuery(query: $rawQuery) { event in switch event { case .exit: isPresented = false case .submit: isPresented = false selectedOption?.action() case .move(.up): if filteredOptions.isEmpty { break } let current = selectedIndex ?? UInt(filteredOptions.count) selectedIndex = (current == 0) ? UInt(filteredOptions.count - 1) : current - 1 case .move(.down): if filteredOptions.isEmpty { break } let current = selectedIndex ?? UInt.max selectedIndex = (current >= UInt(filteredOptions.count - 1)) ? 0 : current + 1 case .move: // Unknown, ignore break } } .onChange(of: query) { newValue in // If the user types a query then we want to make sure the first // value is selected. If the user clears the query and we were selecting // the first, we unset any selection. if !newValue.isEmpty { if selectedIndex == nil { selectedIndex = 0 } } else { if let selectedIndex, selectedIndex == 0 { self.selectedIndex = nil } } } Divider() CommandTable( options: filteredOptions, query: query, selectedIndex: $selectedIndex, hoveredOptionID: $hoveredOptionID) { option in isPresented = false option.action() } } .frame(maxWidth: 500) .background( ZStack { Rectangle() .fill(.ultraThinMaterial) Rectangle() .fill(backgroundColor) .blendMode(.color) } .compositingGroup() ) .clipShape(RoundedRectangle(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(Color(nsColor: .tertiaryLabelColor).opacity(0.75)) ) .shadow(radius: 32, x: 0, y: 12) .padding() .environment(\.colorScheme, scheme) .onChange(of: isPresented) { newValue in 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. rawQuery = "" } } } /// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name. /// Returns 0 if no color name in the query matches, or if the color is nil. private func colorMatchScore(for color: Color?, query: String) -> Double { guard let color = color else { return 0 } let queryLower = query.lowercased() let nsColor = NSColor(color) var bestScore: Double = 0 for name in NSColor.colorNames { guard queryLower.contains(name), let systemColor = NSColor(named: name) else { continue } let distance = nsColor.distance(to: systemColor) // Max distance in weighted RGB space is ~3.0, so normalize and invert // Use a threshold to determine "close enough" matches let maxDistance: Double = 1.5 if distance < maxDistance { let score = 1.0 - (distance / maxDistance) bestScore = max(bestScore, score) } } return bestScore } } /// The text field for building the query for the command palette. private struct CommandPaletteQuery: View { @Binding var query: String var onEvent: ((KeyboardEvent) -> Void)? @FocusState private var isTextFieldFocused: Bool init(query: Binding, onEvent: ((KeyboardEvent) -> Void)? = nil) { _query = query self.onEvent = onEvent } enum KeyboardEvent { case exit case submit case move(MoveCommandDirection) } var body: some View { ZStack { Group { Button { onEvent?(.move(.up)) } label: { Color.clear } .buttonStyle(PlainButtonStyle()) .keyboardShortcut(.upArrow, modifiers: []) Button { onEvent?(.move(.down)) } label: { Color.clear } .buttonStyle(PlainButtonStyle()) .keyboardShortcut(.downArrow, modifiers: []) Button { onEvent?(.move(.up)) } label: { Color.clear } .buttonStyle(PlainButtonStyle()) .keyboardShortcut(.init("p"), modifiers: [.control]) Button { onEvent?(.move(.down)) } label: { Color.clear } .buttonStyle(PlainButtonStyle()) .keyboardShortcut(.init("n"), modifiers: [.control]) } .frame(width: 0, height: 0) .accessibilityHidden(true) TextField("Execute a command…", text: $query) .padding() .font(.system(size: 20, weight: .light)) .frame(height: 48) .textFieldStyle(.plain) .focused($isTextFieldFocused) .onChange(of: isTextFieldFocused) { focused in if !focused { onEvent?(.exit) } } .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 } } } } } private struct CommandTable: View { var options: [CommandOption] var query: String @Binding var selectedIndex: UInt? @Binding var hoveredOptionID: UUID? var action: (CommandOption) -> Void var body: some View { if options.isEmpty { Text("No matches") .foregroundStyle(.secondary) .padding() } else { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 4) { ForEach(Array(options.enumerated()), id: \.1.id) { index, option in CommandRow( option: option, query: query, isSelected: { if let selected = selectedIndex { return selected == index || (selected >= options.count && index == options.count - 1) } else { return false } }(), hoveredID: $hoveredOptionID ) { action(option) } } } .padding(10) } .frame(maxHeight: 200) .onChange(of: selectedIndex) { _ in guard let selectedIndex, selectedIndex < options.count else { return } proxy.scrollTo( options[Int(selectedIndex)].id) } } } } } /// A single row in the command palette. private struct CommandRow: View { let option: CommandOption var query: String var isSelected: Bool @Binding var hoveredID: UUID? var action: () -> Void private var highlightedTitle: Text { guard !query.isEmpty, let indices = option.title.matchedIndices(for: query) else { return Text(option.title) .fontWeight(option.emphasis ? .medium : .regular) } var attributed = AttributedString(option.title) attributed[attributed.startIndex...].font = .body .weight(option.emphasis ? .medium : .regular) for idx in indices { let offset = option.title.distance(from: option.title.startIndex, to: idx) let attrStart = attributed.index(attributed.startIndex, offsetByCharacters: offset) let attrEnd = attributed.index(attrStart, offsetByCharacters: 1) attributed[attrStart.. Text { guard !query.isEmpty, option.title.matchedIndices(for: query) == nil, let indices = subtitle.matchedIndices(for: query) else { return Text(subtitle) } var attributed = AttributedString(subtitle) for idx in indices { let offset = subtitle.distance(from: subtitle.startIndex, to: idx) let attrStart = attributed.index(attributed.startIndex, offsetByCharacters: offset) let attrEnd = attributed.index(attrStart, offsetByCharacters: 1) attributed[attrStart.. [String.Index]? { guard !query.isEmpty else { return nil } // Prefer substring match. if let range = self.range(of: query, options: .caseInsensitive) { return Array(self[range].indices) } // Fall back to initials match. let words = self.split(whereSeparator: \.isWhitespace) var queryIndex = query.startIndex var matched: [String.Index] = [] for word in words { guard queryIndex < query.endIndex else { break } if word.first?.lowercased() == query[queryIndex].lowercased() { matched.append(word.startIndex) queryIndex = query.index(after: queryIndex) } } return queryIndex == query.endIndex ? matched : nil } }