terminal: parse ConEmu progress OSC 9

Fixes #3119

ConEmu and iTerm2 both use OSC 9 to implement different things. iTerm2
uses it to implement desktop notifications, while ConEmu uses it to
implement various OS commands.

Ghostty has supported iTerm2 OSC 9 for a while, but it didn't (and
doesn't) support ConEmu OSC 9. This means that if a program tries to
send a ConEmu OSC 9 to Ghostty, it will turn into a desktop notification.

This commit adds parsing for ConEmu OSC 9 progress reports. This means
that these specific syntaxes can never be desktop notifications, but
they're quite strange to be desktop notifications anyway so this should
be an okay tradeoff.

This doesn't actually _do anything with the progress reports_, it just
parses them so that they don't turn into desktop notifications.
This commit is contained in:
Jan200101
2024-12-24 21:50:57 +01:00
committed by Mitchell Hashimoto
parent b1756b93b8
commit c3bf7246f6
2 changed files with 249 additions and 7 deletions

View File

@ -158,6 +158,12 @@ pub const Command = union(enum) {
/// End a hyperlink (OSC 8)
hyperlink_end: void,
/// Set progress state (OSC 9;4)
progress: struct {
state: ProgressState,
progress: ?u8 = null,
},
pub const ColorKind = union(enum) {
palette: u8,
foreground,
@ -173,6 +179,14 @@ pub const Command = union(enum) {
};
}
};
pub const ProgressState = enum {
remove,
set,
@"error",
indeterminate,
pause,
};
};
/// The terminator used to end an OSC command. For OSC commands that demand
@ -322,6 +336,27 @@ pub const Parser = struct {
// https://sw.kovidgoyal.net/kitty/color-stack/#id1
kitty_color_protocol_key,
kitty_color_protocol_value,
// OSC 9 is used by ConEmu and iTerm2 for different things.
// iTerm2 uses it to post a notification[1].
// ConEmu uses it to implement many custom functions[2].
//
// Some Linux applications (namely systemd and flatpak) have
// adopted the ConEmu implementation but this causes bogus
// notifications on iTerm2 compatible terminal emulators.
//
// Ghostty supports both by disallowing ConEmu-specific commands
// from being shown as desktop notifications.
//
// [1]: https://iterm2.com/documentation-escape-codes.html
// [2]: https://conemu.github.io/en/AnsiEscapeCodes.html#OSC_Operating_system_commands
osc_9,
// ConEmu specific substates
conemu_progress_prestate,
conemu_progress_state,
conemu_progress_prevalue,
conemu_progress_value,
};
/// This must be called to clean up any allocated memory.
@ -735,18 +770,99 @@ pub const Parser = struct {
.@"9" => switch (c) {
';' => {
self.command = .{ .show_desktop_notification = .{
.title = "",
.body = undefined,
} };
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
self.buf_start = self.buf_idx;
self.state = .string;
self.state = .osc_9;
},
else => self.state = .invalid,
},
.osc_9 => switch (c) {
'4' => {
self.state = .conemu_progress_prestate;
},
// Todo: parse out other ConEmu operating system commands.
// Even if we don't support them we probably don't want
// them showing up as desktop notifications.
else => self.showDesktopNotification(),
},
.conemu_progress_prestate => switch (c) {
';' => {
self.command = .{ .progress = .{
.state = undefined,
} };
self.state = .conemu_progress_state;
},
else => self.showDesktopNotification(),
},
.conemu_progress_state => switch (c) {
'0' => {
self.command.progress.state = .remove;
self.state = .conemu_progress_prevalue;
self.complete = true;
},
'1' => {
self.command.progress.state = .set;
self.command.progress.progress = 0;
self.state = .conemu_progress_prevalue;
},
'2' => {
self.command.progress.state = .@"error";
self.complete = true;
self.state = .conemu_progress_prevalue;
},
'3' => {
self.command.progress.state = .indeterminate;
self.complete = true;
self.state = .conemu_progress_prevalue;
},
'4' => {
self.command.progress.state = .pause;
self.complete = true;
self.state = .conemu_progress_prevalue;
},
else => self.showDesktopNotification(),
},
.conemu_progress_prevalue => switch (c) {
';' => {
self.state = .conemu_progress_value;
},
else => self.showDesktopNotification(),
},
.conemu_progress_value => switch (c) {
'0'...'9' => value: {
// No matter what substate we're in, a number indicates
// a completed ConEmu progress command.
self.complete = true;
// If we aren't a set substate, then we don't care
// about the value.
const p = &self.command.progress;
if (p.state != .set) break :value;
assert(p.progress != null);
// If we're over 100% we're done.
if (p.progress.? >= 100) break :value;
// If we're over 10 then any new digit forces us to
// be 100.
if (p.progress.? >= 10)
p.progress = 100
else {
const d = std.fmt.charToDigit(c, 10) catch 0;
p.progress = @min(100, (p.progress.? * 10) + d);
}
},
else => self.showDesktopNotification(),
},
.query_fg_color => switch (c) {
'?' => {
self.command = .{ .report_color = .{ .kind = .foreground } };
@ -901,6 +1017,16 @@ pub const Parser = struct {
}
}
fn showDesktopNotification(self: *Parser) void {
self.command = .{ .show_desktop_notification = .{
.title = "",
.body = undefined,
} };
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
self.state = .string;
}
fn prepAllocableString(self: *Parser) void {
assert(self.buf_dynamic == null);
@ -1532,6 +1658,118 @@ test "OSC: show desktop notification with title" {
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body");
}
test "OSC: OSC9 progress set" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;100";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 100);
}
test "OSC: OSC9 progress set overflow" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;900";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 100);
}
test "OSC: OSC9 progress set single digit" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;9";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 9);
}
test "OSC: OSC9 progress set double digit" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;94";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .set);
try testing.expect(cmd.progress.progress == 94);
}
test "OSC: OSC9 progress set extra semicolon triggers desktop notification" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;1;100;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "");
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;1;100;");
}
test "OSC: OSC9 progress remove with no progress" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;0;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .remove);
try testing.expect(cmd.progress.progress == null);
}
test "OSC: OSC9 progress remove ignores progress" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;0;100";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .progress);
try testing.expect(cmd.progress.state == .remove);
try testing.expect(cmd.progress.progress == null);
}
test "OSC: OSC9 progress remove extra semicolon" {
const testing = std.testing;
var p: Parser = .{};
const input = "9;4;0;100;";
for (input) |ch| p.next(ch);
const cmd = p.end('\x1b').?;
try testing.expect(cmd == .show_desktop_notification);
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "");
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "4;0;100;");
}
test "OSC: empty param" {
const testing = std.testing;

View File

@ -1447,6 +1447,10 @@ pub fn Stream(comptime Handler: type) type {
return;
} else log.warn("unimplemented OSC callback: {}", .{cmd});
},
.progress => {
log.warn("unimplemented OSC callback: {}", .{cmd});
},
}
// Fall through for when we don't have a handler.