aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/tmux/control.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/tmux/control.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/tmux/control.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/tmux/control.zig839
1 files changed, 839 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/tmux/control.zig b/vendor/libghostty-vt/src/terminal/tmux/control.zig
new file mode 100644
index 00000000..3b94eb16
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/tmux/control.zig
@@ -0,0 +1,839 @@
+//! 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 Allocator = std.mem.Allocator;
+const assert = @import("../../quirks.zig").inlineAssert;
+const oni = @import("oniguruma");
+
+const log = std.log.scoped(.terminal_tmux);
+
+/// A tmux control mode parser. This takes in output from tmux control
+/// mode and parses it into a structured notifications.
+///
+/// It is up to the caller to establish the connection to the tmux
+/// control mode session in some way (e.g. via exec, a network socket,
+/// whatever). This is fully agnostic to how the data is received and sent.
+pub const Parser = struct {
+ /// Current state of the client.
+ state: State = .idle,
+
+ /// The buffer used to store in-progress notifications, output, etc.
+ buffer: std.Io.Writer.Allocating,
+
+ /// 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. When this state is set,
+ /// the buffer has been deinited and must not be accessed.
+ broken,
+
+ /// Inside an active notification (started with '%').
+ notification,
+
+ /// Inside a begin/end block.
+ block,
+ };
+
+ pub fn deinit(self: *Parser) void {
+ // If we're in a broken state, we already deinited
+ // the buffer, so we don't need to do anything.
+ if (self.state == .broken) return;
+
+ self.buffer.deinit();
+ }
+
+ // Handle a byte of input.
+ //
+ // If we reach our byte limit this will return OutOfMemory. It only
+ // does this on the first time we exceed the limit; subsequent calls
+ // will return null as we drop all input in a broken state.
+ pub fn put(self: *Parser, byte: u8) Allocator.Error!?Notification {
+ // If we're in a broken state, just do nothing.
+ //
+ // We have to do this check here before we check the buffer, because if
+ // we're in a broken state then we'd have already deinited the buffer.
+ if (self.state == .broken) return null;
+
+ if (self.buffer.written().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 self.parseNotification() catch {
+ // If parsing failed, then we do not mark the state
+ // as broken because we may be able to continue parsing
+ // other types of notifications.
+ //
+ // In the future we may want to emit a notification
+ // here about unknown or unsupported notifications.
+ return null;
+ };
+ },
+
+ // 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 written = self.buffer.written();
+ const idx = if (std.mem.lastIndexOfScalar(
+ u8,
+ written,
+ '\n',
+ )) |v| v + 1 else 0;
+ const line = written[idx..];
+
+ if (parseBlockTerminator(line)) |terminator| {
+ const output = std.mem.trimRight(
+ u8,
+ written[0..idx],
+ "\r\n",
+ );
+
+ // Important: do not clear buffer since the notification
+ // contains it.
+ self.state = .idle;
+ switch (terminator) {
+ .end => return .{ .block_end = output },
+ .err => {
+ log.warn("tmux control mode error={s}", .{output});
+ return .{ .block_err = output };
+ },
+ }
+ }
+
+ // Didn't end the block, continue accumulating.
+ },
+ }
+
+ self.buffer.writer.writeByte(byte) catch |err| switch (err) {
+ error.WriteFailed => return error.OutOfMemory,
+ };
+
+ return null;
+ }
+
+ const ParseError = error{RegexError};
+
+ const BlockTerminator = enum { end, err };
+
+ /// Block payload is raw data, so a line only terminates a block if it
+ /// exactly matches tmux's `%end`/`%error` guard-line shape.
+ fn parseBlockTerminator(line_raw: []const u8) ?BlockTerminator {
+ var line = line_raw;
+ if (line.len > 0 and line[line.len - 1] == '\r') {
+ line = line[0 .. line.len - 1];
+ }
+
+ var fields = std.mem.tokenizeScalar(u8, line, ' ');
+ const cmd = fields.next() orelse return null;
+ const terminator: BlockTerminator = if (std.mem.eql(u8, cmd, "%end"))
+ .end
+ else if (std.mem.eql(u8, cmd, "%error"))
+ .err
+ else
+ return null;
+
+ const time = fields.next() orelse return null;
+ const command_id = fields.next() orelse return null;
+ const flags = fields.next() orelse return null;
+ const extra = fields.next();
+
+ // In the future, we should compare these to the %begin block
+ // because the tmux source guarantees that these always match and
+ // that is a more robust way to match.
+ _ = std.fmt.parseInt(usize, time, 10) catch return null;
+ _ = std.fmt.parseInt(usize, command_id, 10) catch return null;
+ _ = std.fmt.parseInt(usize, flags, 10) catch return null;
+ if (extra != null) return null;
+
+ return terminator;
+ }
+
+ fn parseNotification(self: *Parser) ParseError!?Notification {
+ assert(self.state == .notification);
+
+ const line = line: {
+ var line = self.buffer.written();
+ 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 = oni.Regex.init(
+ "^%output %([0-9]+) (.+)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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 = oni.Regex.init(
+ "^%session-changed \\$([0-9]+) (.+)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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, "%layout-change")) cmd: {
+ var re = oni.Regex.init(
+ "^%layout-change @([0-9]+) (.+) (.+) (.*)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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 layout = line[@intCast(starts[2])..@intCast(ends[2])];
+ const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])];
+ const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])];
+
+ // Important: do not clear buffer here since layout strings point to it
+ self.state = .idle;
+ return .{ .layout_change = .{
+ .window_id = id,
+ .layout = layout,
+ .visible_layout = visible_layout,
+ .raw_flags = raw_flags,
+ } };
+ } else if (std.mem.eql(u8, cmd, "%window-add")) cmd: {
+ var re = oni.Regex.init(
+ "^%window-add @([0-9]+)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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 = oni.Regex.init(
+ "^%window-renamed @([0-9]+) (.+)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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 if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: {
+ var re = oni.Regex.init(
+ "^%window-pane-changed @([0-9]+) %([0-9]+)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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 window_id = std.fmt.parseInt(
+ usize,
+ line[@intCast(starts[1])..@intCast(ends[1])],
+ 10,
+ ) catch unreachable;
+ const pane_id = std.fmt.parseInt(
+ usize,
+ line[@intCast(starts[2])..@intCast(ends[2])],
+ 10,
+ ) catch unreachable;
+
+ self.buffer.clearRetainingCapacity();
+ self.state = .idle;
+ return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } };
+ } else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: {
+ var re = oni.Regex.init(
+ "^%client-detached (.+)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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 client = line[@intCast(starts[1])..@intCast(ends[1])];
+
+ // Important: do not clear buffer here since client points to it
+ self.state = .idle;
+ return .{ .client_detached = .{ .client = client } };
+ } else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: {
+ var re = oni.Regex.init(
+ "^%client-session-changed (.+) \\$([0-9]+) (.+)$",
+ .{ .capture_group = true },
+ oni.Encoding.utf8,
+ oni.Syntax.default,
+ null,
+ ) catch |err| {
+ log.warn("regex init failed error={}", .{err});
+ return error.RegexError;
+ };
+ 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 client = line[@intCast(starts[1])..@intCast(ends[1])];
+ const session_id = std.fmt.parseInt(
+ usize,
+ line[@intCast(starts[2])..@intCast(ends[2])],
+ 10,
+ ) catch unreachable;
+ const name = line[@intCast(starts[3])..@intCast(ends[3])];
+
+ // Important: do not clear buffer here since client/name point to it
+ self.state = .idle;
+ return .{ .client_session_changed = .{ .client = client, .session_id = session_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: *Parser) void {
+ self.state = .broken;
+ self.buffer.deinit();
+ }
+};
+
+/// Possible notification types from tmux control mode. These are documented
+/// in tmux(1). A lot of the simple documentation was copied from that man
+/// page here.
+pub const Notification = union(enum) {
+ /// Entering tmux control mode. This isn't an actual event sent by
+ /// tmux but is one sent by us to indicate that we have detected that
+ /// tmux control mode is starting.
+ enter,
+
+ /// Exit.
+ ///
+ /// NOTE: The tmux protocol contains a "reason" string (human friendly)
+ /// associated with this. We currently drop it because we don't need it
+ /// but this may be something we want to add later. If we do add it,
+ /// we have to consider buffer limits and how we handle those (dropping
+ /// vs truncating, etc.).
+ exit,
+
+ /// Dispatched at the end of a begin/end block with the raw data.
+ /// The control mode parser can't parse the data because it is unaware
+ /// of the command that was sent to trigger this output.
+ block_end: []const u8,
+ block_err: []const u8,
+
+ /// Raw output from a pane.
+ output: struct {
+ pane_id: usize,
+ data: []const u8, // unescaped
+ },
+
+ /// The client is now attached to the session with ID session-id, which is
+ /// named name.
+ session_changed: struct {
+ id: usize,
+ name: []const u8,
+ },
+
+ /// A session was created or destroyed.
+ sessions_changed,
+
+ /// The layout of the window with ID window-id changed.
+ layout_change: struct {
+ window_id: usize,
+ layout: []const u8,
+ visible_layout: []const u8,
+ raw_flags: []const u8,
+ },
+
+ /// The window with ID window-id was linked to the current session.
+ window_add: struct {
+ id: usize,
+ },
+
+ /// The window with ID window-id was renamed to name.
+ window_renamed: struct {
+ id: usize,
+ name: []const u8,
+ },
+
+ /// The active pane in the window with ID window-id changed to the pane
+ /// with ID pane-id.
+ window_pane_changed: struct {
+ window_id: usize,
+ pane_id: usize,
+ },
+
+ /// The client has detached.
+ client_detached: struct {
+ client: []const u8,
+ },
+
+ /// The client is now attached to the session with ID session-id, which is
+ /// named name.
+ client_session_changed: struct {
+ client: []const u8,
+ session_id: usize,
+ name: []const u8,
+ },
+
+ pub fn format(self: Notification, writer: *std.Io.Writer) !void {
+ const T = Notification;
+ const info = @typeInfo(T).@"union";
+
+ try writer.writeAll(@typeName(T));
+ if (info.tag_type) |TagType| {
+ try writer.writeAll("{ .");
+ try writer.writeAll(@tagName(@as(TagType, self)));
+ try writer.writeAll(" = ");
+
+ inline for (info.fields) |u_field| {
+ if (self == @field(TagType, u_field.name)) {
+ const value = @field(self, u_field.name);
+ switch (u_field.type) {
+ []const u8 => try writer.print("\"{s}\"", .{std.mem.trim(u8, value, " \t\r\n")}),
+ else => try writer.print("{any}", .{value}),
+ }
+ }
+ }
+
+ try writer.writeAll(" }");
+ }
+ }
+};
+
+test "tmux begin/end empty" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .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: Parser = .{ .buffer = .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: Parser = .{ .buffer = .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 block payload may start with %end" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%end not really\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%end 1 1 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("%end not really\nhello", n.block_end);
+}
+
+test "tmux block payload may start with %error" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%end 1 1 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("%error not really\nhello", n.block_end);
+}
+
+test "tmux block may terminate with real %error after misleading payload" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%error 1 1 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("%error not really\nhello", n.block_err);
+}
+
+test "tmux block terminator requires exact token count" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%end 1 1 1 trailing\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%end 1 1 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("%end 1 1 1 trailing\nhello", n.block_end);
+}
+
+test "tmux block terminator requires numeric metadata" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%end foo bar baz\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
+ for ("%end 1 1 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("%end foo bar baz\nhello", n.block_end);
+}
+
+test "tmux output" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .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: Parser = .{ .buffer = .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: Parser = .{ .buffer = .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: Parser = .{ .buffer = .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 layout-change" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null);
+ const n = (try c.put('\n')).?;
+ try testing.expect(n == .layout_change);
+ try testing.expectEqual(2, n.layout_change.window_id);
+ try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout);
+ try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout);
+ try testing.expectEqualStrings("*-", n.layout_change.raw_flags);
+}
+
+test "tmux window-add" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .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: Parser = .{ .buffer = .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);
+}
+
+test "tmux window-pane-changed" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null);
+ const n = (try c.put('\n')).?;
+ try testing.expect(n == .window_pane_changed);
+ try testing.expectEqual(42, n.window_pane_changed.window_id);
+ try testing.expectEqual(2, n.window_pane_changed.pane_id);
+}
+
+test "tmux client-detached" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null);
+ const n = (try c.put('\n')).?;
+ try testing.expect(n == .client_detached);
+ try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client);
+}
+
+test "tmux client-session-changed" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var c: Parser = .{ .buffer = .init(alloc) };
+ defer c.deinit();
+ for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null);
+ const n = (try c.put('\n')).?;
+ try testing.expect(n == .client_session_changed);
+ try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client);
+ try testing.expectEqual(2, n.client_session_changed.session_id);
+ try testing.expectEqualStrings("mysession", n.client_session_changed.name);
+}