add direct set_surface_title and set_tab_title actions (#11373)

Fixes #11316

This mirrors the `prompt` actions (hence why there is no window action
here) and enables setting titles via keybind actions which importantly
lets this work via command palettes, App Intents, AppleScript, etc.
pull/11377/head
Mitchell Hashimoto 2026-03-11 09:35:56 -07:00 committed by GitHub
commit 8ad9ec8e88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 117 additions and 0 deletions

View File

@ -889,6 +889,7 @@ typedef enum {
GHOSTTY_ACTION_RENDER_INSPECTOR,
GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
GHOSTTY_ACTION_SET_TITLE,
GHOSTTY_ACTION_SET_TAB_TITLE,
GHOSTTY_ACTION_PROMPT_TITLE,
GHOSTTY_ACTION_PWD,
GHOSTTY_ACTION_MOUSE_SHAPE,
@ -937,6 +938,7 @@ typedef union {
ghostty_action_inspector_e inspector;
ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title;
ghostty_action_set_title_s set_tab_title;
ghostty_action_prompt_title_e prompt_title;
ghostty_action_pwd_s pwd;
ghostty_action_mouse_shape_e mouse_shape;

View File

@ -539,6 +539,9 @@ extension Ghostty {
case GHOSTTY_ACTION_SET_TITLE:
setTitle(app, target: target, v: action.action.set_title)
case GHOSTTY_ACTION_SET_TAB_TITLE:
return setTabTitle(app, target: target, v: action.action.set_tab_title)
case GHOSTTY_ACTION_PROMPT_TITLE:
return promptTitle(app, target: target, v: action.action.prompt_title)
@ -1602,6 +1605,33 @@ extension Ghostty {
}
}
private static func setTabTitle(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_set_title_s
) -> Bool {
switch target.tag {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("set tab title does nothing with an app target")
return false
case GHOSTTY_TARGET_SURFACE:
guard let title = String(cString: v.title!, encoding: .utf8) else { return false }
let titleOverride = title.isEmpty ? nil : title
guard let surface = target.target.surface else { return false }
guard let surfaceView = self.surfaceView(from: surface) else { return false }
guard let window = surfaceView.window,
let controller = window.windowController as? BaseTerminalController
else { return false }
controller.titleOverride = titleOverride
return true
default:
assertionFailure()
return false
}
}
private static func copyTitleToClipboard(
_ app: ghostty_app_t,
target: ghostty_target_s) -> Bool {

View File

@ -5482,6 +5482,26 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.tab,
),
.set_surface_title => |v| {
const title = try self.alloc.dupeZ(u8, v);
defer self.alloc.free(title);
return try self.rt_app.performAction(
.{ .surface = self },
.set_title,
.{ .title = title },
);
},
.set_tab_title => |v| {
const title = try self.alloc.dupeZ(u8, v);
defer self.alloc.free(title);
return try self.rt_app.performAction(
.{ .surface = self },
.set_tab_title,
.{ .title = title },
);
},
.clear_screen => {
// This is a duplicate of some of the logic in termio.clearScreen
// but we need to do this here so we can know the answer before

View File

@ -201,6 +201,9 @@ pub const Action = union(Key) {
/// Set the title of the target to the requested value.
set_title: SetTitle,
/// Set the tab title override for the target's tab.
set_tab_title: SetTitle,
/// Set the title of the target to a prompted value. It is up to
/// the apprt to prompt. The value specifies whether to prompt for the
/// surface title or the tab title.
@ -375,6 +378,7 @@ pub const Action = union(Key) {
render_inspector,
desktop_notification,
set_title,
set_tab_title,
prompt_title,
pwd,
mouse_shape,

View File

@ -740,6 +740,7 @@ pub const Application = extern struct {
.scrollbar => Action.scrollbar(target, value),
.set_title => Action.setTitle(target, value),
.set_tab_title => return Action.setTabTitle(target, value),
.show_child_exited => return Action.showChildExited(target, value),
@ -2545,6 +2546,30 @@ const Action = struct {
}
}
pub fn setTabTitle(
target: apprt.Target,
value: apprt.action.SetTitle,
) bool {
switch (target) {
.app => {
log.warn("set_tab_title to app is unexpected", .{});
return false;
},
.surface => |core| {
const surface = core.rt_surface.surface;
const tab = ext.getAncestor(
Tab,
surface.as(gtk.Widget),
) orelse {
log.warn("surface is not in a tab, ignoring set_tab_title", .{});
return false;
};
tab.setTitleOverride(if (value.title.len == 0) null else value.title);
return true;
},
}
}
pub fn showChildExited(
target: apprt.Target,
value: apprt.surface.Message.ChildExited,

View File

@ -577,6 +577,16 @@ pub const Action = union(enum) {
/// and persists across focus changes within the tab.
prompt_tab_title,
/// Set the title for the current focused surface.
///
/// If the title is empty, the surface title is reset to an empty title.
set_surface_title: []const u8,
/// Set the title for the current focused tab.
///
/// If the title is empty, the tab title override is cleared.
set_tab_title: []const u8,
/// Create a new split in the specified direction.
///
/// Valid arguments:
@ -1324,6 +1334,8 @@ pub const Action = union(enum) {
.set_font_size,
.prompt_surface_title,
.prompt_tab_title,
.set_surface_title,
.set_tab_title,
.clear_screen,
.select_all,
.scroll_to_top,
@ -3292,6 +3304,16 @@ test "parse: action with string" {
try testing.expect(binding.action == .esc);
try testing.expectEqualStrings("A", binding.action.esc);
}
{
const binding = try parseSingle("a=set_surface_title:surface");
try testing.expect(binding.action == .set_surface_title);
try testing.expectEqualStrings("surface", binding.action.set_surface_title);
}
{
const binding = try parseSingle("a=set_tab_title:tab");
try testing.expect(binding.action == .set_tab_title);
try testing.expectEqualStrings("tab", binding.action.set_tab_title);
}
}
test "parse: action with enum" {
@ -4557,6 +4579,18 @@ test "action: format" {
try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written());
}
test "action: format set title" {
const testing = std.testing;
const alloc = testing.allocator;
const a: Action = .{ .set_tab_title = "foo bar" };
var buf: std.Io.Writer.Allocating = .init(alloc);
defer buf.deinit();
try a.format(&buf.writer);
try testing.expectEqualStrings("set_tab_title:foo bar", buf.written());
}
test "set: appendChain with no parent returns error" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -689,6 +689,8 @@ fn actionCommands(action: Action.Key) []const Command {
.esc,
.cursor_key,
.set_font_size,
.set_surface_title,
.set_tab_title,
.search,
.scroll_to_row,
.scroll_page_fractional,