macOS: Selection for Find feature (#10192)

Adds the `selection_for_search` action, with Cmd+E keybind by default.
This action inputs the currently selected text into the search field
without changing focus, matching standard macOS behavior.

Implements discussion #9776 and #10036

<details><summary>AI Disclosure</summary>
<p>

Tab completions with Codestral.
Reviewed by Gemini 3 Flash via chatting.
No Agentic coding tools were involved.

</p>
</details>
This commit is contained in:
Mitchell Hashimoto
2026-01-06 14:26:03 -08:00
committed by GitHub
12 changed files with 65 additions and 8 deletions

View File

@@ -882,7 +882,7 @@ typedef enum {
GHOSTTY_ACTION_SEARCH_TOTAL,
GHOSTTY_ACTION_SEARCH_SELECTED,
GHOSTTY_ACTION_READONLY,
} ghostty_action_tag_e;
} ghostty_action_tag_e;
typedef union {
ghostty_action_split_direction_e new_split;

View File

@@ -46,6 +46,7 @@ class AppDelegate: NSObject,
@IBOutlet private var menuSelectAll: NSMenuItem?
@IBOutlet private var menuFindParent: NSMenuItem?
@IBOutlet private var menuFind: NSMenuItem?
@IBOutlet private var menuSelectionForFind: NSMenuItem?
@IBOutlet private var menuFindNext: NSMenuItem?
@IBOutlet private var menuFindPrevious: NSMenuItem?
@IBOutlet private var menuHideFindBar: NSMenuItem?
@@ -615,6 +616,7 @@ class AppDelegate: NSObject,
syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection)
syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll)
syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind)
syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind)
syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext)
syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious)

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24412" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24412"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@@ -58,6 +58,7 @@
<outlet property="menuSelectSplitBelow" destination="QDz-d9-CBr" id="FsH-Dq-jij"/>
<outlet property="menuSelectSplitLeft" destination="cTK-oy-KuV" id="Jpr-5q-dqz"/>
<outlet property="menuSelectSplitRight" destination="upj-mc-L7X" id="nLY-o1-lky"/>
<outlet property="menuSelectionForSearch" destination="TDN-42-Bu7" id="M04-1K-vze"/>
<outlet property="menuServices" destination="aQe-vS-j8Q" id="uWQ-Wo-T1L"/>
<outlet property="menuSplitDown" destination="UDZ-4y-6xL" id="ptr-mj-Azh"/>
<outlet property="menuSplitLeft" destination="Ppv-GP-lQU" id="Xd5-Cd-Jut"/>
@@ -281,6 +282,13 @@
<action selector="findHide:" target="-1" id="hGP-K9-yN9"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="2N8-Xz-RVc"/>
<menuItem title="Use Selection for Find" id="TDN-42-Bu7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="selectionForFind:" target="-1" id="rhL-7g-XQQ"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>

View File

@@ -1383,7 +1383,11 @@ class BaseTerminalController: NSWindowController,
@IBAction func find(_ sender: Any) {
focusedSurface?.find(sender)
}
@IBAction func selectionForFind(_ sender: Any) {
focusedSurface?.selectionForFind(sender)
}
@IBAction func findNext(_ sender: Any) {
focusedSurface?.findNext(sender)
}

View File

@@ -1869,11 +1869,15 @@ extension Ghostty {
let startSearch = Ghostty.Action.StartSearch(c: v)
DispatchQueue.main.async {
if surfaceView.searchState != nil {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
if let searchState = surfaceView.searchState {
if let needle = startSearch.needle, !needle.isEmpty {
searchState.needle = needle
}
} else {
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)
}
NotificationCenter.default.post(name: .ghosttySearchFocus, object: surfaceView)
}
default:

View File

@@ -475,7 +475,9 @@ extension Ghostty {
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttySearchFocus)) { notification in
guard notification.object as? SurfaceView === surfaceView else { return }
isSearchFieldFocused = true
DispatchQueue.main.async {
isSearchFieldFocused = true
}
}
.background(
GeometryReader { barGeo in

View File

@@ -1519,6 +1519,14 @@ extension Ghostty {
}
}
@IBAction func selectionForFind(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "search_selection"
if (!ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8)))) {
AppDelegate.logger.warning("action failed action=\(action)")
}
}
@IBAction func findNext(_ sender: Any?) {
guard let surface = self.surface else { return }
let action = "search:next"

View File

@@ -5163,6 +5163,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
);
},
.search_selection => {
const selection = try self.selectionString(self.alloc) orelse return false;
return try self.rt_app.performAction(
.{ .surface = self },
.start_search,
.{ .needle = selection },
);
},
.end_search => {
// We only return that this was performed if we actually
// stopped a search, but we also send the apprt end_search so

View File

@@ -313,7 +313,9 @@ pub const Action = union(Key) {
/// A command has finished,
command_finished: CommandFinished,
/// Start the search overlay with an optional initial needle.
/// Start the search overlay with an optional initial needle. If the
/// search is already active and the needle is non-empty, update the
/// current search needle and focus the search input.
start_search: StartSearch,
/// End the search overlay, clearing the search state and hiding it.

View File

@@ -6585,6 +6585,12 @@ pub const Keybinds = struct {
.start_search,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'e' }, .mods = .{ .super = true } },
.search_selection,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'f' }, .mods = .{ .super = true, .shift = true } },

View File

@@ -368,6 +368,11 @@ pub const Action = union(enum) {
/// If a previous search is active, it is replaced.
search: []const u8,
/// Start a search for the current text selection. If there is no
/// selection, this does nothing. If a search is already active, this
/// changes the search terms.
search_selection,
/// Navigate the search results. If there is no active search, this
/// is not performed.
navigate_search: NavigateSearch,
@@ -1284,6 +1289,7 @@ pub const Action = union(enum) {
.cursor_key,
.search,
.navigate_search,
.search_selection,
.start_search,
.end_search,
.reset,

View File

@@ -189,6 +189,12 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Start a search if one isn't already active.",
}},
.search_selection => comptime &.{.{
.action = .search_selection,
.title = "Search Selection",
.description = "Start a search for the current text selection.",
}},
.end_search => comptime &.{.{
.action = .end_search,
.title = "End Search",