diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index e18d14df6..9aebdbd3a 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -800,7 +800,31 @@ test "csi: too many params" { } } -test "dcs" { +test "dcs: XTGETTCAP" { + var p = init(); + _ = p.next(0x1B); + for ("P+") |c| { + const a = p.next(c); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2] == null); + } + + { + const a = p.next('q'); + try testing.expect(p.state == .dcs_passthrough); + try testing.expect(a[0] == null); + try testing.expect(a[1] == null); + try testing.expect(a[2].? == .dcs_hook); + + const hook = a[2].?.dcs_hook; + try testing.expectEqualSlices(u8, &[_]u8{'+'}, hook.intermediates); + try testing.expectEqualSlices(u16, &[_]u16{}, hook.params); + try testing.expectEqual('q', hook.final); + } +} + +test "dcs: params" { var p = init(); _ = p.next(0x1B); for ("P1000") |c| { diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index cde00d218..da6f3ae23 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -21,33 +21,67 @@ pub const Handler = struct { self.discard(); } - pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) void { + pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) ?Command { assert(self.state == .inactive); - self.state = if (tryHook(alloc, dcs)) |state_| state: { - if (state_) |state| break :state state else { - log.info("unknown DCS hook: {}", .{dcs}); - break :state .{ .ignore = {} }; - } - } else |err| state: { - log.info( - "error initializing DCS hook, will ignore hook err={}", - .{err}, - ); - break :state .{ .ignore = {} }; + + // Initialize our state to ignore in case of error + self.state = .{ .ignore = {} }; + + // Try to parse the hook. + const hk_ = self.tryHook(alloc, dcs) catch |err| { + log.info("error initializing DCS hook, will ignore hook err={}", .{err}); + return null; }; + const hk = hk_ orelse { + log.info("unknown DCS hook: {}", .{dcs}); + return null; + }; + + self.state = hk.state; + return hk.command; } - fn tryHook(alloc: Allocator, dcs: DCS) !?State { + const Hook = struct { + state: State, + command: ?Command = null, + }; + + fn tryHook(self: Handler, alloc: Allocator, dcs: DCS) !?Hook { return switch (dcs.intermediates.len) { + 0 => switch (dcs.final) { + // Tmux control mode + 'p' => tmux: { + // Tmux control mode must start with ESC P 1000 p + if (dcs.params.len != 1 or dcs.params[0] != 1000) break :tmux null; + + break :tmux .{ + .state = .{ + .tmux = .{ + .max_bytes = self.max_bytes, + .buffer = try std.ArrayList(u8).initCapacity( + alloc, + 128, // Arbitrary choice to limit initial reallocs + ), + }, + }, + .command = .{ .tmux = .{ .enter = {} } }, + }; + }, + + else => null, + }, + 1 => switch (dcs.intermediates[0]) { '+' => switch (dcs.final) { // XTGETTCAP // https://github.com/mitchellh/ghostty/issues/517 'q' => .{ - .xtgettcap = try std.ArrayList(u8).initCapacity( - alloc, - 128, // Arbitrary choice - ), + .state = .{ + .xtgettcap = try std.ArrayList(u8).initCapacity( + alloc, + 128, // Arbitrary choice + ), + }, }, else => null, @@ -55,9 +89,9 @@ pub const Handler = struct { '$' => switch (dcs.final) { // DECRQSS - 'q' => .{ + 'q' => .{ .state = .{ .decrqss = .{}, - }, + } }, else => null, }, @@ -69,21 +103,28 @@ pub const Handler = struct { }; } - pub fn put(self: *Handler, byte: u8) void { - self.tryPut(byte) catch |err| { + /// Put a byte into the DCS handler. This will return a command + /// if a command needs to be executed. + pub fn put(self: *Handler, byte: u8) ?Command { + return self.tryPut(byte) catch |err| { // On error we just discard our state and ignore the rest log.info("error putting byte into DCS handler err={}", .{err}); self.discard(); self.state = .{ .ignore = {} }; + return null; }; } - fn tryPut(self: *Handler, byte: u8) !void { + fn tryPut(self: *Handler, byte: u8) !?Command { switch (self.state) { .inactive, .ignore, => {}, + .tmux => |*tmux| return .{ + .tmux = (try tmux.put(byte)) orelse return null, + }, + .xtgettcap => |*list| { if (list.items.len >= self.max_bytes) { return error.OutOfMemory; @@ -101,15 +142,26 @@ pub const Handler = struct { buffer.len += 1; }, } + + return null; } pub fn unhook(self: *Handler) ?Command { + // Note: we do NOT call deinit here on purpose because some commands + // transfer memory ownership. If state needs cleanup, the switch + // prong below should handle it. defer self.state = .{ .inactive = {} }; + return switch (self.state) { .inactive, .ignore, => null, + .tmux => tmux: { + self.state.deinit(); + break :tmux .{ .tmux = .{ .exit = {} } }; + }, + .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, .decrqss => |buffer| .{ .decrqss = switch (buffer.len) { @@ -133,16 +185,7 @@ pub const Handler = struct { } fn discard(self: *Handler) void { - switch (self.state) { - .inactive, - .ignore, - => {}, - - .xtgettcap => |*list| list.deinit(), - - .decrqss => {}, - } - + self.state.deinit(); self.state = .{ .inactive = {} }; } }; @@ -154,12 +197,14 @@ pub const Command = union(enum) { /// DECRQSS decrqss: DECRQSS, + /// Tmux control mode + tmux: terminal.tmux.Notification, + pub fn deinit(self: Command) void { switch (self) { - .xtgettcap => |*v| { - v.data.deinit(); - }, + .xtgettcap => |*v| v.data.deinit(), .decrqss => {}, + .tmux => {}, } } @@ -193,6 +238,12 @@ pub const Command = union(enum) { decstbm, decslrm, }; + + /// Tmux control mode + pub const Tmux = union(enum) { + enter: void, + exit: void, + }; }; const State = union(enum) { @@ -211,6 +262,21 @@ const State = union(enum) { data: [2]u8 = undefined, len: u2 = 0, }, + + /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode + tmux: terminal.tmux.Client, + + pub fn deinit(self: *State) void { + switch (self.*) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*v| v.deinit(), + .decrqss => {}, + .tmux => |*v| v.deinit(), + } + } }; test "unknown DCS command" { @@ -219,7 +285,7 @@ test "unknown DCS command" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .final = 'A' }); + try testing.expect(h.hook(alloc, .{ .final = 'A' }) == null); try testing.expect(h.state == .ignore); try testing.expect(h.unhook() == null); try testing.expect(h.state == .inactive); @@ -231,8 +297,8 @@ test "XTGETTCAP command" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("536D756C78") |byte| h.put(byte); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); @@ -246,8 +312,8 @@ test "XTGETTCAP command multiple keys" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("536D756C78;536D756C78") |byte| h.put(byte); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("536D756C78;536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); @@ -262,8 +328,8 @@ test "XTGETTCAP command invalid data" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("who;536D756C78") |byte| h.put(byte); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); + for ("who;536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); @@ -278,8 +344,8 @@ test "DECRQSS command" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('m'); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); + _ = h.put('m'); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .decrqss); @@ -292,8 +358,8 @@ test "DECRQSS invalid command" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('z'); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); + _ = h.put('z'); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .decrqss); @@ -301,9 +367,31 @@ test "DECRQSS invalid command" { h.discard(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('"'); - h.put(' '); - h.put('q'); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); + _ = h.put('"'); + _ = h.put(' '); + _ = h.put('q'); try testing.expect(h.unhook() == null); } + +test "tmux enter and implicit exit" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + + { + var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?; + defer cmd.deinit(); + try testing.expect(cmd == .tmux); + try testing.expect(cmd.tmux == .enter); + } + + { + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .tmux); + try testing.expect(cmd.tmux == .exit); + } +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 8807921ff..b486a8da5 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -20,6 +20,7 @@ pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); pub const size = @import("size.zig"); +pub const tmux = @import("tmux.zig"); pub const x11_color = @import("x11_color.zig"); pub const Charset = charsets.Charset; diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig new file mode 100644 index 000000000..1ea9f8c39 --- /dev/null +++ b/src/terminal/tmux.zig @@ -0,0 +1,423 @@ +//! This file contains the implementation for tmux control mode. See +//! tmux(1) for more information on control mode. Some basics are documented +//! here but this is not meant to be a comprehensive source of protocol +//! documentation. + +const std = @import("std"); +const assert = std.debug.assert; +const oni = @import("oniguruma"); + +const log = std.log.scoped(.terminal_tmux); + +/// A tmux control mode client. It is expected that the caller establishes +/// the connection in some way (i.e. detects the opening DCS sequence). This +/// just works on a byte stream. +pub const Client = struct { + /// Current state of the client. + state: State = .idle, + + /// The buffer used to store in-progress notifications, output, etc. + buffer: std.ArrayList(u8), + + /// The maximum size in bytes of the buffer. This is used to limit + /// memory usage. If the buffer exceeds this size, the client will + /// enter a broken state (the control mode session will be forcibly + /// exited and future data dropped). + max_bytes: usize = 1024 * 1024, + + const State = enum { + /// Outside of any active notifications. This should drop any output + /// unless it is '%' on the first byte of a line. The buffer will be + /// cleared when it sees '%', this is so that the previous notification + /// data is valid until we receive/process new data. + idle, + + /// We experienced unexpected input and are in a broken state + /// so we cannot continue processing. + broken, + + /// Inside an active notification (started with '%'). + notification, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *Client) void { + self.buffer.deinit(); + } + + // Handle a byte of input. + pub fn put(self: *Client, byte: u8) !?Notification { + if (self.buffer.items.len >= self.max_bytes) { + self.broken(); + return error.OutOfMemory; + } + + switch (self.state) { + // Drop because we're in a broken state. + .broken => return null, + + // Waiting for a notification so if the byte is not '%' then + // we're in a broken state. Control mode output should always + // be wrapped in '%begin/%end' orelse we expect a notification. + // Return an exit notification. + .idle => if (byte != '%') { + self.broken(); + return .{ .exit = {} }; + } else { + self.buffer.clearRetainingCapacity(); + self.state = .notification; + }, + + // If we're in a notification and its not a newline then + // we accumulate. If it is a newline then we have a + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. + return try self.parseNotification(); + }, + + // If we're in a block then we accumulate until we see a newline + // and then we check to see if that line ended the block. + .block => if (byte == '\n') { + const idx = if (std.mem.lastIndexOfScalar( + u8, + self.buffer.items, + '\n', + )) |v| v + 1 else 0; + const line = self.buffer.items[idx..]; + + if (std.mem.startsWith(u8, line, "%end") or + std.mem.startsWith(u8, line, "%error")) + { + const err = std.mem.startsWith(u8, line, "%error"); + const output = std.mem.trimRight(u8, self.buffer.items[0..idx], "\r\n"); + + // If it is an error then log it. + if (err) log.warn("tmux control mode error={s}", .{output}); + + // Important: do not clear buffer since the notification + // contains it. + self.state = .idle; + return if (err) .{ .block_err = output } else .{ .block_end = output }; + } + + // Didn't end the block, continue accumulating. + }, + } + + try self.buffer.append(byte); + + return null; + } + + fn parseNotification(self: *Client) !?Notification { + assert(self.state == .notification); + + const line = line: { + var line = self.buffer.items; + if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1]; + break :line line; + }; + const cmd = cmd: { + const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len; + break :cmd line[0..idx]; + }; + + // The notification MUST exist because we guard entering the notification + // state on seeing at least a '%'. + if (std.mem.eql(u8, cmd, "%begin")) { + // We don't use the rest of the tokens for now because tmux + // claims to guarantee that begin/end are always in order and + // never intermixed. In the future, we should probably validate + // this. + // TODO(tmuxcc): do this before merge? + + // Move to block state because we expect a corresponding end/error + // and want to accumulate the data. + self.state = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else if (std.mem.eql(u8, cmd, "%output")) cmd: { + var re = try oni.Regex.init( + "^%output %([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const data = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .output = .{ .pane_id = id, .data = data } }; + } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { + var re = try oni.Regex.init( + "^%session-changed \\$([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .session_changed = .{ .id = id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: { + if (!std.mem.eql(u8, line, "%sessions-changed")) { + log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line }); + break :cmd; + } + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .sessions_changed = {} }; + } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: { + var re = try oni.Regex.init( + "^%window-add @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_add = .{ .id = id } }; + } else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: { + var re = try oni.Regex.init( + "^%window-renamed @([0-9]+) (.+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ); + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const name = line[@intCast(starts[2])..@intCast(ends[2])]; + + // Important: do not clear buffer here since name points to it + self.state = .idle; + return .{ .window_renamed = .{ .id = id, .name = name } }; + } else { + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); + } + + // Unknown command. Clear the buffer and return to idle state. + self.buffer.clearRetainingCapacity(); + self.state = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *Client) void { + self.state = .broken; + self.buffer.clearAndFree(); + } +}; + +/// Possible notification types from tmux control mode. These are documented +/// in tmux(1). +pub const Notification = union(enum) { + enter: void, + exit: void, + + block_end: []const u8, + block_err: []const u8, + + output: struct { + pane_id: usize, + data: []const u8, // unescaped + }, + + session_changed: struct { + id: usize, + name: []const u8, + }, + + sessions_changed: void, + + window_add: struct { + id: usize, + }, + + window_renamed: struct { + id: usize, + name: []const u8, + }, +}; + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("", n.block_end); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_err); + try testing.expectEqualStrings("", n.block_err); +} + +test "tmux begin/end data" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); + for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null); + for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .block_end); + try testing.expectEqualStrings("hello\nworld", n.block_end); +} + +test "tmux output" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(42, n.output.pane_id); + try testing.expectEqualStrings("foo bar baz", n.output.data); +} + +test "tmux session-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .session_changed); + try testing.expectEqual(42, n.session_changed.id); + try testing.expectEqualStrings("foo", n.session_changed.name); +} + +test "tmux sessions-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux sessions-changed carriage return" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .sessions_changed); +} + +test "tmux window-add" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_add); + try testing.expectEqual(14, n.window_add.id); +} + +test "tmux window-renamed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Client = .{ .buffer = std.ArrayList(u8).init(alloc) }; + defer c.deinit(); + for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_renamed); + try testing.expectEqual(42, n.window_renamed.id); + try testing.expectEqualStrings("bar", n.window_renamed.name); +} diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 8c6212554..b0f973b1e 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1860,19 +1860,31 @@ const StreamHandler = struct { } pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { - self.dcs.hook(self.alloc, dcs); + var cmd = self.dcs.hook(self.alloc, dcs) orelse return; + defer cmd.deinit(); + try self.dcsCommand(&cmd); } pub fn dcsPut(self: *StreamHandler, byte: u8) !void { - self.dcs.put(byte); + var cmd = self.dcs.put(byte) orelse return; + defer cmd.deinit(); + try self.dcsCommand(&cmd); } pub fn dcsUnhook(self: *StreamHandler) !void { var cmd = self.dcs.unhook() orelse return; defer cmd.deinit(); + try self.dcsCommand(&cmd); + } + fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); - switch (cmd) { + switch (cmd.*) { + .tmux => |tmux| { + // TODO: process it + log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); + }, + .xtgettcap => |*gettcap| { const map = comptime terminfo.ghostty.xtgettcapMap(); while (gettcap.next()) |key| { @@ -1880,6 +1892,7 @@ const StreamHandler = struct { self.messageWriter(.{ .write_stable = response }); } }, + .decrqss => |decrqss| { var response: [128]u8 = undefined; var stream = std.io.fixedBufferStream(&response);