diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 8aaa57e9b..5a04384b5 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -94,6 +94,7 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 | + * | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -447,6 +448,42 @@ typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)( void* userdata, const GhosttyClipboardWrite* write); +/** + * A request to show a desktop notification. + * + * This is a sized struct. The callback must only access fields present in the + * size reported by `size`. Both strings are borrowed and valid only for the + * duration of the callback. + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Notification title, or an empty string when the protocol omits it. */ + GhosttyString title; + + /** Notification body. */ + GhosttyString body; +} GhosttyTerminalDesktopNotification; + +/** + * Callback function type for desktop notifications. + * + * Called synchronously when the terminal receives OSC 9 or OSC 777. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param notification Borrowed desktop notification request + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalDesktopNotificationFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyTerminalDesktopNotification* notification); + /** * Callback function type for color scheme queries (CSI ? 996 n). * @@ -914,6 +951,15 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: size_t* */ GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES = 28, + + /** + * Callback invoked when the running program requests a desktop + * notification via OSC 9 or OSC 777. Set to NULL to ignore desktop + * notification requests. + * + * Input type: GhosttyTerminalDesktopNotificationFn + */ + GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION = 29, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index c2a1ae333..bdc7af7f6 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -5,6 +5,7 @@ const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; pub const ZigTerminal = @import("../Terminal.zig"); +const Action = @import("../stream.zig").Action; const Stream = @import("../stream_terminal.zig").Stream; const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -81,6 +82,15 @@ pub const ClipboardWrite = extern struct { contents_len: usize, }; +/// A request to show a desktop notification. +/// +/// C: GhosttyTerminalDesktopNotification +pub const DesktopNotification = extern struct { + size: usize, + title: lib.String, + body: lib.String, +}; + /// C callback state for terminal effects. Trampolines are always /// installed on the stream handler; they check these fields and /// no-op when the corresponding callback is null. @@ -89,6 +99,7 @@ const Effects = struct { write_pty: ?WritePtyFn = null, bell: ?BellFn = null, color_scheme: ?ColorSchemeFn = null, + desktop_notification: ?DesktopNotificationFn = null, device_attributes_cb: ?DeviceAttributesFn = null, enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, @@ -130,6 +141,10 @@ const Effects = struct { /// and its contents are borrowed and only valid for the callback duration. pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult; + /// C function pointer type for the desktop_notification callback. The + /// request and its strings are borrowed for the callback duration. + pub const DesktopNotificationFn = *const fn (Terminal, ?*anyopaque, *const DesktopNotification) callconv(lib.calling_conv) void; + /// C function pointer type for the title_changed callback. pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; @@ -219,6 +234,26 @@ const Effects = struct { return func(@ptrCast(wrapper), wrapper.effects.userdata, &request); } + fn desktopNotificationTrampoline( + handler: *Handler, + notification: Action.ShowDesktopNotification, + ) void { + const wrapper = TerminalWrapper.fromHandler(handler); + const func = wrapper.effects.desktop_notification orelse return; + const request: DesktopNotification = .{ + .size = @sizeOf(DesktopNotification), + .title = .{ + .ptr = notification.title.ptr, + .len = notification.title.len, + }, + .body = .{ + .ptr = notification.body.ptr, + .len = notification.body.len, + }, + }; + func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + } + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { const wrapper = TerminalWrapper.fromHandler(handler); const func = wrapper.effects.color_scheme orelse return null; @@ -376,6 +411,7 @@ fn new_( .write_pty = &Effects.writePtyTrampoline, .bell = &Effects.bellTrampoline, .color_scheme = &Effects.colorSchemeTrampoline, + .desktop_notification = &Effects.desktopNotificationTrampoline, .device_attributes = &Effects.deviceAttributesTrampoline, .enquiry = &Effects.enquiryTrampoline, .xtversion = &Effects.xtversionTrampoline, @@ -457,6 +493,7 @@ pub const Option = enum(c_int) { clipboard_write = 26, scrollback_max_bytes = 27, scrollback_max_lines = 28, + desktop_notification = 29, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -465,6 +502,7 @@ pub const Option = enum(c_int) { .write_pty => ?Effects.WritePtyFn, .bell => ?Effects.BellFn, .color_scheme => ?Effects.ColorSchemeFn, + .desktop_notification => ?Effects.DesktopNotificationFn, .device_attributes => ?Effects.DeviceAttributesFn, .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, @@ -526,6 +564,7 @@ fn setTyped( .write_pty => wrapper.effects.write_pty = value, .bell => wrapper.effects.bell = value, .color_scheme => wrapper.effects.color_scheme = value, + .desktop_notification => wrapper.effects.desktop_notification = value, .device_attributes => wrapper.effects.device_attributes_cb = value, .enquiry => wrapper.effects.enquiry = value, .xtversion => wrapper.effects.xtversion = value, @@ -2843,6 +2882,81 @@ test "title_changed without callback is silent" { vt_write(t, "\x1B]2;Hello\x1B\\", 10); } +test "set desktop_notification 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 count: usize = 0; + var last_userdata: ?*anyopaque = null; + var last_size: usize = 0; + var title: [64]u8 = undefined; + var title_len: usize = 0; + var body: [64]u8 = undefined; + var body_len: usize = 0; + + fn desktopNotification( + _: Terminal, + ud: ?*anyopaque, + notification: *const DesktopNotification, + ) callconv(lib.calling_conv) void { + count += 1; + last_userdata = ud; + last_size = notification.size; + title_len = notification.title.len; + body_len = notification.body.len; + @memcpy(title[0..title_len], notification.title.ptr[0..title_len]); + @memcpy(body[0..body_len], notification.body.ptr[0..body_len]); + } + }; + S.count = 0; + S.last_userdata = null; + S.last_size = 0; + S.title_len = 0; + S.body_len = 0; + + var sentinel: u8 = 99; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set( + t, + .desktop_notification, + @ptrCast(&S.desktopNotification), + )); + + // Split OSC 777 across writes to exercise the persistent VT parser. + const seq_a = "\x1B]777;notify;Codex;"; + const seq_b = "Needs attention\x1B\\"; + vt_write(t, seq_a, seq_a.len); + try testing.expectEqual(@as(usize, 0), S.count); + vt_write(t, seq_b, seq_b.len); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(@sizeOf(DesktopNotification), S.last_size); + try testing.expectEqualStrings("Codex", S.title[0..S.title_len]); + try testing.expectEqualStrings("Needs attention", S.body[0..S.body_len]); + + // OSC 9 has no title and preserves its body. + const seq_c = "\x1B]9;Build complete\x07"; + vt_write(t, seq_c, seq_c.len); + try testing.expectEqual(@as(usize, 2), S.count); + try testing.expectEqualStrings("", S.title[0..S.title_len]); + try testing.expectEqualStrings("Build complete", S.body[0..S.body_len]); + + // Removing the callback takes effect immediately. + try testing.expectEqual(Result.success, set(t, .desktop_notification, null)); + vt_write(t, seq_c, seq_c.len); + try testing.expectEqual(@as(usize, 2), S.count); +} + test "set pwd_changed callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index 549dfff15..04d4f5011 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -68,6 +68,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: { .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) }, .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, + .{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) }, .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, }); diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 021e1892c..fbe133bb1 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -74,6 +74,11 @@ pub const Handler = struct { /// Called when the bell is rung (BEL). bell: ?*const fn (*Handler) void, + /// Called when the running program requests a desktop notification + /// via OSC 9 or OSC 777. The title and body are borrowed and only + /// valid for the duration of the callback. + desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) void, + /// Called in response to a color scheme DSR query (CSI ? 996 n). /// Returns the current color scheme. Return null to silently /// ignore the query. @@ -131,6 +136,7 @@ pub const Handler = struct { .bell = null, .clipboard_write = null, .color_scheme = null, + .desktop_notification = null, .device_attributes = null, .enquiry = null, .size = null, @@ -314,6 +320,7 @@ pub const Handler = struct { // Effect-based handlers .bell => self.bell(), + .show_desktop_notification => self.desktopNotification(value), .device_attributes => self.reportDeviceAttributes(value), .device_status => self.deviceStatus(value.request), .enquiry => self.reportEnquiry(), @@ -337,7 +344,6 @@ pub const Handler = struct { .dcs_unhook => try self.dcsUnhook(), // Have no terminal-modifying effect - .show_desktop_notification, .progress_report, .title_push, .title_pop, @@ -396,6 +402,14 @@ pub const Handler = struct { func(self); } + fn desktopNotification( + self: *Handler, + notification: Action.ShowDesktopNotification, + ) void { + const func = self.effects.desktop_notification orelse return; + func(self, notification); + } + fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void { const func = self.effects.clipboard_write orelse return; @@ -2035,6 +2049,63 @@ test "bell effect callback" { } } +test "desktop_notification effect callback" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // A null callback (the default readonly effects) silently ignores + // notifications and leaves the terminal usable. + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("\x1B]9;Ignored\x1B\\AfterNotification"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AfterNotification", str); + } + + t.fullReset(); + + const S = struct { + var count: usize = 0; + var last_title: []const u8 = ""; + var last_body: []const u8 = ""; + + fn desktopNotification( + _: *Handler, + notification: Action.ShowDesktopNotification, + ) void { + count += 1; + last_title = notification.title; + last_body = notification.body; + } + }; + S.count = 0; + S.last_title = ""; + S.last_body = ""; + + var handler: Handler = .init(&t); + handler.effects.desktop_notification = &S.desktopNotification; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // OSC 9 is split across writes and carries only a body. + s.nextSlice("\x1B]9;Build "); + try testing.expectEqual(@as(usize, 0), S.count); + s.nextSlice("complete\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqualStrings("", S.last_title); + try testing.expectEqualStrings("Build complete", S.last_body); + + // OSC 777 preserves its separate title and body fields. + s.nextSlice("\x1B]777;notify;Codex;Needs attention\x07"); + try testing.expectEqual(@as(usize, 2), S.count); + try testing.expectEqualStrings("Codex", S.last_title); + try testing.expectEqualStrings("Needs attention", S.last_body); +} + test "clipboard_write effect callback" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator);