macOS: command-palette-entry shows up in command palette (#10050)

Fixes #7158

This has worked on GTK for awhile. This exposes custom command palette
entries to the macOS app now.
This commit is contained in:
Mitchell Hashimoto
2025-12-24 14:42:35 -08:00
committed by GitHub
6 changed files with 134 additions and 9 deletions

View File

@@ -454,6 +454,12 @@ typedef struct {
size_t len;
} ghostty_config_color_list_s;
// config.RepeatableCommand
typedef struct {
const ghostty_command_s* commands;
size_t len;
} ghostty_config_command_list_s;
// config.Palette
typedef struct {
ghostty_config_color_s colors[256];

View File

@@ -137,7 +137,6 @@
Features/Update/UpdateSimulator.swift,
Features/Update/UpdateViewModel.swift,
"Ghostty/FullscreenMode+Extension.swift",
Ghostty/Ghostty.Command.swift,
Ghostty/Ghostty.Error.swift,
Ghostty/Ghostty.Event.swift,
Ghostty/Ghostty.Input.swift,

View File

@@ -64,7 +64,7 @@ struct TerminalCommandPaletteView: View {
// 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
options.append(contentsOf: (jumpOptions + terminalOptions + customEntries).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)
@@ -135,6 +135,19 @@ struct TerminalCommandPaletteView: View {
}
}
/// Custom commands from the command-palette-entry configuration.
private var customEntries: [CommandOption] {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return [] }
return appDelegate.ghostty.config.commandPaletteEntries.map { c in
CommandOption(
title: c.title,
description: c.description
) {
onAction(c.action)
}
}
}
/// Commands for jumping to other terminal surfaces.
private var jumpOptions: [CommandOption] {
TerminalController.all.flatMap { controller -> [CommandOption] in

View File

@@ -622,6 +622,16 @@ extension Ghostty {
let str = String(cString: ptr)
return Scrollbar(rawValue: str) ?? defaultValue
}
var commandPaletteEntries: [Ghostty.Command] {
guard let config = self.config else { return [] }
var v: ghostty_config_command_list_s = .init()
let key = "command-palette-entry"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return [] }
guard v.len > 0 else { return [] }
let buffer = UnsafeBufferPointer(start: v.commands, count: v.len)
return buffer.map { Ghostty.Command(cValue: $0) }
}
}
}

View File

@@ -8041,15 +8041,37 @@ pub const SplitPreserveZoom = packed struct {
};
pub const RepeatableCommand = struct {
value: std.ArrayListUnmanaged(inputpkg.Command) = .empty,
const Self = @This();
pub fn init(self: *RepeatableCommand, alloc: Allocator) !void {
value: std.ArrayListUnmanaged(inputpkg.Command) = .empty,
value_c: std.ArrayListUnmanaged(inputpkg.Command.C) = .empty,
/// ghostty_config_command_list_s
pub const C = extern struct {
commands: [*]inputpkg.Command.C,
len: usize,
};
pub fn cval(self: *const Self) C {
return .{
.commands = self.value_c.items.ptr,
.len = self.value_c.items.len,
};
}
pub fn init(self: *Self, alloc: Allocator) !void {
self.value = .empty;
self.value_c = .empty;
errdefer {
self.value.deinit(alloc);
self.value_c.deinit(alloc);
}
try self.value.appendSlice(alloc, inputpkg.command.defaults);
try self.value_c.appendSlice(alloc, inputpkg.command.defaultsC);
}
pub fn parseCLI(
self: *RepeatableCommand,
self: *Self,
alloc: Allocator,
input_: ?[]const u8,
) !void {
@@ -8057,26 +8079,36 @@ pub const RepeatableCommand = struct {
const input = input_ orelse "";
if (input.len == 0) {
self.value.clearRetainingCapacity();
self.value_c.clearRetainingCapacity();
return;
}
// Reserve space in our lists
try self.value.ensureUnusedCapacity(alloc, 1);
try self.value_c.ensureUnusedCapacity(alloc, 1);
const cmd = try cli.args.parseAutoStruct(
inputpkg.Command,
alloc,
input,
null,
);
try self.value.append(alloc, cmd);
const cmd_c = try cmd.cval(alloc);
self.value.appendAssumeCapacity(cmd);
self.value_c.appendAssumeCapacity(cmd_c);
}
/// Deep copy of the struct. Required by Config.
pub fn clone(self: *const RepeatableCommand, alloc: Allocator) Allocator.Error!RepeatableCommand {
pub fn clone(self: *const Self, alloc: Allocator) Allocator.Error!Self {
const value = try self.value.clone(alloc);
for (value.items) |*item| {
item.* = try item.clone(alloc);
}
return .{ .value = value };
return .{
.value = value,
.value_c = try self.value_c.clone(alloc),
};
}
/// Compare if two of our value are equal. Required by Config.
@@ -8232,6 +8264,50 @@ pub const RepeatableCommand = struct {
try testing.expectEqualStrings("kurwa", item.action.text);
}
}
test "RepeatableCommand cval" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:Foo,action:ignore");
try list.parseCLI(alloc, "title:Bar,description:bobr,action:text:ale bydle");
try testing.expectEqual(@as(usize, 2), list.value.items.len);
try testing.expectEqual(@as(usize, 2), list.value_c.items.len);
const cv = list.cval();
try testing.expectEqual(@as(usize, 2), cv.len);
// First entry
try testing.expectEqualStrings("Foo", std.mem.sliceTo(cv.commands[0].title, 0));
try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action_key, 0));
try testing.expectEqualStrings("ignore", std.mem.sliceTo(cv.commands[0].action, 0));
// Second entry
try testing.expectEqualStrings("Bar", std.mem.sliceTo(cv.commands[1].title, 0));
try testing.expectEqualStrings("bobr", std.mem.sliceTo(cv.commands[1].description, 0));
try testing.expectEqualStrings("text", std.mem.sliceTo(cv.commands[1].action_key, 0));
try testing.expectEqualStrings("text:ale bydle", std.mem.sliceTo(cv.commands[1].action, 0));
}
test "RepeatableCommand cval cleared" {
const testing = std.testing;
var arena = ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const alloc = arena.allocator();
var list: RepeatableCommand = .{};
try list.parseCLI(alloc, "title:Foo,action:ignore");
try testing.expectEqual(@as(usize, 1), list.cval().len);
try list.parseCLI(alloc, "");
try testing.expectEqual(@as(usize, 0), list.cval().len);
}
};
/// OSC 4, 10, 11, and 12 default color reporting format.

View File

@@ -43,7 +43,7 @@ pub const Command = struct {
return true;
}
/// Convert this command to a C struct.
/// Convert this command to a C struct at comptime.
pub fn comptimeCval(self: Command) C {
assert(@inComptime());
@@ -55,6 +55,27 @@ pub const Command = struct {
};
}
/// Convert this command to a C struct at runtime.
///
/// This shares memory with the original command.
///
/// The action string is allocated using the provided allocator. You can
/// free the slice directly if you need to but we recommend an arena
/// for this.
pub fn cval(self: Command, alloc: Allocator) Allocator.Error!C {
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
self.action.format(&buf.writer) catch return error.OutOfMemory;
const action = try buf.toOwnedSliceSentinel(0);
return .{
.action_key = @tagName(self.action),
.action = action.ptr,
.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,