From 7e46200d318cc18c67deccb33f3cb3a9ca80cb1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 08:56:21 -0800 Subject: [PATCH 01/11] macos: command palette entries support leading color --- .../Features/Command Palette/CommandPalette.swift | 9 +++++++++ .../Command Palette/TerminalCommandPalette.swift | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 79c3ca756..70b444827 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -6,6 +6,7 @@ struct CommandOption: Identifiable, Hashable { let description: String? let symbols: [String]? let leadingIcon: String? + let leadingColor: Color? let badge: String? let emphasis: Bool let action: () -> Void @@ -15,6 +16,7 @@ struct CommandOption: Identifiable, Hashable { description: String? = nil, symbols: [String]? = nil, leadingIcon: String? = nil, + leadingColor: Color? = nil, badge: String? = nil, emphasis: Bool = false, action: @escaping () -> Void @@ -23,6 +25,7 @@ struct CommandOption: Identifiable, Hashable { self.description = description self.symbols = symbols self.leadingIcon = leadingIcon + self.leadingColor = leadingColor self.badge = badge self.emphasis = emphasis self.action = action @@ -283,6 +286,12 @@ fileprivate struct CommandRow: View { var body: some View { Button(action: action) { HStack(spacing: 8) { + if let color = option.leadingColor { + Circle() + .fill(color) + .frame(width: 8, height: 8) + } + if let icon = option.leadingIcon { Image(systemName: icon) .foregroundStyle(option.emphasis ? Color.accentColor : .secondary) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 96ff3d0c1..95e5e6a01 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -62,7 +62,7 @@ struct TerminalCommandPaletteView: View { return CommandOption( title: c.title, description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, ) { onAction(c.action) } From 5d11bdddc3e81011b4543f19f6ea563a0e515ed6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:04:51 -0800 Subject: [PATCH 02/11] macos: implement the present terminal action so we can use that --- .../Terminal/BaseTerminalController.swift | 14 +++++++++ macos/Sources/Ghostty/Ghostty.App.swift | 29 +++++++++++++++++-- macos/Sources/Ghostty/Package.swift | 3 ++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5f067c128..b70fd2c56 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -195,6 +195,11 @@ class BaseTerminalController: NSWindowController, selector: #selector(ghosttyDidResizeSplit(_:)), name: Ghostty.Notification.didResizeSplit, object: nil) + center.addObserver( + self, + selector: #selector(ghosttyDidPresentTerminal(_:)), + name: Ghostty.Notification.ghosttyPresentTerminal, + object: nil) // Listen for local events that we need to know of outside of // single surface handlers. @@ -700,6 +705,15 @@ class BaseTerminalController: NSWindowController, } } + @objc private func ghosttyDidPresentTerminal(_ notification: Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard surfaceTree.contains(target) else { return } + + // Bring the window to front and focus the surface without activating the app + window?.orderFrontRegardless() + Ghostty.moveFocus(to: target) + } + // MARK: Local Events private func localEventHandler(_ event: NSEvent) -> NSEvent? { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 4e9d039d4..3348ab714 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -627,12 +627,13 @@ extension Ghostty { case GHOSTTY_ACTION_SEARCH_SELECTED: searchSelected(app, target: target, v: action.action.search_selected) + case GHOSTTY_ACTION_PRESENT_TERMINAL: + return presentTerminal(app, target: target) + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: fallthrough - case GHOSTTY_ACTION_PRESENT_TERMINAL: - fallthrough case GHOSTTY_ACTION_SIZE_LIMIT: fallthrough case GHOSTTY_ACTION_QUIT_TIMER: @@ -845,6 +846,30 @@ extension Ghostty { } } + private static func presentTerminal( + _ app: ghostty_app_t, + target: ghostty_target_s + ) -> Bool { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + return false + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + + NotificationCenter.default.post( + name: Notification.ghosttyPresentTerminal, + object: surfaceView + ) + return true + + default: + assertionFailure() + return false + } + } + private static func closeTab(_ app: ghostty_app_t, target: ghostty_target_s, mode: ghostty_action_close_tab_mode_e) { switch (target.tag) { case GHOSTTY_TARGET_APP: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index b834ea31f..375e5c37b 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -435,6 +435,9 @@ extension Ghostty.Notification { /// New window. Has base surface config requested in userinfo. static let ghosttyNewWindow = Notification.Name("com.mitchellh.ghostty.newWindow") + /// Present terminal. Bring the surface's window to focus without activating the app. + static let ghosttyPresentTerminal = Notification.Name("com.mitchellh.ghostty.presentTerminal") + /// Toggle fullscreen of current window static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen") static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue From e1e782c617ac311d6cb535de23fd23d5c687b437 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:10:46 -0800 Subject: [PATCH 03/11] macos: clean up the way command options are built up --- .../TerminalCommandPalette.swift | 120 +++++++++--------- 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 95e5e6a01..8150dbdbb 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -18,63 +18,6 @@ struct TerminalCommandPaletteView: View { /// The callback when an action is submitted. var onAction: ((String) -> Void) - // The commands available to the command palette. - private var commandOptions: [CommandOption] { - var options: [CommandOption] = [] - - // Add update command if an update is installable. This must always be the first so - // it is at the top. - if let updateViewModel, updateViewModel.state.isInstallable { - // We override the update available one only because we want to properly - // convey it'll go all the way through. - let title: String - if case .updateAvailable = updateViewModel.state { - title = "Update Ghostty and Restart" - } else { - title = updateViewModel.text - } - - options.append(CommandOption( - title: title, - description: updateViewModel.description, - leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", - badge: updateViewModel.badge, - emphasis: true - ) { - (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() - }) - } - - // Add cancel/skip update command if the update is installable - if let updateViewModel, updateViewModel.state.isInstallable { - options.append(CommandOption( - title: "Cancel or Skip Update", - description: "Dismiss the current update process" - ) { - updateViewModel.state.cancel() - }) - } - - // Add terminal commands - guard let surface = surfaceView.surfaceModel else { return options } - do { - let terminalCommands = try surface.commands().map { c in - return CommandOption( - title: c.title, - description: c.description, - symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, - ) { - onAction(c.action) - } - } - options.append(contentsOf: terminalCommands) - } catch { - return options - } - - return options - } - var body: some View { ZStack { if isPresented { @@ -116,6 +59,69 @@ struct TerminalCommandPaletteView: View { } } } + + /// All commands available in the command palette, combining update and terminal options. + private var commandOptions: [CommandOption] { + var options: [CommandOption] = [] + options.append(contentsOf: updateOptions) + options.append(contentsOf: terminalOptions) + return options + } + + /// Commands for installing or canceling available updates. + private var updateOptions: [CommandOption] { + var options: [CommandOption] = [] + + guard let updateViewModel, updateViewModel.state.isInstallable else { + return options + } + + // We override the update available one only because we want to properly + // convey it'll go all the way through. + let title: String + if case .updateAvailable = updateViewModel.state { + title = "Update Ghostty and Restart" + } else { + title = updateViewModel.text + } + + options.append(CommandOption( + title: title, + description: updateViewModel.description, + leadingIcon: updateViewModel.iconName ?? "shippingbox.fill", + badge: updateViewModel.badge, + emphasis: true + ) { + (NSApp.delegate as? AppDelegate)?.updateController.installUpdate() + }) + + options.append(CommandOption( + title: "Cancel or Skip Update", + description: "Dismiss the current update process" + ) { + updateViewModel.state.cancel() + }) + + return options + } + + /// Commands exposed by the terminal surface. + private var terminalOptions: [CommandOption] { + guard let surface = surfaceView.surfaceModel else { return [] } + do { + return try surface.commands().map { c in + CommandOption( + title: c.title, + description: c.description, + symbols: ghosttyConfig.keyboardShortcut(for: c.action)?.keyList, + ) { + onAction(c.action) + } + } + } catch { + return [] + } + } } /// This is done to ensure that the given view is in the responder chain. From 835fe3dd0fce241bc249ebc9e7a33b78d0ffe32e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:17:09 -0800 Subject: [PATCH 04/11] macos: add the active terminals to our command palette to jump --- .../TerminalCommandPalette.swift | 29 +++++++++++++++++++ .../Terminal/BaseTerminalController.swift | 9 ++++-- .../Helpers/Extensions/String+Extension.swift | 9 ++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 8150dbdbb..e3da9ff56 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -64,6 +64,7 @@ struct TerminalCommandPaletteView: View { private var commandOptions: [CommandOption] { var options: [CommandOption] = [] options.append(contentsOf: updateOptions) + options.append(contentsOf: jumpOptions) options.append(contentsOf: terminalOptions) return options } @@ -122,6 +123,34 @@ struct TerminalCommandPaletteView: View { return [] } } + + /// Commands for jumping to other terminal surfaces. + private var jumpOptions: [CommandOption] { + TerminalController.all.flatMap { controller -> [CommandOption] in + guard let window = controller.window else { return [] } + + let color = (window as? TerminalWindow)?.tabColor + let displayColor = color != TerminalTabColor.none ? color : nil + + return controller.surfaceTree.map { surface in + let title = surface.title.isEmpty ? window.title : surface.title + let displayTitle = title.isEmpty ? "Untitled" : title + + return CommandOption( + title: "Focus: \(displayTitle)", + description: surface.pwd?.abbreviatedPath, + leadingIcon: "rectangle.on.rectangle", + leadingColor: displayColor?.displayColor.map { Color($0) } + ) { + NotificationCenter.default.post( + name: Ghostty.Notification.ghosttyPresentTerminal, + object: surface + ) + } + } + } + } + } /// This is done to ensure that the given view is in the responder chain. diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index b70fd2c56..9e8aece2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -709,9 +709,12 @@ class BaseTerminalController: NSWindowController, guard let target = notification.object as? Ghostty.SurfaceView else { return } guard surfaceTree.contains(target) else { return } - // Bring the window to front and focus the surface without activating the app - window?.orderFrontRegardless() - Ghostty.moveFocus(to: target) + // Bring the window to front and focus the surface. + window?.makeKeyAndOrderFront(nil) + + // We use a small delay to ensure this runs after any UI cleanup + // (e.g., command palette restoring focus to its original surface). + Ghostty.moveFocus(to: target, delay: 0.1) } // MARK: Local Events diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index 0c1c4fe91..a8d93091a 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -17,4 +17,13 @@ extension String { return url } #endif + + /// Returns the path with the home directory abbreviated as ~. + var abbreviatedPath: String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + if hasPrefix(home) { + return "~" + dropFirst(home.count) + } + return self + } } From b30e94c0ece807b2a8af006758842db446ba8722 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:32:39 -0800 Subject: [PATCH 05/11] macos: sort in the focus jumps in other commands --- .../Command Palette/TerminalCommandPalette.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index e3da9ff56..6d6a89162 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -63,9 +63,16 @@ struct TerminalCommandPaletteView: View { /// All commands available in the command palette, combining update and terminal options. private var commandOptions: [CommandOption] { var options: [CommandOption] = [] + // Updates always appear first options.append(contentsOf: updateOptions) - options.append(contentsOf: jumpOptions) - options.append(contentsOf: terminalOptions) + + // Sort the rest. We replace ":" with a character that sorts before space + // so that "Foo:" sorts before "Foo Bar:". + options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in + let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") + let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") + return aNormalized.localizedCaseInsensitiveCompare(bNormalized) == .orderedAscending + }) return options } From 1fd3f27e26ca17ac3f299ade8f534f099d43f0e5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:44:01 -0800 Subject: [PATCH 06/11] macos: show pwd for jump options --- .../Command Palette/CommandPalette.swift | 20 ++++++++++++++++--- .../TerminalCommandPalette.swift | 8 +++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 70b444827..3cb4e3480 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -3,6 +3,7 @@ import SwiftUI struct CommandOption: Identifiable, Hashable { let id = UUID() let title: String + let subtitle: String? let description: String? let symbols: [String]? let leadingIcon: String? @@ -13,6 +14,7 @@ struct CommandOption: Identifiable, Hashable { init( title: String, + subtitle: String? = nil, description: String? = nil, symbols: [String]? = nil, leadingIcon: String? = nil, @@ -22,6 +24,7 @@ struct CommandOption: Identifiable, Hashable { action: @escaping () -> Void ) { self.title = title + self.subtitle = subtitle self.description = description self.symbols = symbols self.leadingIcon = leadingIcon @@ -55,7 +58,10 @@ struct CommandPaletteView: View { if query.isEmpty { return options } else { - return options.filter { $0.title.localizedCaseInsensitiveContains(query) } + return options.filter { + $0.title.localizedCaseInsensitiveContains(query) || + ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) + } } } @@ -298,8 +304,16 @@ fileprivate struct CommandRow: View { .font(.system(size: 14, weight: .medium)) } - Text(option.title) - .fontWeight(option.emphasis ? .medium : .regular) + VStack(alignment: .leading, spacing: 2) { + Text(option.title) + .fontWeight(option.emphasis ? .medium : .regular) + + if let subtitle = option.subtitle { + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } Spacer() diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index 6d6a89162..ecd301208 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -142,10 +142,16 @@ struct TerminalCommandPaletteView: View { return controller.surfaceTree.map { surface in let title = surface.title.isEmpty ? window.title : surface.title let displayTitle = title.isEmpty ? "Untitled" : title + let pwd = surface.pwd?.abbreviatedPath + let subtitle: String? = if let pwd, !displayTitle.contains(pwd) { + pwd + } else { + nil + } return CommandOption( title: "Focus: \(displayTitle)", - description: surface.pwd?.abbreviatedPath, + subtitle: subtitle, leadingIcon: "rectangle.on.rectangle", leadingColor: displayColor?.displayColor.map { Color($0) } ) { From d23f7e051fb207c7d7f20666e888d33818374c7a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:52:05 -0800 Subject: [PATCH 07/11] macos: stable sort for surfaces --- .../Command Palette/CommandPalette.swift | 14 +++++++++++ .../TerminalCommandPalette.swift | 16 +++++++++--- macos/Sources/Helpers/AnySortKey.swift | 25 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Helpers/AnySortKey.swift diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 3cb4e3480..333c69fec 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -1,15 +1,27 @@ 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( @@ -21,6 +33,7 @@ struct CommandOption: Identifiable, Hashable { leadingColor: Color? = nil, badge: String? = nil, emphasis: Bool = false, + sortKey: AnySortKey? = nil, action: @escaping () -> Void ) { self.title = title @@ -31,6 +44,7 @@ struct CommandOption: Identifiable, Hashable { self.leadingColor = leadingColor self.badge = badge self.emphasis = emphasis + self.sortKey = sortKey self.action = action } diff --git a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift index ecd301208..19bedd4ee 100644 --- a/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift +++ b/macos/Sources/Features/Command Palette/TerminalCommandPalette.swift @@ -67,11 +67,20 @@ struct TerminalCommandPaletteView: View { options.append(contentsOf: updateOptions) // Sort the rest. We replace ":" with a character that sorts before space - // so that "Foo:" sorts before "Foo Bar:". + // so that "Foo:" sorts before "Foo Bar:". Use sortKey as a tie-breaker + // for stable ordering when titles are equal. options.append(contentsOf: (jumpOptions + terminalOptions).sorted { a, b in let aNormalized = a.title.replacingOccurrences(of: ":", with: "\t") let bNormalized = b.title.replacingOccurrences(of: ":", with: "\t") - return aNormalized.localizedCaseInsensitiveCompare(bNormalized) == .orderedAscending + let comparison = aNormalized.localizedCaseInsensitiveCompare(bNormalized) + if comparison != .orderedSame { + return comparison == .orderedAscending + } + // Tie-breaker: use sortKey if both have one + if let aSortKey = a.sortKey, let bSortKey = b.sortKey { + return aSortKey < bSortKey + } + return false }) return options } @@ -153,7 +162,8 @@ struct TerminalCommandPaletteView: View { title: "Focus: \(displayTitle)", subtitle: subtitle, leadingIcon: "rectangle.on.rectangle", - leadingColor: displayColor?.displayColor.map { Color($0) } + leadingColor: displayColor?.displayColor.map { Color($0) }, + sortKey: AnySortKey(ObjectIdentifier(surface)) ) { NotificationCenter.default.post( name: Ghostty.Notification.ghosttyPresentTerminal, diff --git a/macos/Sources/Helpers/AnySortKey.swift b/macos/Sources/Helpers/AnySortKey.swift new file mode 100644 index 000000000..6813ccf45 --- /dev/null +++ b/macos/Sources/Helpers/AnySortKey.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Type-erased wrapper for any Comparable type to use as a sort key. +struct AnySortKey: Comparable { + private let value: Any + private let comparator: (Any, Any) -> ComparisonResult + + init(_ value: T) { + self.value = value + self.comparator = { lhs, rhs in + guard let l = lhs as? T, let r = rhs as? T else { return .orderedSame } + if l < r { return .orderedAscending } + if l > r { return .orderedDescending } + return .orderedSame + } + } + + static func < (lhs: AnySortKey, rhs: AnySortKey) -> Bool { + lhs.comparator(lhs.value, rhs.value) == .orderedAscending + } + + static func == (lhs: AnySortKey, rhs: AnySortKey) -> Bool { + lhs.comparator(lhs.value, rhs.value) == .orderedSame + } +} From e1356538ac70b876bc55bffe0b191465dfe2db62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 09:57:33 -0800 Subject: [PATCH 08/11] macos: show a highlight animation when a terminal is presented --- .../Terminal/BaseTerminalController.swift | 3 + macos/Sources/Ghostty/SurfaceView.swift | 62 ++++++++++++++++++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 11 ++++ 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 9e8aece2d..5129351a1 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -715,6 +715,9 @@ class BaseTerminalController: NSWindowController, // We use a small delay to ensure this runs after any UI cleanup // (e.g., command palette restoring focus to its original surface). Ghostty.moveFocus(to: target, delay: 0.1) + + // Show a brief highlight to help the user locate the presented terminal. + target.highlight() } // MARK: Local Events diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 82232dd89..49c6a4982 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -49,7 +49,7 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false - + #if canImport(AppKit) // Observe SecureInput to detect when its enabled @ObservedObject private var secureInput = SecureInput.shared @@ -219,6 +219,9 @@ extension Ghostty { BellBorderOverlay(bell: surfaceView.bell) } + // Show a highlight effect when this surface needs attention + HighlightOverlay(highlighted: surfaceView.highlighted) + // If our surface is not healthy, then we render an error view over it. if (!surfaceView.healthy) { Rectangle().fill(ghostty.config.backgroundColor) @@ -242,6 +245,7 @@ extension Ghostty { } } } + } } @@ -764,6 +768,62 @@ extension Ghostty { } } + /// Visual overlay that briefly highlights a surface to draw attention to it. + /// Uses a soft, soothing highlight with a pulsing border effect. + struct HighlightOverlay: View { + let highlighted: Bool + + @State private var borderPulse: Bool = false + + var body: some View { + ZStack { + Rectangle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + Color.accentColor.opacity(0.12), + Color.accentColor.opacity(0.03), + Color.clear + ]), + center: .center, + startRadius: 0, + endRadius: 2000 + ) + ) + + Rectangle() + .strokeBorder( + LinearGradient( + gradient: Gradient(colors: [ + Color.accentColor.opacity(0.8), + Color.accentColor.opacity(0.5), + Color.accentColor.opacity(0.8) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: borderPulse ? 4 : 2 + ) + .shadow(color: Color.accentColor.opacity(borderPulse ? 0.8 : 0.6), radius: borderPulse ? 12 : 8, x: 0, y: 0) + .shadow(color: Color.accentColor.opacity(borderPulse ? 0.5 : 0.3), radius: borderPulse ? 24 : 16, x: 0, y: 0) + } + .allowsHitTesting(false) + .opacity(highlighted ? 1.0 : 0.0) + .animation(.easeOut(duration: 0.4), value: highlighted) + .onChange(of: highlighted) { newValue in + if newValue { + withAnimation(.easeInOut(duration: 0.4).repeatForever(autoreverses: true)) { + borderPulse = true + } + } else { + withAnimation(.easeOut(duration: 0.4)) { + borderPulse = false + } + } + } + } + } + // MARK: Readonly Badge /// A badge overlay that indicates a surface is in readonly mode. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index d26545ebc..88a0bb6e8 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -126,6 +126,9 @@ extension Ghostty { /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). + @Published private(set) var highlighted: Bool = false + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -1523,6 +1526,14 @@ extension Ghostty { } } + /// Triggers a brief highlight animation on this surface. + func highlight() { + highlighted = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.highlighted = false + } + } + @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) From 829dd1b9b23683e5e6bd583b7d1724d1fa69de52 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:13:53 -0800 Subject: [PATCH 09/11] macos: focus shenangians --- macos/Sources/Features/Terminal/BaseTerminalController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 5129351a1..d79c89d2d 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -714,6 +714,7 @@ class BaseTerminalController: NSWindowController, // We use a small delay to ensure this runs after any UI cleanup // (e.g., command palette restoring focus to its original surface). + Ghostty.moveFocus(to: target) Ghostty.moveFocus(to: target, delay: 0.1) // Show a brief highlight to help the user locate the presented terminal. From 842583b628538c4eb6232d9e4de8d23a55404016 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:25:59 -0800 Subject: [PATCH 10/11] macos: fix uikit build --- macos/Sources/Ghostty/SurfaceView_UIKit.swift | 3 +++ macos/Sources/Helpers/Extensions/String+Extension.swift | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 568a93314..b2e429455 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -46,6 +46,9 @@ extension Ghostty { /// True when the surface is in readonly mode. @Published private(set) var readonly: Bool = false + + /// True when the surface should show a highlight effect (e.g., when presented via goto_split). + @Published private(set) var highlighted: Bool = false // Returns sizing information for the surface. This is the raw C // structure because I'm lazy. diff --git a/macos/Sources/Helpers/Extensions/String+Extension.swift b/macos/Sources/Helpers/Extensions/String+Extension.swift index a8d93091a..139a7892c 100644 --- a/macos/Sources/Helpers/Extensions/String+Extension.swift +++ b/macos/Sources/Helpers/Extensions/String+Extension.swift @@ -7,7 +7,7 @@ extension String { return self.prefix(maxLength) + trailing } - #if canImport(AppKit) +#if canImport(AppKit) func temporaryFile(_ filename: String = "temp") -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent(filename) @@ -16,7 +16,6 @@ extension String { try? string.write(to: url, atomically: true, encoding: .utf8) return url } - #endif /// Returns the path with the home directory abbreviated as ~. var abbreviatedPath: String { @@ -26,4 +25,5 @@ extension String { } return self } +#endif } From e1d0b2202947f9c497fe521a93c24d57907d97ef Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 17 Dec 2025 10:48:39 -0800 Subject: [PATCH 11/11] macos: allow searching sessions by color too --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../Command Palette/CommandPalette.swift | 41 +++++++++++++++++-- .../Extensions/NSColor+Extension.swift | 39 ++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Helpers/Extensions/NSColor+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 562166c87..1a810e621 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -157,6 +157,7 @@ "Helpers/Extensions/KeyboardShortcut+Extension.swift", "Helpers/Extensions/NSAppearance+Extension.swift", "Helpers/Extensions/NSApplication+Extension.swift", + "Helpers/Extensions/NSColor+Extension.swift", "Helpers/Extensions/NSImage+Extension.swift", "Helpers/Extensions/NSMenu+Extension.swift", "Helpers/Extensions/NSMenuItem+Extension.swift", diff --git a/macos/Sources/Features/Command Palette/CommandPalette.swift b/macos/Sources/Features/Command Palette/CommandPalette.swift index 333c69fec..235881dde 100644 --- a/macos/Sources/Features/Command Palette/CommandPalette.swift +++ b/macos/Sources/Features/Command Palette/CommandPalette.swift @@ -67,14 +67,23 @@ struct CommandPaletteView: View { @FocusState private var isTextFieldFocused: Bool // The options that we should show, taking into account any filtering from - // the query. + // the query. Options with matching leadingColor are ranked higher. var filteredOptions: [CommandOption] { if query.isEmpty { return options } else { - return options.filter { + // Filter by title/subtitle match OR color match + let filtered = options.filter { $0.title.localizedCaseInsensitiveContains(query) || - ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) + ($0.subtitle?.localizedCaseInsensitiveContains(query) ?? false) || + 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 } } } @@ -191,6 +200,32 @@ struct CommandPaletteView: View { isTextFieldFocused = isPresented } } + + /// 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. diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift new file mode 100644 index 000000000..63cf02ed4 --- /dev/null +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -0,0 +1,39 @@ +import AppKit + +extension NSColor { + /// Using a color list let's us get localized names. + private static let appleColorList: NSColorList? = NSColorList(named: "Apple") + + convenience init?(named name: String) { + guard let colorList = Self.appleColorList, + let color = colorList.color(withKey: name.capitalized) else { + return nil + } + guard let components = color.usingColorSpace(.sRGB) else { + return nil + } + self.init( + red: components.redComponent, + green: components.greenComponent, + blue: components.blueComponent, + alpha: components.alphaComponent + ) + } + + static var colorNames: [String] { + appleColorList?.allKeys.map { $0.lowercased() } ?? [] + } + + /// Calculates the perceptual distance to another color in RGB space. + func distance(to other: NSColor) -> Double { + guard let a = self.usingColorSpace(.sRGB), + let b = other.usingColorSpace(.sRGB) else { return .infinity } + + let dr = a.redComponent - b.redComponent + let dg = a.greenComponent - b.greenComponent + let db = a.blueComponent - b.blueComponent + + // Weighted Euclidean distance (human eye is more sensitive to green) + return sqrt(2 * dr * dr + 4 * dg * dg + 3 * db * db) + } +}