diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 8aaa57e9b..9480d37a3 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -94,6 +94,8 @@ 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 | + * | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -447,6 +449,100 @@ 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); + +/** + * State of a terminal progress report. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Remove any visible progress indication. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_REMOVE = 0, + + /** Show determinate progress. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_SET = 1, + + /** Show a failed progress state. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_ERROR = 2, + + /** Show indeterminate progress. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_INDETERMINATE = 3, + + /** Show paused progress. */ + GHOSTTY_TERMINAL_PROGRESS_STATE_PAUSE = 4, + GHOSTTY_TERMINAL_PROGRESS_STATE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalProgressState; + +/** + * A progress report emitted by the running program. + * + * This is a sized struct. The callback must only access fields present in the + * size reported by `size`. + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Literal progress state reported by the running program. */ + GhosttyTerminalProgressState state; + + /** Progress percentage from 0 through 100, or -1 when omitted. */ + int8_t progress; +} GhosttyTerminalProgressReport; + +/** + * Callback function type for progress reports. + * + * Called synchronously when the terminal receives OSC 9;4. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param report Borrowed progress report + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalProgressReportFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyTerminalProgressReport* report); + /** * Callback function type for color scheme queries (CSI ? 996 n). * @@ -914,6 +1010,23 @@ 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, + + /** + * Callback invoked when the running program reports progress via OSC 9;4. + * Set to NULL to ignore progress reports. + * + * Input type: GhosttyTerminalProgressReportFn + */ + GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT = 30, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; diff --git a/src/lib/enum.zig b/src/lib/enum.zig index bfc86bbf7..8a1bbb1fb 100644 --- a/src/lib/enum.zig +++ b/src/lib/enum.zig @@ -121,7 +121,10 @@ pub fn checkGhosttyHEnum( if (@hasDecl(c, expected_name)) { std.testing.expectEqual(field.value, @field(c, expected_name)) catch |e| { - std.log.err(@typeName(T) ++ " key " ++ field.name ++ " does not have the same backing int as " ++ expected_name, .{}); + std.log.err( + "{s} key {s} does not have the same backing int as " ++ expected_name, + .{ @typeName(T), field.name }, + ); return e; }; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index c2a1ae333..85f4dbcf4 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -5,6 +5,8 @@ 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 osc = @import("../osc.zig"); const Stream = @import("../stream_terminal.zig").Stream; const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); @@ -81,6 +83,27 @@ 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: GhosttyTerminalProgressState +pub const ProgressState = osc.Command.ProgressReport.State; + +/// A progress report emitted by the running program. +/// +/// C: GhosttyTerminalProgressReport +pub const ProgressReport = extern struct { + size: usize, + state: ProgressState, + progress: i8, +}; + /// 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,11 +112,13 @@ 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, title_changed: ?TitleChangedFn = null, pwd_changed: ?PwdChangedFn = null, + progress_report: ?ProgressReportFn = null, size_cb: ?SizeFn = null, clipboard_write: ?ClipboardWriteFn = null, @@ -130,12 +155,19 @@ 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; /// C function pointer type for the pwd_changed callback. pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + /// C function pointer type for the progress_report callback. + pub const ProgressReportFn = *const fn (Terminal, ?*anyopaque, *const ProgressReport) callconv(lib.calling_conv) void; + /// C function pointer type for the size callback. /// Returns true and fills out_size if size is available, /// or returns false to silently ignore the query. @@ -219,6 +251,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; @@ -285,6 +337,20 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn progressReportTrampoline( + handler: *Handler, + report: osc.Command.ProgressReport, + ) void { + const wrapper = TerminalWrapper.fromHandler(handler); + const func = wrapper.effects.progress_report orelse return; + const c_report: ProgressReport = .{ + .size = @sizeOf(ProgressReport), + .state = @enumFromInt(@intFromEnum(report.state)), + .progress = if (report.progress) |value| @intCast(value) else -1, + }; + func(@ptrCast(wrapper), wrapper.effects.userdata, &c_report); + } + fn sizeTrampoline(handler: *Handler) ?size_report.Size { const wrapper = TerminalWrapper.fromHandler(handler); const func = wrapper.effects.size_cb orelse return null; @@ -376,11 +442,13 @@ 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, .title_changed = &Effects.titleChangedTrampoline, .pwd_changed = &Effects.pwdChangedTrampoline, + .progress_report = &Effects.progressReportTrampoline, .size = &Effects.sizeTrampoline, .clipboard_write = &Effects.clipboardWriteTrampoline, }; @@ -457,6 +525,8 @@ pub const Option = enum(c_int) { clipboard_write = 26, scrollback_max_bytes = 27, scrollback_max_lines = 28, + desktop_notification = 29, + progress_report = 30, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -465,11 +535,13 @@ 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, .title_changed => ?Effects.TitleChangedFn, .pwd_changed => ?Effects.PwdChangedFn, + .progress_report => ?Effects.ProgressReportFn, .size_cb => ?Effects.SizeFn, .clipboard_write => ?Effects.ClipboardWriteFn, .title, .pwd => ?*const lib.String, @@ -526,11 +598,13 @@ 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, .title_changed => wrapper.effects.title_changed = value, .pwd_changed => wrapper.effects.pwd_changed = value, + .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, .clipboard_write => wrapper.effects.clipboard_write = value, .title => { @@ -2843,6 +2917,151 @@ 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, + 80, + 24, + )); + 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 progress_report callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var count: usize = 0; + var last_userdata: ?*anyopaque = null; + var last_size: usize = 0; + var last_state: ProgressState = .remove; + var last_progress: i8 = -1; + + fn progressReport( + _: Terminal, + ud: ?*anyopaque, + report: *const ProgressReport, + ) callconv(lib.calling_conv) void { + count += 1; + last_userdata = ud; + last_size = report.size; + last_state = report.state; + last_progress = report.progress; + } + }; + S.count = 0; + S.last_userdata = null; + S.last_size = 0; + S.last_state = .remove; + S.last_progress = -1; + + var sentinel: u8 = 100; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set( + t, + .progress_report, + @ptrCast(&S.progressReport), + )); + + const cases = [_]struct { + sequence: []const u8, + state: ProgressState, + progress: i8, + }{ + .{ .sequence = "\x1B]9;4;0;\x1B\\", .state = .remove, .progress = -1 }, + .{ .sequence = "\x1B]9;4;1;42\x07", .state = .set, .progress = 42 }, + .{ .sequence = "\x1B]9;4;2;7\x1B\\", .state = .@"error", .progress = 7 }, + .{ .sequence = "\x1B]9;4;3\x1B\\", .state = .indeterminate, .progress = -1 }, + .{ .sequence = "\x1B]9;4;4;75\x1B\\", .state = .pause, .progress = 75 }, + }; + + for (cases, 1..) |case, expected_count| { + const midpoint = case.sequence.len / 2; + vt_write(t, case.sequence.ptr, midpoint); + try testing.expectEqual(expected_count - 1, S.count); + vt_write(t, case.sequence.ptr + midpoint, case.sequence.len - midpoint); + try testing.expectEqual(expected_count, S.count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(@sizeOf(ProgressReport), S.last_size); + try testing.expectEqual(case.state, S.last_state); + try testing.expectEqual(case.progress, S.last_progress); + } + + try testing.expectEqual(Result.success, set(t, .progress_report, null)); + const ignored = "\x1B]9;4;1;90\x1B\\"; + vt_write(t, ignored, ignored.len); + try testing.expectEqual(@as(usize, cases.len), 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..60f97c342 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -68,6 +68,8 @@ 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) }, + .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, }); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 26ce12672..e3d323915 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -203,19 +203,16 @@ pub const Command = union(Key) { ); pub const ProgressReport = struct { - pub const State = enum(c_int) { - remove, - set, - @"error", - indeterminate, - pause, - - test "ghostty.h Command.ProgressReport.State" { - if (comptime build_options.artifact == .lib) return error.SkipZigTest; - try lib.checkGhosttyHEnum(State, "GHOSTTY_PROGRESS_STATE_"); - } + const state_keys = &.{ + "remove", + "set", + "error", + "indeterminate", + "pause", }; + pub const State = LibEnum(lib.target, state_keys); + state: State, progress: ?u8 = null, @@ -235,6 +232,12 @@ pub const Command = union(Key) { )) else -1, }; } + + test "ghostty.h Command.ProgressReport.State" { + if (comptime build_options.artifact == .lib) return error.SkipZigTest; + const CState = LibEnum(.c, state_keys); + try lib.checkGhosttyHEnum(CState, "GHOSTTY_PROGRESS_STATE_"); + } }; comptime { diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 021e1892c..89f48b417 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. @@ -104,6 +109,9 @@ pub const Handler = struct { /// handler.terminal.getPwd(). pwd_changed: ?*const fn (*Handler) void, + /// Called when the running program reports progress via OSC 9;4. + progress_report: ?*const fn (*Handler, osc.Command.ProgressReport) void, + /// Called when the running program writes to a clipboard. The write /// has a normalized destination and one or more decoded MIME /// representations. All request, MIME, and data memory is borrowed @@ -131,8 +139,10 @@ pub const Handler = struct { .bell = null, .clipboard_write = null, .color_scheme = null, + .desktop_notification = null, .device_attributes = null, .enquiry = null, + .progress_report = null, .size = null, .title_changed = null, .pwd_changed = null, @@ -314,6 +324,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(), @@ -323,6 +334,7 @@ pub const Handler = struct { .size_report => self.reportSize(value), .window_title => try self.windowTitle(value.title), .report_pwd => try self.reportPwd(value.url), + .progress_report => self.progressReport(value), .xtversion => self.reportXtversion(), .clipboard_contents => self.clipboardContents( value.kind, @@ -337,8 +349,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 +406,19 @@ 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 progressReport(self: *Handler, report: osc.Command.ProgressReport) void { + const func = self.effects.progress_report orelse return; + func(self, report); + } + fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void { const func = self.effects.clipboard_write orelse return; @@ -2035,6 +2058,119 @@ 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 "progress_report 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 reports. + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + s.nextSlice("\x1B]9;4;1;25\x1B\\"); + } + + const S = struct { + var count: usize = 0; + var last_state: osc.Command.ProgressReport.State = .remove; + var last_progress: ?u8 = null; + + fn progressReport(_: *Handler, report: osc.Command.ProgressReport) void { + count += 1; + last_state = report.state; + last_progress = report.progress; + } + }; + S.count = 0; + S.last_state = .remove; + S.last_progress = null; + + var handler: Handler = .init(&t); + handler.effects.progress_report = &S.progressReport; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + const cases = [_]struct { + sequence: []const u8, + state: osc.Command.ProgressReport.State, + progress: ?u8, + }{ + .{ .sequence = "\x1B]9;4;0;\x1B\\", .state = .remove, .progress = null }, + .{ .sequence = "\x1B]9;4;1;42\x07", .state = .set, .progress = 42 }, + .{ .sequence = "\x1B]9;4;2;7\x1B\\", .state = .@"error", .progress = 7 }, + .{ .sequence = "\x1B]9;4;3\x1B\\", .state = .indeterminate, .progress = null }, + .{ .sequence = "\x1B]9;4;4;75\x1B\\", .state = .pause, .progress = 75 }, + }; + + for (cases, 1..) |case, expected_count| { + // Split each sequence to verify parsing survives PTY read boundaries. + const midpoint = case.sequence.len / 2; + s.nextSlice(case.sequence[0..midpoint]); + try testing.expectEqual(expected_count - 1, S.count); + s.nextSlice(case.sequence[midpoint..]); + try testing.expectEqual(expected_count, S.count); + try testing.expectEqual(case.state, S.last_state); + try testing.expectEqual(case.progress, S.last_progress); + } +} + test "clipboard_write effect callback" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator);