macOS: Session Search (#9945)

Replaces #9785 

This adds session search to the command palette on macOS. Session search
lets you jump to any running terminal based on title or working
directory. The command palette shows you the title, working directory,
and tab color (if any) to help you identify the terminal you care about.

This also enhances our command palette in general to support better
sorting, more stable sorts when keys are identical, etc.

## Demo


https://github.com/user-attachments/assets/602a9424-e182-4651-bf08-378e9c5e1616




## Future

Since this inherits the command palette infrastructure, we don't have
fuzzy search capabilities yet, but I still think this is useful already.
We should add fuzzy searching in the future.

Thanks @phaistonian for the inspiration here but I did do a full
rewrite.

**AI disclosure:** I used AI to assist with this in places, but I
understand everything and touched up almost everything manually.
main tip
Mitchell Hashimoto 2025-12-17 11:01:19 -08:00 committed by GitHub
commit a4cb73db84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 393 additions and 66 deletions

View File

@ -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",

View File

@ -1,30 +1,50 @@
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
}
@ -47,12 +67,24 @@ 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 { $0.title.localizedCaseInsensitiveContains(query) }
// Filter by title/subtitle match OR color match
let filtered = options.filter {
$0.title.localizedCaseInsensitiveContains(query) ||
($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
}
}
}
@ -168,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.
@ -283,14 +341,28 @@ 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)
.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()

View File

@ -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,121 @@ 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)
// Sort the rest. We replace ":" with a character that sorts before space
// 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")
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
}
/// 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 []
}
}
/// 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
let pwd = surface.pwd?.abbreviatedPath
let subtitle: String? = if let pwd, !displayTitle.contains(pwd) {
pwd
} else {
nil
}
return CommandOption(
title: "Focus: \(displayTitle)",
subtitle: subtitle,
leadingIcon: "rectangle.on.rectangle",
leadingColor: displayColor?.displayColor.map { Color($0) },
sortKey: AnySortKey(ObjectIdentifier(surface))
) {
NotificationCenter.default.post(
name: Ghostty.Notification.ghosttyPresentTerminal,
object: surface
)
}
}
}
}
}
/// This is done to ensure that the given view is in the responder chain.

View File

@ -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,22 @@ 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.
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)
Ghostty.moveFocus(to: target, delay: 0.1)
// Show a brief highlight to help the user locate the presented terminal.
target.highlight()
}
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {

View File

@ -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:

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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.

View File

@ -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<T: Comparable>(_ 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
}
}

View File

@ -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)
}
}

View File

@ -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,5 +16,14 @@ 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 {
let home = FileManager.default.homeDirectoryForCurrentUser.path
if hasPrefix(home) {
return "~" + dropFirst(home.count)
}
return self
}
#endif
}