Skip to content

Commit ceeca73

Browse files
committed
input: add new_tab_with_command keybind action
Adds a new keybind action that opens a new tab running the given command instead of the configured default, e.g.: keybind = cmd+ctrl+digit_1=new_tab_with_command:ssh myhost The surface configuration plumbing (command override, inherited config notification) already existed; this exposes it through a binding action. macOS implements the full behavior; GTK currently falls back to a plain new tab.
1 parent 5659cef commit ceeca73

7 files changed

Lines changed: 129 additions & 0 deletions

File tree

include/ghostty.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,12 @@ typedef struct {
821821
uintptr_t len;
822822
} ghostty_action_open_url_s;
823823

824+
// apprt.action.NewTabCommand.C
825+
typedef struct {
826+
const char* command;
827+
uintptr_t len;
828+
} ghostty_action_new_tab_with_command_s;
829+
824830
// apprt.action.CloseTabMode
825831
typedef enum {
826832
GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,
@@ -949,6 +955,7 @@ typedef enum {
949955
GHOSTTY_ACTION_SEARCH_SELECTED,
950956
GHOSTTY_ACTION_READONLY,
951957
GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
958+
GHOSTTY_ACTION_NEW_TAB_WITH_COMMAND,
952959
} ghostty_action_tag_e;
953960

954961
typedef union {
@@ -990,6 +997,7 @@ typedef union {
990997
ghostty_action_search_total_s search_total;
991998
ghostty_action_search_selected_s search_selected;
992999
ghostty_action_readonly_e readonly;
1000+
ghostty_action_new_tab_with_command_s new_tab_with_command;
9931001
} ghostty_action_u;
9941002

9951003
typedef struct {

macos/Sources/Ghostty/Ghostty.App.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,9 @@ extension Ghostty {
500500
case GHOSTTY_ACTION_NEW_TAB:
501501
newTab(app, target: target)
502502

503+
case GHOSTTY_ACTION_NEW_TAB_WITH_COMMAND:
504+
newTabWithCommand(app, target: target, v: action.action.new_tab_with_command)
505+
503506
case GHOSTTY_ACTION_NEW_SPLIT:
504507
newSplit(app, target: target, direction: action.action.new_split)
505508

@@ -851,6 +854,54 @@ extension Ghostty {
851854
}
852855
}
853856

857+
private static func newTabWithCommand(
858+
_ app: ghostty_app_t,
859+
target: ghostty_target_s,
860+
v: ghostty_action_new_tab_with_command_s
861+
) {
862+
let command = String(
863+
bytes: UnsafeRawBufferPointer(start: v.command, count: Int(v.len)),
864+
encoding: .utf8
865+
)
866+
867+
switch target.tag {
868+
case GHOSTTY_TARGET_APP:
869+
// A new tab with a command requires a surface context so
870+
// we can inherit the proper configuration. Without one we
871+
// do nothing.
872+
Ghostty.logger.warning("new_tab_with_command does nothing with an app target")
873+
return
874+
875+
case GHOSTTY_TARGET_SURFACE:
876+
guard let surface = target.target.surface else { return }
877+
guard let surfaceView = self.surfaceView(from: surface) else { return }
878+
guard let appState = self.appState(fromView: surfaceView) else { return }
879+
guard appState.config.windowDecorations else {
880+
let alert = NSAlert()
881+
alert.messageText = "Tabs are disabled"
882+
alert.informativeText = "Enable window decorations to use tabs"
883+
alert.addButton(withTitle: "OK")
884+
alert.alertStyle = .warning
885+
_ = alert.runModal()
886+
return
887+
}
888+
889+
var config = SurfaceConfiguration(from: ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_TAB))
890+
config.command = command
891+
892+
NotificationCenter.default.post(
893+
name: Notification.ghosttyNewTab,
894+
object: surfaceView,
895+
userInfo: [
896+
Notification.NewSurfaceConfigKey: config,
897+
]
898+
)
899+
900+
default:
901+
assertionFailure()
902+
}
903+
}
904+
854905
private static func newSplit(
855906
_ app: ghostty_app_t,
856907
target: ghostty_target_s,

src/Surface.zig

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5268,6 +5268,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
52685268
{},
52695269
),
52705270

5271+
.new_tab_with_command => |v| return try self.rt_app.performAction(
5272+
.{ .surface = self },
5273+
.new_tab_with_command,
5274+
.{ .command = v },
5275+
),
5276+
52715277
.close_tab => |v| return try self.rt_app.performAction(
52725278
.{ .surface = self },
52735279
.close_tab,

src/apprt/action.zig

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@ pub const Action = union(Key) {
347347
/// otherwise the terminal-set title.
348348
copy_title_to_clipboard,
349349

350+
/// Open a new tab running the given command instead of the default.
351+
/// Otherwise behaves the same as new_tab.
352+
new_tab_with_command: NewTabCommand,
353+
350354
/// Sync with: ghostty_action_tag_e
351355
pub const Key = enum(c_int) {
352356
quit,
@@ -415,6 +419,7 @@ pub const Action = union(Key) {
415419
search_selected,
416420
readonly,
417421
copy_title_to_clipboard,
422+
new_tab_with_command,
418423

419424
test "ghostty.h Action.Key" {
420425
try lib.checkGhosttyHEnum(Key, "GHOSTTY_ACTION_");
@@ -930,6 +935,25 @@ pub const OpenUrl = struct {
930935
}
931936
};
932937

938+
/// The command to run in a new tab for new_tab_with_command.
939+
pub const NewTabCommand = struct {
940+
/// The command to execute.
941+
command: []const u8,
942+
943+
// Sync with: ghostty_action_new_tab_with_command_s
944+
pub const C = extern struct {
945+
command: [*]const u8,
946+
len: usize,
947+
};
948+
949+
pub fn cval(self: NewTabCommand) C {
950+
return .{
951+
.command = self.command.ptr,
952+
.len = self.command.len,
953+
};
954+
}
955+
};
956+
933957
/// sync with ghostty_action_close_tab_mode_e in ghostty.h
934958
pub const CloseTabMode = enum(c_int) {
935959
/// Close the current tab.

src/apprt/gtk/class/application.zig

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,10 @@ pub const Application = extern struct {
710710

711711
.new_tab => return Action.newTab(target),
712712

713+
// TODO: GTK does not yet support the custom command and
714+
// falls back to opening a regular new tab.
715+
.new_tab_with_command => return Action.newTab(target),
716+
713717
.new_window => try Action.newWindow(
714718
self,
715719
switch (target) {

src/input/Binding.zig

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,22 @@ pub const Action = union(enum) {
559559
/// Open a new tab.
560560
new_tab,
561561

562+
/// Open a new tab running the given command instead of the
563+
/// default configured command, e.g.
564+
/// `keybind = cmd+ctrl+digit_1=new_tab_with_command:ssh myhost`
565+
///
566+
/// The command is always run in a shell (e.g. `/bin/sh -c`), so it
567+
/// may contain arguments. Unlike the `command` configuration, the
568+
/// `direct:` and `shell:` prefixes are not supported.
569+
///
570+
/// The new tab behaves as if `wait-after-command` were enabled:
571+
/// when the command exits, the terminal remains open and must be
572+
/// closed manually.
573+
///
574+
/// This is currently only fully implemented on macOS. On other
575+
/// platforms this opens a new tab with the default command.
576+
new_tab_with_command: []const u8,
577+
562578
/// Go to the previous tab.
563579
previous_tab,
564580

@@ -1413,6 +1429,7 @@ pub const Action = union(enum) {
14131429
// come from. For example `new_window` needs to be sourced to
14141430
// a surface so inheritance can be done correctly.
14151431
.new_tab,
1432+
.new_tab_with_command,
14161433
.previous_tab,
14171434
.next_tab,
14181435
.last_tab,
@@ -3072,6 +3089,24 @@ test "parse: text action equals sign" {
30723089
}
30733090
}
30743091

3092+
test "parse: new_tab_with_command" {
3093+
const testing = std.testing;
3094+
{
3095+
const binding = try parseSingle("ctrl+a=new_tab_with_command:ssh myhost");
3096+
try testing.expect(binding.action == .new_tab_with_command);
3097+
try testing.expectEqualStrings(
3098+
"ssh myhost",
3099+
binding.action.new_tab_with_command,
3100+
);
3101+
}
3102+
3103+
// A command is required: no colon is an error.
3104+
try testing.expectError(
3105+
Error.InvalidFormat,
3106+
parseSingle("ctrl+a=new_tab_with_command"),
3107+
);
3108+
}
3109+
30753110
// For Ghostty 1.2+ we changed our key names to match the W3C and removed
30763111
// `physical:`. This tests the backwards compatibility with the old format.
30773112
// Note that our backwards compatibility isn't 100% perfect since triggers

src/input/command.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,7 @@ fn actionCommands(action: Action.Key) []const Command {
699699
.jump_to_prompt,
700700
.write_scrollback_file,
701701
.goto_tab,
702+
.new_tab_with_command,
702703
.resize_split,
703704
.activate_key_table,
704705
.activate_key_table_once,

0 commit comments

Comments
 (0)