Expose additional events: desktop and progress (#13483)

This exposes libghostty-vt callbacks for additional terminal events.

* Desktop notifications from OSC 9/777
* Progress reports from OSC 9;4, including state and optional percentage
* Uses the `lib.Enum` progress state
* Exposes both through the existing terminal option/effect API

AI disclosure: Sol-5.6 was used extensively to write the code, I
reviewed it personally.
This commit is contained in:
Mitchell Hashimoto
2026-07-27 13:22:01 -07:00
committed by GitHub
6 changed files with 490 additions and 14 deletions

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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(

View File

@@ -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) },
});

View File

@@ -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 {

View File

@@ -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);