From 6f18d44ed69a5194a74ab72b6d86aaaf545f1dd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Mar 2026 07:06:09 -0700 Subject: [PATCH] vt: add title_changed effect callback Add GHOSTTY_TERMINAL_OPT_TITLE_CHANGED so C consumers are notified when the terminal title changes via OSC 0 or OSC 2 sequences. The callback has the same fire-and-forget shape as bell. --- include/ghostty/vt/terminal.h | 24 ++++++++++++ src/terminal/c/terminal.zig | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 32956d6a3..42141172d 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -201,6 +201,21 @@ typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal, void* userdata); +/** + * Callback function type for title_changed. + * + * Called when the terminal title changes via escape sequences + * (e.g. OSC 0 or OSC 2). The new title can be queried from the + * terminal after the callback returns. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, + void* userdata); + /** * Terminal option identifiers. * @@ -249,6 +264,15 @@ typedef enum { * Input type: GhosttyTerminalXtversionFn* */ GHOSTTY_TERMINAL_OPT_XTVERSION = 4, + + /** + * Callback invoked when the terminal title changes via escape + * sequences (e.g. OSC 0 or OSC 2). Set to NULL to ignore title + * change events. + * + * Input type: GhosttyTerminalTitleChangedFn* + */ + GHOSTTY_TERMINAL_OPT_TITLE_CHANGED = 5, } GhosttyTerminalOption; /** diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 0dc0b0d6e..7e457e1e4 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -39,6 +39,7 @@ const Effects = struct { bell: ?BellFn = null, enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, + title_changed: ?TitleChangedFn = null, /// C function pointer type for the write_pty callback. pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(.c) void; @@ -57,6 +58,9 @@ const Effects = struct { /// (len=0) causes the default "libghostty" to be reported. pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(.c) lib.String; + /// C function pointer type for the title_changed callback. + pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(.c) void; + fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -88,6 +92,13 @@ const Effects = struct { if (result.len == 0) return ""; return result.ptr[0..result.len]; } + + fn titleChangedTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.title_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } }; /// C: GhosttyTerminal @@ -151,6 +162,7 @@ fn new_( handler.effects.bell = &Effects.bellTrampoline; handler.effects.enquiry = &Effects.enquiryTrampoline; handler.effects.xtversion = &Effects.xtversionTrampoline; + handler.effects.title_changed = &Effects.titleChangedTrampoline; wrapper.* = .{ .terminal = t, @@ -176,6 +188,7 @@ pub const Option = enum(c_int) { bell = 2, enquiry = 3, xtversion = 4, + title_changed = 5, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -185,6 +198,7 @@ pub const Option = enum(c_int) { .bell => ?Effects.BellFn, .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, + .title_changed => ?Effects.TitleChangedFn, }; } }; @@ -222,6 +236,7 @@ fn setTyped( .bell => wrapper.effects.bell = if (value) |v| v.* else null, .enquiry => wrapper.effects.enquiry = if (value) |v| v.* else null, .xtversion => wrapper.effects.xtversion = if (value) |v| v.* else null, + .title_changed => wrapper.effects.title_changed = if (value) |v| v.* else null, } } @@ -1198,6 +1213,64 @@ test "xtversion without callback reports default" { try testing.expectEqualStrings("\x1BP>|libghostty\x1B\\", S.last_data.?); } +test "set title_changed callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var title_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn titleChanged(_: Terminal, ud: ?*anyopaque) callconv(.c) void { + title_count += 1; + last_userdata = ud; + } + }; + S.title_count = 0; + S.last_userdata = null; + + var sentinel: u8 = 77; + const ud: ?*anyopaque = @ptrCast(&sentinel); + set(t, .userdata, @ptrCast(&ud)); + const cb: ?Effects.TitleChangedFn = &S.titleChanged; + set(t, .title_changed, @ptrCast(&cb)); + + // OSC 2 ; title ST — set window title + vt_write(t, "\x1B]2;Hello\x1B\\", 10); + try testing.expectEqual(@as(usize, 1), S.title_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + + // Another title change + vt_write(t, "\x1B]2;World\x1B\\", 10); + try testing.expectEqual(@as(usize, 2), S.title_count); +} + +test "title_changed without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib_alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 2 without a callback should not crash + vt_write(t, "\x1B]2;Hello\x1B\\", 10); +} + test "grid_ref out of bounds" { var t: Terminal = null; try testing.expectEqual(Result.success, new(