From 38d33a761b62926b53dd5cea4719d9df10375307 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 18:34:05 -0700 Subject: [PATCH 01/11] terminal: test DCS to make sure we don't regress --- src/terminal/Parser.zig | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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| { From 01e1538ad329e45d760687bf71797571a6677533 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 18:42:22 -0700 Subject: [PATCH 02/11] terminal: dcs put can return a command --- src/terminal/dcs.zig | 31 +++++++++++++++++-------------- src/termio/Exec.zig | 9 +++++++-- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index cde00d218..72949789d 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -69,16 +69,19 @@ 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, @@ -101,6 +104,8 @@ pub const Handler = struct { buffer.len += 1; }, } + + return null; } pub fn unhook(self: *Handler) ?Command { @@ -156,9 +161,7 @@ pub const Command = union(enum) { pub fn deinit(self: Command) void { switch (self) { - .xtgettcap => |*v| { - v.data.deinit(); - }, + .xtgettcap => |*v| v.data.deinit(), .decrqss => {}, } } @@ -232,7 +235,7 @@ test "XTGETTCAP command" { var h: Handler = .{}; defer h.deinit(); h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("536D756C78") |byte| h.put(byte); + for ("536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); @@ -247,7 +250,7 @@ test "XTGETTCAP command multiple keys" { var h: Handler = .{}; defer h.deinit(); h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("536D756C78;536D756C78") |byte| h.put(byte); + for ("536D756C78;536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); @@ -263,7 +266,7 @@ test "XTGETTCAP command invalid data" { var h: Handler = .{}; defer h.deinit(); h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); - for ("who;536D756C78") |byte| h.put(byte); + for ("who;536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); @@ -279,7 +282,7 @@ test "DECRQSS command" { var h: Handler = .{}; defer h.deinit(); h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('m'); + _ = h.put('m'); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .decrqss); @@ -293,7 +296,7 @@ test "DECRQSS invalid command" { var h: Handler = .{}; defer h.deinit(); h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('z'); + _ = h.put('z'); var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .decrqss); @@ -302,8 +305,8 @@ test "DECRQSS invalid command" { h.discard(); h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); - h.put('"'); - h.put(' '); - h.put('q'); + _ = h.put('"'); + _ = h.put(' '); + _ = h.put('q'); try testing.expect(h.unhook() == null); } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 8c6212554..6b1d6d1ac 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1864,15 +1864,20 @@ const StreamHandler = struct { } 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.*) { .xtgettcap => |*gettcap| { const map = comptime terminfo.ghostty.xtgettcapMap(); while (gettcap.next()) |key| { From f375bf009c210acacf16b9b6661153f25dbde41b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 18:53:42 -0700 Subject: [PATCH 03/11] terminal: all DCS events can produce a command --- src/terminal/dcs.zig | 63 ++++++++++++++++++++++++++------------------ src/termio/Exec.zig | 4 ++- 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 72949789d..41ec28931 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -21,33 +21,44 @@ 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_ = 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(alloc: Allocator, dcs: DCS) !?Hook { return switch (dcs.intermediates.len) { 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 +66,9 @@ pub const Handler = struct { '$' => switch (dcs.final) { // DECRQSS - 'q' => .{ + 'q' => .{ .state = .{ .decrqss = .{}, - }, + } }, else => null, }, @@ -222,7 +233,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); @@ -234,7 +245,7 @@ test "XTGETTCAP command" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); for ("536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); @@ -249,7 +260,7 @@ test "XTGETTCAP command multiple keys" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); for ("536D756C78;536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); @@ -265,7 +276,7 @@ test "XTGETTCAP command invalid data" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null); for ("who;536D756C78") |byte| _ = h.put(byte); var cmd = h.unhook().?; defer cmd.deinit(); @@ -281,7 +292,7 @@ test "DECRQSS command" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); _ = h.put('m'); var cmd = h.unhook().?; defer cmd.deinit(); @@ -295,7 +306,7 @@ test "DECRQSS invalid command" { var h: Handler = .{}; defer h.deinit(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); _ = h.put('z'); var cmd = h.unhook().?; defer cmd.deinit(); @@ -304,7 +315,7 @@ test "DECRQSS invalid command" { h.discard(); - h.hook(alloc, .{ .intermediates = "$", .final = 'q' }); + try testing.expect(h.hook(alloc, .{ .intermediates = "$", .final = 'q' }) == null); _ = h.put('"'); _ = h.put(' '); _ = h.put('q'); diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 6b1d6d1ac..a861bee6a 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1860,7 +1860,9 @@ 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 { From ff43609097f19777c94defa1eab9f846deb515ba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 18:57:36 -0700 Subject: [PATCH 04/11] terminal: boilerplate for tmux control mode parsing --- src/terminal/dcs.zig | 19 +++++++++++++++++++ src/termio/Exec.zig | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 41ec28931..06e4d9437 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -98,6 +98,8 @@ pub const Handler = struct { .ignore, => {}, + .tmux => {}, + .xtgettcap => |*list| { if (list.items.len >= self.max_bytes) { return error.OutOfMemory; @@ -126,6 +128,8 @@ pub const Handler = struct { .ignore, => null, + .tmux => null, + .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, .decrqss => |buffer| .{ .decrqss = switch (buffer.len) { @@ -157,6 +161,8 @@ pub const Handler = struct { .xtgettcap => |*list| list.deinit(), .decrqss => {}, + + .tmux => {}, } self.state = .{ .inactive = {} }; @@ -170,10 +176,14 @@ pub const Command = union(enum) { /// DECRQSS decrqss: DECRQSS, + /// Tmux control mode + tmux: Tmux, + pub fn deinit(self: Command) void { switch (self) { .xtgettcap => |*v| v.data.deinit(), .decrqss => {}, + .tmux => {}, } } @@ -207,6 +217,12 @@ pub const Command = union(enum) { decstbm, decslrm, }; + + /// Tmux control mode + pub const Tmux = union(enum) { + enter: void, + exit: void, + }; }; const State = union(enum) { @@ -225,6 +241,9 @@ const State = union(enum) { data: [2]u8 = undefined, len: u2 = 0, }, + + /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode + tmux: void, }; test "unknown DCS command" { diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index a861bee6a..b0f973b1e 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1880,6 +1880,11 @@ const StreamHandler = struct { fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{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| { @@ -1887,6 +1892,7 @@ const StreamHandler = struct { self.messageWriter(.{ .write_stable = response }); } }, + .decrqss => |decrqss| { var response: [128]u8 = undefined; var stream = std.io.fixedBufferStream(&response); From 88d055452b81dab01449a75d35d4177ebf59633e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 19:02:33 -0700 Subject: [PATCH 05/11] terminal: tmux enter/exit --- src/terminal/dcs.zig | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 06e4d9437..883be90e1 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -48,6 +48,21 @@ pub const Handler = struct { fn tryHook(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 = {} }, + .command = .{ .tmux = .{ .enter = {} } }, + }; + }, + + else => null, + }, + 1 => switch (dcs.intermediates[0]) { '+' => switch (dcs.final) { // XTGETTCAP @@ -128,7 +143,7 @@ pub const Handler = struct { .ignore, => null, - .tmux => null, + .tmux => .{ .tmux = .{ .exit = {} } }, .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, @@ -340,3 +355,25 @@ test "DECRQSS invalid command" { _ = 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); + } +} From f4db5009d617d937b8ddd387d3a2b3cbf60c4308 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 19:09:55 -0700 Subject: [PATCH 06/11] terminal: dcs state cleanup in deinit --- src/terminal/dcs.zig | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 883be90e1..287aa03ad 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -137,7 +137,11 @@ pub const Handler = struct { } 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, @@ -168,18 +172,7 @@ pub const Handler = struct { } fn discard(self: *Handler) void { - switch (self.state) { - .inactive, - .ignore, - => {}, - - .xtgettcap => |*list| list.deinit(), - - .decrqss => {}, - - .tmux => {}, - } - + self.state.deinit(); self.state = .{ .inactive = {} }; } }; @@ -259,6 +252,18 @@ const State = union(enum) { /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode tmux: void, + + pub fn deinit(self: State) void { + switch (self) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*v| v.deinit(), + .decrqss => {}, + .tmux => {}, + } + } }; test "unknown DCS command" { From 1ea25c5c641c4cbbb649ad8d56b7de8bf485e53c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 11 Jul 2024 20:51:48 -0700 Subject: [PATCH 07/11] terminal: tmux parsing handles begin/end blocks --- src/terminal/dcs.zig | 168 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 161 insertions(+), 7 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 287aa03ad..ee9a39340 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -55,7 +55,14 @@ pub const Handler = struct { if (dcs.params.len != 1 or dcs.params[0] != 1000) break :tmux null; break :tmux .{ - .state = .{ .tmux = {} }, + .state = .{ + .tmux = .{ + .buffer = try std.ArrayList(u8).initCapacity( + alloc, + 128, // Arbitrary choice to limit initial reallocs + ), + }, + }, .command = .{ .tmux = .{ .enter = {} } }, }; }, @@ -113,7 +120,7 @@ pub const Handler = struct { .ignore, => {}, - .tmux => {}, + .tmux => |*tmux| return try tmux.put(byte, self.max_bytes), .xtgettcap => |*list| { if (list.items.len >= self.max_bytes) { @@ -147,7 +154,10 @@ pub const Handler = struct { .ignore, => null, - .tmux => .{ .tmux = .{ .exit = {} } }, + .tmux => tmux: { + self.state.deinit(); + break :tmux .{ .tmux = .{ .exit = {} } }; + }, .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, @@ -251,21 +261,143 @@ const State = union(enum) { }, /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode - tmux: void, + tmux: TmuxState, - pub fn deinit(self: State) void { - switch (self) { + pub fn deinit(self: *State) void { + switch (self.*) { .inactive, .ignore, => {}, .xtgettcap => |*v| v.deinit(), .decrqss => {}, - .tmux => {}, + .tmux => |*v| v.deinit(), } } }; +const TmuxState = struct { + tag: Tag = .idle, + buffer: std.ArrayList(u8), + + const Tag = enum { + /// Outside of any active command. This should drop any output + /// unless it is '%' on the first byte of a line. + idle, + + /// We experienced unexpected input and are in a broken state + /// so we cannot continue processing. + broken, + + /// Inside an active command (started with '%'). + command, + + /// Inside a begin/end block. + block, + }; + + pub fn deinit(self: *TmuxState) void { + self.buffer.deinit(); + } + + // Handle a byte of input. + pub fn put(self: *TmuxState, byte: u8, max_bytes: usize) !?Command { + if (self.buffer.items.len >= max_bytes) { + self.broken(); + return error.OutOfMemory; + } + + switch (self.tag) { + // Drop because we're in a broken state. + .broken => return null, + + // Waiting for a command so if the byte is not '%' then + // we're in a broken state. Return an exit command. + .idle => if (byte != '%') { + self.broken(); + return .{ .tmux = .{ .exit = {} } }; + } else { + self.tag = .command; + }, + + // If we're in a command and its not a newline then + // we accumulate. If it is a newline then we have a + // complete command we need to parse. + .command => if (byte == '\n') { + // We have a complete command, parse it. + return try self.parseCommand(); + }, + + // If we're ina 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")) + { + // If it is an error then log it. + if (std.mem.startsWith(u8, line, "%error")) { + const output = self.buffer.items[0..idx]; + log.warn("tmux control mode error={s}", .{output}); + } + + // We ignore the rest of the line, see %begin for why. + self.tag = .idle; + self.buffer.clearRetainingCapacity(); + return null; + } + }, + } + + try self.buffer.append(byte); + + return null; + } + + fn parseCommand(self: *TmuxState) !?Command { + assert(self.tag == .command); + + var it = std.mem.tokenizeScalar(u8, self.buffer.items, ' '); + + // The command MUST exist because we guard entering the command + // state on seeing at least a '%'. + const cmd = it.next().?; + 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.tag = .block; + self.buffer.clearRetainingCapacity(); + return null; + } else { + // Unknown command, log it and return to idle state. + log.warn("unknown tmux control mode command={s}", .{cmd}); + } + + // Successful exit, revert to idle state. + self.buffer.clearRetainingCapacity(); + self.tag = .idle; + + return null; + } + + // Mark the tmux state as broken. + fn broken(self: *TmuxState) void { + self.tag = .broken; + self.buffer.clearAndFree(); + } +}; + test "unknown DCS command" { const testing = std.testing; const alloc = testing.allocator; @@ -382,3 +514,25 @@ test "tmux enter and implicit exit" { try testing.expect(cmd.tmux == .exit); } } + +test "tmux begin/end empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); + for ("%end 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); +} + +test "tmux begin/error empty" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?.deinit(); + for ("%begin 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); + for ("%error 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); +} From 8c3559ecff6486c171afae7cbe64c2b3688bc6bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Jul 2024 09:49:59 -0700 Subject: [PATCH 08/11] terminal: move tmux control mode parsing out to dedicated file --- src/terminal/dcs.zig | 135 ++------------------------------- src/terminal/main.zig | 1 + src/terminal/tmux.zig | 170 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 127 deletions(-) create mode 100644 src/terminal/tmux.zig diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index ee9a39340..5feb25e35 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -28,7 +28,7 @@ pub const Handler = struct { self.state = .{ .ignore = {} }; // Try to parse the hook. - const hk_ = tryHook(alloc, dcs) catch |err| { + const hk_ = self.tryHook(alloc, dcs) catch |err| { log.info("error initializing DCS hook, will ignore hook err={}", .{err}); return null; }; @@ -46,7 +46,7 @@ pub const Handler = struct { command: ?Command = null, }; - fn tryHook(alloc: Allocator, dcs: DCS) !?Hook { + fn tryHook(self: Handler, alloc: Allocator, dcs: DCS) !?Hook { return switch (dcs.intermediates.len) { 0 => switch (dcs.final) { // Tmux control mode @@ -57,6 +57,7 @@ pub const Handler = struct { break :tmux .{ .state = .{ .tmux = .{ + .max_bytes = self.max_bytes, .buffer = try std.ArrayList(u8).initCapacity( alloc, 128, // Arbitrary choice to limit initial reallocs @@ -120,7 +121,9 @@ pub const Handler = struct { .ignore, => {}, - .tmux => |*tmux| return try tmux.put(byte, self.max_bytes), + .tmux => |*tmux| return .{ + .tmux = (try tmux.put(byte)) orelse return null, + }, .xtgettcap => |*list| { if (list.items.len >= self.max_bytes) { @@ -195,7 +198,7 @@ pub const Command = union(enum) { decrqss: DECRQSS, /// Tmux control mode - tmux: Tmux, + tmux: terminal.tmux.Notification, pub fn deinit(self: Command) void { switch (self) { @@ -261,7 +264,7 @@ const State = union(enum) { }, /// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode - tmux: TmuxState, + tmux: terminal.tmux.Client, pub fn deinit(self: *State) void { switch (self.*) { @@ -276,128 +279,6 @@ const State = union(enum) { } }; -const TmuxState = struct { - tag: Tag = .idle, - buffer: std.ArrayList(u8), - - const Tag = enum { - /// Outside of any active command. This should drop any output - /// unless it is '%' on the first byte of a line. - idle, - - /// We experienced unexpected input and are in a broken state - /// so we cannot continue processing. - broken, - - /// Inside an active command (started with '%'). - command, - - /// Inside a begin/end block. - block, - }; - - pub fn deinit(self: *TmuxState) void { - self.buffer.deinit(); - } - - // Handle a byte of input. - pub fn put(self: *TmuxState, byte: u8, max_bytes: usize) !?Command { - if (self.buffer.items.len >= max_bytes) { - self.broken(); - return error.OutOfMemory; - } - - switch (self.tag) { - // Drop because we're in a broken state. - .broken => return null, - - // Waiting for a command so if the byte is not '%' then - // we're in a broken state. Return an exit command. - .idle => if (byte != '%') { - self.broken(); - return .{ .tmux = .{ .exit = {} } }; - } else { - self.tag = .command; - }, - - // If we're in a command and its not a newline then - // we accumulate. If it is a newline then we have a - // complete command we need to parse. - .command => if (byte == '\n') { - // We have a complete command, parse it. - return try self.parseCommand(); - }, - - // If we're ina 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")) - { - // If it is an error then log it. - if (std.mem.startsWith(u8, line, "%error")) { - const output = self.buffer.items[0..idx]; - log.warn("tmux control mode error={s}", .{output}); - } - - // We ignore the rest of the line, see %begin for why. - self.tag = .idle; - self.buffer.clearRetainingCapacity(); - return null; - } - }, - } - - try self.buffer.append(byte); - - return null; - } - - fn parseCommand(self: *TmuxState) !?Command { - assert(self.tag == .command); - - var it = std.mem.tokenizeScalar(u8, self.buffer.items, ' '); - - // The command MUST exist because we guard entering the command - // state on seeing at least a '%'. - const cmd = it.next().?; - 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.tag = .block; - self.buffer.clearRetainingCapacity(); - return null; - } else { - // Unknown command, log it and return to idle state. - log.warn("unknown tmux control mode command={s}", .{cmd}); - } - - // Successful exit, revert to idle state. - self.buffer.clearRetainingCapacity(); - self.tag = .idle; - - return null; - } - - // Mark the tmux state as broken. - fn broken(self: *TmuxState) void { - self.tag = .broken; - self.buffer.clearAndFree(); - } -}; - test "unknown DCS command" { const testing = std.testing; const alloc = testing.allocator; 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..27f28e77a --- /dev/null +++ b/src/terminal/tmux.zig @@ -0,0 +1,170 @@ +//! 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 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 commands, 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 command. This should drop any output + /// unless it is '%' on the first byte of a line. + idle, + + /// We experienced unexpected input and are in a broken state + /// so we cannot continue processing. + broken, + + /// Inside an active command (started with '%'). + command, + + /// 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 command so if the byte is not '%' then + // we're in a broken state. Return an exit command. + .idle => if (byte != '%') { + self.broken(); + return .{ .exit = {} }; + } else { + self.state = .command; + }, + + // If we're in a command and its not a newline then + // we accumulate. If it is a newline then we have a + // complete command we need to parse. + .command => if (byte == '\n') { + // We have a complete command, parse it. + return try self.parseNotification(); + }, + + // If we're ina 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")) + { + // If it is an error then log it. + if (std.mem.startsWith(u8, line, "%error")) { + const output = self.buffer.items[0..idx]; + log.warn("tmux control mode error={s}", .{output}); + } + + // We ignore the rest of the line, see %begin for why. + self.state = .idle; + self.buffer.clearRetainingCapacity(); + return null; + } + }, + } + + try self.buffer.append(byte); + + return null; + } + + fn parseNotification(self: *Client) !?Notification { + assert(self.state == .command); + + var it = std.mem.tokenizeScalar(u8, self.buffer.items, ' '); + + // The command MUST exist because we guard entering the command + // state on seeing at least a '%'. + const cmd = it.next().?; + 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 { + // Unknown command, log it and return to idle state. + log.warn("unknown tmux control mode command={s}", .{cmd}); + } + + // Successful exit, revert 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, +}; + +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\n") |byte| try testing.expect(try c.put(byte) == null); +} + +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\n") |byte| try testing.expect(try c.put(byte) == null); +} From bc7bc151209151f58013c1afbf697a9dd408a53f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Jul 2024 10:22:59 -0700 Subject: [PATCH 09/11] terminal/tmux: parse session-changed notification --- src/terminal/tmux.zig | 94 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 19 deletions(-) diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 27f28e77a..4e7f92b5e 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -5,6 +5,7 @@ const std = @import("std"); const assert = std.debug.assert; +const oni = @import("oniguruma"); const log = std.log.scoped(.terminal_tmux); @@ -15,7 +16,7 @@ pub const Client = struct { /// Current state of the client. state: State = .idle, - /// The buffer used to store in-progress commands, output, etc. + /// 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 @@ -25,16 +26,18 @@ pub const Client = struct { max_bytes: usize = 1024 * 1024, const State = enum { - /// Outside of any active command. This should drop any output - /// unless it is '%' on the first byte of a line. + /// 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 command (started with '%'). - command, + /// Inside an active notification (started with '%'). + notification, /// Inside a begin/end block. block, @@ -55,24 +58,27 @@ pub const Client = struct { // Drop because we're in a broken state. .broken => return null, - // Waiting for a command so if the byte is not '%' then - // we're in a broken state. Return an exit command. + // 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.state = .command; + self.buffer.clearRetainingCapacity(); + self.state = .notification; }, - // If we're in a command and its not a newline then + // If we're in a notification and its not a newline then // we accumulate. If it is a newline then we have a - // complete command we need to parse. - .command => if (byte == '\n') { - // We have a complete command, parse it. + // complete notification we need to parse. + .notification => if (byte == '\n') { + // We have a complete notification, parse it. return try self.parseNotification(); }, - // If we're ina block then we accumulate until we see a newline + // 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( @@ -81,6 +87,7 @@ pub const Client = struct { '\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")) { @@ -95,6 +102,8 @@ pub const Client = struct { self.buffer.clearRetainingCapacity(); return null; } + + // Didn't end the block, continue accumulating. }, } @@ -104,11 +113,12 @@ pub const Client = struct { } fn parseNotification(self: *Client) !?Notification { - assert(self.state == .command); + assert(self.state == .notification); - var it = std.mem.tokenizeScalar(u8, self.buffer.items, ' '); + const line = self.buffer.items; + var it = std.mem.tokenizeScalar(u8, line, ' '); - // The command MUST exist because we guard entering the command + // The notification MUST exist because we guard entering the notification // state on seeing at least a '%'. const cmd = it.next().?; if (std.mem.eql(u8, cmd, "%begin")) { @@ -123,12 +133,40 @@ pub const Client = struct { self.state = .block; self.buffer.clearRetainingCapacity(); return null; + } 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} err={}", .{ cmd, 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 { - // Unknown command, log it and return to idle state. - log.warn("unknown tmux control mode command={s}", .{cmd}); + // Unknown notification, log it and return to idle state. + log.warn("unknown tmux control mode notification={s}", .{cmd}); } - // Successful exit, revert to idle state. + // Unknown command. Clear the buffer and return to idle state. self.buffer.clearRetainingCapacity(); self.state = .idle; @@ -147,6 +185,11 @@ pub const Client = struct { pub const Notification = union(enum) { enter: void, exit: void, + + session_changed: struct { + id: usize, + name: []const u8, + }, }; test "tmux begin/end empty" { @@ -168,3 +211,16 @@ test "tmux begin/error empty" { for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); for ("%error 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); } + +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); +} From 057dc32c719680530c11bf0bf8b662dcda9529fa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Jul 2024 11:57:37 -0700 Subject: [PATCH 10/11] terminal/tmux: many more notifications --- src/terminal/tmux.zig | 182 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 178 insertions(+), 4 deletions(-) diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 4e7f92b5e..8bbc68fa4 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -115,12 +115,18 @@ pub const Client = struct { fn parseNotification(self: *Client) !?Notification { assert(self.state == .notification); - const line = self.buffer.items; - var it = std.mem.tokenizeScalar(u8, line, ' '); + 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 '%'. - const cmd = it.next().?; 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 @@ -133,6 +139,34 @@ pub const Client = struct { 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]+) (.+)$", @@ -144,7 +178,7 @@ pub const Client = struct { defer re.deinit(); var region = re.search(line, .{}) catch |err| { - log.warn("failed to match notification cmd={s} err={}", .{ cmd, err }); + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); break :cmd; }; defer region.deinit(); @@ -161,6 +195,70 @@ pub const Client = struct { // 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}); @@ -186,10 +284,26 @@ pub const Notification = union(enum) { enter: void, exit: void, + 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" { @@ -212,6 +326,19 @@ test "tmux begin/error empty" { for ("%error 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null); } +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; @@ -224,3 +351,50 @@ test "tmux 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); +} From df088c67f443db817788b87bb34570d14e95dd8a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 12 Jul 2024 14:04:56 -0700 Subject: [PATCH 11/11] terminal/tmux: block output notifications --- src/terminal/dcs.zig | 22 ---------------------- src/terminal/tmux.zig | 43 +++++++++++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index 5feb25e35..da6f3ae23 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -395,25 +395,3 @@ test "tmux enter and implicit exit" { try testing.expect(cmd.tmux == .exit); } } - -test "tmux begin/end empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); - for ("%end 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); -} - -test "tmux begin/error empty" { - const testing = std.testing; - const alloc = testing.allocator; - - var h: Handler = .{}; - defer h.deinit(); - h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?.deinit(); - for ("%begin 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); - for ("%error 1578922740 269 1\n") |byte| try testing.expect(h.put(byte) == null); -} diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 8bbc68fa4..1ea9f8c39 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -91,16 +91,16 @@ pub const Client = struct { if (std.mem.startsWith(u8, line, "%end") or std.mem.startsWith(u8, line, "%error")) { - // If it is an error then log it. - if (std.mem.startsWith(u8, line, "%error")) { - const output = self.buffer.items[0..idx]; - log.warn("tmux control mode error={s}", .{output}); - } + const err = std.mem.startsWith(u8, line, "%error"); + const output = std.mem.trimRight(u8, self.buffer.items[0..idx], "\r\n"); - // We ignore the rest of the line, see %begin for why. + // 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; - self.buffer.clearRetainingCapacity(); - return null; + return if (err) .{ .block_err = output } else .{ .block_end = output }; } // Didn't end the block, continue accumulating. @@ -284,6 +284,9 @@ 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 @@ -313,7 +316,10 @@ test "tmux begin/end empty" { 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\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" { @@ -323,7 +329,24 @@ test "tmux begin/error empty" { 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\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" {