macOS: Command Palette (#7153)

This introduces a command palette (inspired by @pluiedev's work in
#5681, but not using it as a base) for macOS.

The command palette is available in the `View` menu and also bindable
via `toggle_command_palette`, default binding is `cmd+shift+p` to match
VSCode.

The commands in the command palette must map to a _bindable_ action,
though they may not have an associated keybinding. This means that any
new binding actions we add in the future can be represented here and
also makes it easy in the future to add configuration to add new custom
entries to the command palette. For this initial PR, the available
commands are hardcoded (`src/input/commands.zig`).

I've noticed in other programs (VSCode, Zed), the command palette
contains pretty much _all available actions_ even if they're basically
useless in the context of a command palette. For example, Zed has the
"toggle command palette" action in the command palette and it... does
nothing (it probably should hide the palette). I followed @pluiedev's
lead and made this subjective in this PR but I wonder if we should
actually force all binding actions to be available.

There are various other improvements I'd like to make but omitted from
this PR for the sake of limiting scope:

* Instead of an entry with no matches doing nothing, we can allow users
to manually input _any_ configurable binding.
* Localization, since macOS doesn't have any yet. But for Linux when we
port this we probably have to change our strings extraction.

## Demo


https://github.com/user-attachments/assets/a2155cfb-d86b-4c1a-82b5-74ba927e4d69
This commit is contained in:
Mitchell Hashimoto
2025-04-22 08:52:27 -07:00
committed by GitHub
22 changed files with 951 additions and 49 deletions

View File

@@ -4295,6 +4295,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.toggle,
),
.toggle_command_palette => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_command_palette,
{},
),
.select_all => {
const sel = self.io.terminal.screen.selectAll();
if (sel) |s| {

View File

@@ -107,6 +107,9 @@ pub const Action = union(Key) {
/// Toggle the quick terminal in or out.
toggle_quick_terminal,
/// Toggle the command palette. This currently only works on macOS.
toggle_command_palette,
/// Toggle the visibility of all Ghostty terminal windows.
toggle_visibility,
@@ -244,6 +247,8 @@ pub const Action = union(Key) {
/// Closes the currently focused window.
close_window,
/// Called when the bell character is seen. The apprt should do whatever
/// it needs to ring the bell. This is usually a sound or visual effect.
ring_bell,
/// Sync with: ghostty_action_tag_e
@@ -259,6 +264,7 @@ pub const Action = union(Key) {
toggle_tab_overview,
toggle_window_decorations,
toggle_quick_terminal,
toggle_command_palette,
toggle_visibility,
move_tab,
goto_tab,

View File

@@ -1487,6 +1487,23 @@ pub const CAPI = struct {
return @intCast(@as(input.Mods.Backing, @bitCast(result)));
}
/// Returns the current possible commands for a surface
/// in the output parameter. The memory is owned by libghostty
/// and doesn't need to be freed.
export fn ghostty_surface_commands(
surface: *Surface,
out: *[*]const input.Command.C,
len: *usize,
) void {
// In the future we may use this information to filter
// some commands.
_ = surface;
const commands = input.command.defaultsC;
out.* = commands.ptr;
len.* = commands.len;
}
/// Send this for raw keypresses (i.e. the keyDown event on macOS).
/// This will handle the keymap translation and send the appropriate
/// key and char events.

View File

@@ -228,6 +228,7 @@ pub const App = struct {
.toggle_tab_overview,
.toggle_window_decorations,
.toggle_quick_terminal,
.toggle_command_palette,
.toggle_visibility,
.goto_tab,
.move_tab,

View File

@@ -488,6 +488,7 @@ pub fn performAction(
// Unimplemented
.close_all_windows,
.toggle_command_palette,
.toggle_visibility,
.cell_size,
.key_sequence,

View File

@@ -4866,6 +4866,13 @@ pub const Keybinds = struct {
.{ .jump_to_prompt = 1 },
);
// Toggle command palette, matches VSCode
try self.set.put(
alloc,
.{ .key = .{ .translated = .p }, .mods = .{ .super = true, .shift = true } },
.{ .toggle_command_palette = {} },
);
// Inspector, matching Chromium
try self.set.put(
alloc,

View File

@@ -5,6 +5,7 @@ const mouse = @import("input/mouse.zig");
const key = @import("input/key.zig");
const keyboard = @import("input/keyboard.zig");
pub const command = @import("input/command.zig");
pub const function_keys = @import("input/function_keys.zig");
pub const keycodes = @import("input/keycodes.zig");
pub const kitty = @import("input/kitty.zig");
@@ -12,6 +13,7 @@ pub const kitty = @import("input/kitty.zig");
pub const ctrlOrSuper = key.ctrlOrSuper;
pub const Action = key.Action;
pub const Binding = @import("input/Binding.zig");
pub const Command = command.Command;
pub const Link = @import("input/Link.zig");
pub const Key = key.Key;
pub const KeyboardLayout = keyboard.Layout;

View File

@@ -441,6 +441,14 @@ pub const Action = union(enum) {
/// This only works on macOS, since this is a system API on macOS.
toggle_secure_input: void,
/// Toggle the command palette. The command palette is a UI element
/// that lets you see what actions you can perform, their associated
/// keybindings (if any), a search bar to filter the actions, and
/// the ability to then execute the action.
///
/// This only works on macOS.
toggle_command_palette,
/// Toggle the "quick" terminal. The quick terminal is a terminal that
/// appears on demand from a keybinding, often sliding in from a screen
/// edge such as the top. This is useful for quick access to a terminal
@@ -790,6 +798,7 @@ pub const Action = union(enum) {
.toggle_fullscreen,
.toggle_window_decorations,
.toggle_secure_input,
.toggle_command_palette,
.reset_window_size,
.crash,
=> .surface,
@@ -1017,15 +1026,6 @@ pub const Action = union(enum) {
}
};
// A key for the C API to execute an action. This must be kept in sync
// with include/ghostty.h.
pub const Key = enum(c_int) {
copy_to_clipboard,
paste_from_clipboard,
new_tab,
new_window,
};
/// Trigger is the associated key state that can trigger an action.
/// This is an extern struct because this is also used in the C API.
///

408
src/input/command.zig Normal file
View File

@@ -0,0 +1,408 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Action = @import("Binding.zig").Action;
/// A command is a named binding action that can be executed from
/// something like a command palette.
///
/// A command must be associated with a binding; all commands can be
/// mapped to traditional `keybind` configurations. This restriction
/// makes it so that there is nothing special about commands and likewise
/// it makes it trivial and consistent to define custom commands.
///
/// For apprt implementers: a command palette doesn't have to make use
/// of all the fields here. We try to provide as much information as
/// possible to make it easier to implement a command palette in the way
/// that makes the most sense for the application.
pub const Command = struct {
action: Action,
title: [:0]const u8,
description: [:0]const u8,
/// ghostty_command_s
pub const C = extern struct {
action_key: [*:0]const u8,
action: [*:0]const u8,
title: [*:0]const u8,
description: [*:0]const u8,
};
/// Convert this command to a C struct.
pub fn comptimeCval(self: Command) C {
assert(@inComptime());
return .{
.action_key = @tagName(self.action),
.action = std.fmt.comptimePrint("{s}", .{self.action}),
.title = self.title,
.description = self.description,
};
}
/// Implements a comparison function for std.mem.sortUnstable
/// and similar functions. The sorting is defined by Ghostty
/// to be what we prefer. If a caller wants some other sorting,
/// they should do it themselves.
pub fn lessThan(_: void, lhs: Command, rhs: Command) bool {
return std.ascii.orderIgnoreCase(lhs.title, rhs.title) == .lt;
}
};
pub const defaults: []const Command = defaults: {
@setEvalBranchQuota(100_000);
var count: usize = 0;
for (@typeInfo(Action.Key).@"enum".fields) |field| {
const action = @field(Action.Key, field.name);
count += actionCommands(action).len;
}
var result: [count]Command = undefined;
var i: usize = 0;
for (@typeInfo(Action.Key).@"enum".fields) |field| {
const action = @field(Action.Key, field.name);
const commands = actionCommands(action);
for (commands) |cmd| {
result[i] = cmd;
i += 1;
}
}
std.mem.sortUnstable(Command, &result, {}, Command.lessThan);
assert(i == count);
const final = result;
break :defaults &final;
};
/// Defaults in C-compatible form.
pub const defaultsC: []const Command.C = defaults: {
var result: [defaults.len]Command.C = undefined;
for (defaults, 0..) |cmd, i| result[i] = cmd.comptimeCval();
const final = result;
break :defaults &final;
};
/// Returns the set of commands associated with this action key by
/// default. Not all actions should have commands. As a general guideline,
/// an action should have a command only if it is useful and reasonable
/// to appear in a command palette.
fn actionCommands(action: Action.Key) []const Command {
// This is implemented as a function and switch rather than a
// flat comptime const because we want to ensure we get a compiler
// error when a new binding is added so that the contributor has
// to consider whether that new binding should have commands or not.
const result: []const Command = switch (action) {
// Note: the use of `comptime` prefix on the return values
// ensures that the data returned is all in the binary and
// and not pointing to the stack.
.reset => comptime &.{.{
.action = .reset,
.title = "Reset Terminal",
.description = "Reset the terminal to a clean state.",
}},
.copy_to_clipboard => comptime &.{.{
.action = .copy_to_clipboard,
.title = "Copy to Clipboard",
.description = "Copy the selected text to the clipboard.",
}},
.copy_url_to_clipboard => comptime &.{.{
.action = .copy_url_to_clipboard,
.title = "Copy URL to Clipboard",
.description = "Copy the URL under the cursor to the clipboard.",
}},
.paste_from_clipboard => comptime &.{.{
.action = .paste_from_clipboard,
.title = "Paste from Clipboard",
.description = "Paste the contents of the clipboard.",
}},
.paste_from_selection => comptime &.{.{
.action = .paste_from_selection,
.title = "Paste from Selection",
.description = "Paste the contents of the selection clipboard.",
}},
.increase_font_size => comptime &.{.{
.action = .{ .increase_font_size = 1 },
.title = "Increase Font Size",
.description = "Increase the font size by 1 point.",
}},
.decrease_font_size => comptime &.{.{
.action = .{ .decrease_font_size = 1 },
.title = "Decrease Font Size",
.description = "Decrease the font size by 1 point.",
}},
.reset_font_size => comptime &.{.{
.action = .reset_font_size,
.title = "Reset Font Size",
.description = "Reset the font size to the default.",
}},
.clear_screen => comptime &.{.{
.action = .clear_screen,
.title = "Clear Screen",
.description = "Clear the screen and scrollback.",
}},
.select_all => comptime &.{.{
.action = .select_all,
.title = "Select All",
.description = "Select all text on the screen.",
}},
.scroll_to_top => comptime &.{.{
.action = .scroll_to_top,
.title = "Scroll to Top",
.description = "Scroll to the top of the screen.",
}},
.scroll_to_bottom => comptime &.{.{
.action = .scroll_to_bottom,
.title = "Scroll to Bottom",
.description = "Scroll to the bottom of the screen.",
}},
.scroll_page_up => comptime &.{.{
.action = .scroll_page_up,
.title = "Scroll Page Up",
.description = "Scroll the screen up by a page.",
}},
.scroll_page_down => comptime &.{.{
.action = .scroll_page_down,
.title = "Scroll Page Down",
.description = "Scroll the screen down by a page.",
}},
.write_screen_file => comptime &.{
.{
.action = .{ .write_screen_file = .paste },
.title = "Copy Screen to Temporary File and Paste Path",
.description = "Copy the screen contents to a temporary file and paste the path to the file.",
},
.{
.action = .{ .write_screen_file = .open },
.title = "Copy Screen to Temporary File and Open",
.description = "Copy the screen contents to a temporary file and open it.",
},
},
.write_selection_file => comptime &.{
.{
.action = .{ .write_selection_file = .paste },
.title = "Copy Selection to Temporary File and Paste Path",
.description = "Copy the selection contents to a temporary file and paste the path to the file.",
},
.{
.action = .{ .write_selection_file = .open },
.title = "Copy Selection to Temporary File and Open",
.description = "Copy the selection contents to a temporary file and open it.",
},
},
.new_window => comptime &.{.{
.action = .new_window,
.title = "New Window",
.description = "Open a new window.",
}},
.new_tab => comptime &.{.{
.action = .new_tab,
.title = "New Tab",
.description = "Open a new tab.",
}},
.move_tab => comptime &.{
.{
.action = .{ .move_tab = -1 },
.title = "Move Tab Left",
.description = "Move the current tab to the left.",
},
.{
.action = .{ .move_tab = 1 },
.title = "Move Tab Right",
.description = "Move the current tab to the right.",
},
},
.toggle_tab_overview => comptime &.{.{
.action = .toggle_tab_overview,
.title = "Toggle Tab Overview",
.description = "Toggle the tab overview.",
}},
.prompt_surface_title => comptime &.{.{
.action = .prompt_surface_title,
.title = "Change Title...",
.description = "Prompt for a new title for the current terminal.",
}},
.new_split => comptime &.{
.{
.action = .{ .new_split = .left },
.title = "Split Left",
.description = "Split the terminal to the left.",
},
.{
.action = .{ .new_split = .right },
.title = "Split Right",
.description = "Split the terminal to the right.",
},
.{
.action = .{ .new_split = .up },
.title = "Split Up",
.description = "Split the terminal up.",
},
.{
.action = .{ .new_split = .down },
.title = "Split Down",
.description = "Split the terminal down.",
},
},
.toggle_split_zoom => comptime &.{.{
.action = .toggle_split_zoom,
.title = "Toggle Split Zoom",
.description = "Toggle the zoom state of the current split.",
}},
.equalize_splits => comptime &.{.{
.action = .equalize_splits,
.title = "Equalize Splits",
.description = "Equalize the size of all splits.",
}},
.reset_window_size => comptime &.{.{
.action = .reset_window_size,
.title = "Reset Window Size",
.description = "Reset the window size to the default.",
}},
.inspector => comptime &.{.{
.action = .{ .inspector = .toggle },
.title = "Toggle Inspector",
.description = "Toggle the inspector.",
}},
.open_config => comptime &.{.{
.action = .open_config,
.title = "Open Config",
.description = "Open the config file.",
}},
.reload_config => comptime &.{.{
.action = .reload_config,
.title = "Reload Config",
.description = "Reload the config file.",
}},
.close_surface => comptime &.{.{
.action = .close_surface,
.title = "Close Terminal",
.description = "Close the current terminal.",
}},
.close_tab => comptime &.{.{
.action = .close_tab,
.title = "Close Tab",
.description = "Close the current tab.",
}},
.close_window => comptime &.{.{
.action = .close_window,
.title = "Close Window",
.description = "Close the current window.",
}},
.close_all_windows => comptime &.{.{
.action = .close_all_windows,
.title = "Close All Windows",
.description = "Close all windows.",
}},
.toggle_maximize => comptime &.{.{
.action = .toggle_maximize,
.title = "Toggle Maximize",
.description = "Toggle the maximized state of the current window.",
}},
.toggle_fullscreen => comptime &.{.{
.action = .toggle_fullscreen,
.title = "Toggle Fullscreen",
.description = "Toggle the fullscreen state of the current window.",
}},
.toggle_window_decorations => comptime &.{.{
.action = .toggle_window_decorations,
.title = "Toggle Window Decorations",
.description = "Toggle the window decorations.",
}},
.toggle_secure_input => comptime &.{.{
.action = .toggle_secure_input,
.title = "Toggle Secure Input",
.description = "Toggle secure input mode.",
}},
.quit => comptime &.{.{
.action = .quit,
.title = "Quit",
.description = "Quit the application.",
}},
// No commands because they're parameterized and there
// aren't obvious values users would use. It is possible that
// these may have commands in the future if there are very
// common values that users tend to use.
.csi,
.esc,
.text,
.cursor_key,
.scroll_page_fractional,
.scroll_page_lines,
.adjust_selection,
.jump_to_prompt,
.write_scrollback_file,
.goto_tab,
.goto_split,
.resize_split,
.crash,
=> comptime &.{},
// No commands because I'm not sure they make sense in a command
// palette context.
.toggle_command_palette,
.toggle_quick_terminal,
.toggle_visibility,
.previous_tab,
.next_tab,
.last_tab,
=> comptime &.{},
// No commands for obvious reasons
.ignore,
.unbind,
=> comptime &.{},
};
// All generated commands should have the same action as the
// action passed in.
for (result) |cmd| assert(cmd.action == action);
return result;
}
test "command defaults" {
// This just ensures that defaults is analyzed and works.
const testing = std.testing;
try testing.expect(defaults.len > 0);
try testing.expectEqual(defaults.len, defaultsC.len);
}