From af3a11b54673a0bb8f3c1e6f2d076f69810cbf4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:09:52 -0800 Subject: [PATCH 01/28] terminal/tmux: output has format/comptimeFormat --- src/terminal/tmux/output.zig | 68 ++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index dcfa89ac3..cff1a982d 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -36,6 +36,36 @@ pub fn parseFormatStruct( return result; } +pub fn comptimeFormat( + comptime vars: []const Variable, + comptime delimiter: u8, +) []const u8 { + comptime { + @setEvalBranchQuota(50000); + var counter: std.Io.Writer.Discarding = .init(&.{}); + try format(&counter.writer, vars, delimiter); + + var buf: [counter.count]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + const final = buf; + return final[0..writer.end]; + } +} + +/// Format a set of variables into the proper format string for tmux +/// that we can handle with `parseFormatStruct`. +pub fn format( + writer: *std.Io.Writer, + vars: []const Variable, + delimiter: u8, +) std.Io.Writer.Error!void { + for (vars, 0..) |variable, i| { + if (i != 0) try writer.writeByte(delimiter); + try writer.print("#{{{t}}}", .{variable}); + } +} + /// Returns a struct type that contains fields for each of the given /// format variables. This can be used with `parseFormatStruct` to /// parse an output string into a format struct. @@ -203,3 +233,41 @@ test "parseFormatStruct with empty layout field" { try testing.expectEqual(1, result.session_id); try testing.expectEqualStrings("", result.window_layout); } + +fn testFormat( + comptime vars: []const Variable, + comptime delimiter: u8, + comptime expected: []const u8, +) !void { + const comptime_result = comptime comptimeFormat(vars, delimiter); + try testing.expectEqualStrings(expected, comptime_result); + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try format(&writer, vars, delimiter); + try testing.expectEqualStrings(expected, buf[0..writer.end]); +} + +test "format single variable" { + try testFormat(&.{.session_id}, ' ', "#{session_id}"); +} + +test "format multiple variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height}"); +} + +test "format with comma delimiter" { + try testFormat(&.{ .window_id, .window_layout }, ',', "#{window_id},#{window_layout}"); +} + +test "format with tab delimiter" { + try testFormat(&.{ .window_width, .window_height }, '\t', "#{window_width}\t#{window_height}"); +} + +test "format empty variables" { + try testFormat(&.{}, ' ', ""); +} + +test "format all variables" { + try testFormat(&.{ .session_id, .window_id, .window_width, .window_height, .window_layout }, ' ', "#{session_id} #{window_id} #{window_width} #{window_height} #{window_layout}"); +} From 0d75a787471a2b1a26dc31d05c5f607d7cab1543 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 11:01:04 -0800 Subject: [PATCH 02/28] terminal/tmux: start viewer state machine --- src/terminal/tmux.zig | 1 + src/terminal/tmux/viewer.zig | 235 ++++++++++++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/terminal/tmux/viewer.zig diff --git a/src/terminal/tmux.zig b/src/terminal/tmux.zig index 82ef5036b..c7cda1442 100644 --- a/src/terminal/tmux.zig +++ b/src/terminal/tmux.zig @@ -6,6 +6,7 @@ pub const output = @import("tmux/output.zig"); pub const ControlParser = control.Parser; pub const ControlNotification = control.Notification; pub const Layout = layout.Layout; +pub const Viewer = @import("tmux/viewer.zig").Viewer; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig new file mode 100644 index 000000000..7a84f9243 --- /dev/null +++ b/src/terminal/tmux/viewer.zig @@ -0,0 +1,235 @@ +const std = @import("std"); +const testing = std.testing; +const assert = @import("../../quirks.zig").inlineAssert; +const control = @import("control.zig"); +const output = @import("output.zig"); + +const log = std.log.scoped(.terminal_tmux_viewer); + +// NOTE: There is some fragility here that can possibly break if tmux +// changes their implementation. In particular, the order of notifications +// and assurances about what is sent when are based on reading the tmux +// source code as of Dec, 2025. These aren't documented as fixed. +// +// I've tried not to depend on anything that seems like it'd change +// in the future. For example, it seems reasonable that command output +// always comes before session attachment. But, I am noting this here +// in case something breaks in the future we can consider it. We should +// be able to easily unit test all variations seen in the real world. + +/// A viewer is a tmux control mode client that attempts to create +/// a remote view of a tmux session, including providing the ability to send +/// new input to the session. +/// +/// This is the primary use case for tmux control mode, but technically +/// tmux control mode clients can do anything a normal tmux client can do, +/// so the `control.zig` and other files in this folder are more general +/// purpose. +/// +/// This struct helps move through a state machine of connecting to a tmux +/// session, negotiating capabilities, listing window state, etc. +pub const Viewer = struct { + state: State = .startup_block, + + /// The current session ID we're attached to. The default value + /// is meaningless, because this has to be sent down during + /// the startup process. + session_id: usize = 0, + + pub const Action = union(enum) { + /// Tmux has closed the control mode connection, we should end + /// our viewer session in some way. + exit, + + /// Send a command to tmux, e.g. `list-windows`. The caller + /// should not worry about parsing this or reading what command + /// it is; just send it to tmux as-is. This will include the + /// trailing newline so you can send it directly. + command: []const u8, + }; + + /// Initial state + pub const init: Viewer = .{}; + + /// Send in the next tmux notification we got from the control mode + /// protocol. The return value is any action that needs to be taken + /// in reaction to this notification (could be none). + pub fn next(self: *Viewer, n: control.Notification) ?Action { + return switch (self.state) { + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct null; + }, + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => self.nextCommand(n), + }; + } + + fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_block); + + switch (n) { + // This is only sent by the DCS parser when we first get + // DCS 1000p, it should never reach us here. + .enter => unreachable, + + // I don't think this is technically possible (reading the + // tmux source code), but if we see an exit we can semantically + // handle this without issue. + .exit => { + self.state = .defunct; + return .exit; + }, + + // Any begin and end (even error) is fine! Now we wait for + // session-changed to get the initial session ID. session-changed + // is guaranteed to come after the initial command output + // since if the initial command is `attach` tmux will run that, + // queue the notification, then do notificatins. + .block_end, .block_err => { + self.state = .startup_session; + return null; + }, + + // I don't like catch-all else branches but startup is such + // a special case of looking for very specific things that + // are unlikely to expand. + else => return null, + } + } + + fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + assert(self.state == .startup_session); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .session_changed => |info| { + self.session_id = info.id; + self.state = .list_windows; + return .{ .command = std.fmt.comptimePrint( + "list-windows -F '{s}'", + .{comptime Format.list_windows.comptimeFormat()}, + ) }; + }, + + else => return null, + } + } + + fn nextCommand(self: *Viewer, n: control.Notification) ?Action { + assert(self.state != .startup_block); + assert(self.state != .startup_session); + assert(self.state != .defunct); + + switch (n) { + .enter => unreachable, + + .exit => { + self.state = .defunct; + return .exit; + }, + + .block_end, + .block_err, + => |content| switch (self.state) { + .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { + // TODO: parse the content + _ = content; + return null; + }, + }, + + // TODO: Use exhaustive matching here, determine if we need + // to handle the other cases. + else => return null, + } + } +}; + +const State = enum { + /// We start in this state just after receiving the initial + /// DCS 1000p opening sequence. We wait for an initial + /// begin/end block that is guaranteed to be sent by tmux for + /// the initial control mode command. (See tmux server-client.c + /// where control mode starts). + startup_block, + + /// After receiving the initial block, we wait for a session-changed + /// notification to record the initial session ID. + startup_session, + + /// Tmux has closed the control mode connection + defunct, + + /// We're waiting on a list-windows response from tmux. + list_windows, +}; + +/// Format strings used for commands in our viewer. +const Format = struct { + /// The variables included in this format, in order. + vars: []const output.Variable, + + /// The delimiter to use between variables. This must be a character + /// guaranteed to not appear in any of the variable outputs. + delim: u8, + + const list_windows: Format = .{ + .delim = ' ', + .vars = &.{ + .session_id, + .window_id, + .window_width, + .window_height, + .window_layout, + }, + }; + + /// The format string, available at comptime. + pub fn comptimeFormat(comptime self: Format) []const u8 { + return output.comptimeFormat(self.vars, self.delim); + } + + /// The struct that can contain the parsed output. + pub fn Struct(comptime self: Format) type { + return output.FormatStruct(self.vars); + } +}; + +test "immediate exit" { + var viewer: Viewer = .init; + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} + +test "initial flow" { + var viewer: Viewer = .init; + + // First we receive the initial block end + try testing.expect(viewer.next(.{ .block_end = "" }) == null); + + // Then we receive session-changed with the initial session + { + const action = viewer.next(.{ .session_changed = .{ + .id = 42, + .name = "main", + } }).?; + try testing.expect(action == .command); + try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + try testing.expectEqual(42, viewer.session_id); + } + + try testing.expectEqual(.exit, viewer.next(.exit).?); + try testing.expect(viewer.next(.exit) == null); +} diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 6e125e100..e25d635c9 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -368,7 +368,11 @@ pub const StreamHandler = struct { fn dcsCommand(self: *StreamHandler, cmd: *terminal.dcs.Command) !void { // log.warn("DCS command: {}", .{cmd}); switch (cmd.*) { - .tmux => |tmux| { + .tmux => |tmux| tmux: { + // If tmux control mode is disabled at the build level, + // then this whole block shouldn't be analyzed. + if (comptime !terminal.options.tmux_control_mode) break :tmux; + // TODO: process it log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); }, From 4c3ef8fa13d12d6b5bba8f9f3e78214187ca8e84 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 5 Dec 2025 15:21:26 -0800 Subject: [PATCH 03/28] terminal/tmux: viewer list windows state --- src/terminal/tmux/viewer.zig | 134 ++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 26 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7a84f9243..60666b2aa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -29,12 +30,17 @@ const log = std.log.scoped(.terminal_tmux_viewer); /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. pub const Viewer = struct { - state: State = .startup_block, + /// Allocator used for all internal state. + alloc: Allocator, - /// The current session ID we're attached to. The default value - /// is meaningless, because this has to be sent down during - /// the startup process. - session_id: usize = 0, + /// Current state of the state machine. + state: State, + + /// The current session ID we're attached to. + session_id: usize, + + /// The windows in the current session. + windows: std.ArrayList(Window), pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -48,8 +54,32 @@ pub const Viewer = struct { command: []const u8, }; - /// Initial state - pub const init: Viewer = .{}; + pub const Window = struct { + id: usize, + width: usize, + height: usize, + // TODO: more fields, obviously! + }; + + /// Initialize a new viewer. + /// + /// The given allocator is used for all internal state. You must + /// call deinit when you're done with the viewer to free it. + pub fn init(alloc: Allocator) Viewer { + return .{ + .alloc = alloc, + .state = .startup_block, + // The default value here is meaningless. We don't get started + // until we receive a session-changed notification which will + // set this to a real value. + .session_id = 0, + .windows = .empty, + }; + } + + pub fn deinit(self: *Viewer) void { + self.windows.deinit(self.alloc); + } /// Send in the next tmux notification we got from the control mode /// protocol. The return value is any action that needs to be taken @@ -80,10 +110,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -108,10 +135,7 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; @@ -134,19 +158,17 @@ pub const Viewer = struct { switch (n) { .enter => unreachable, - .exit => { - self.state = .defunct; - return .exit; - }, + .exit => return self.defunct(), - .block_end, + inline .block_end, .block_err, - => |content| switch (self.state) { + => |content, tag| switch (self.state) { .startup_block, .startup_session, .defunct => unreachable, + .list_windows => { - // TODO: parse the content - _ = content; - return null; + // Move to defunct on error blocks. + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch self.defunct(); }, }, @@ -155,6 +177,53 @@ pub const Viewer = struct { else => return null, } } + + fn receivedListWindows( + self: *Viewer, + content: []const u8, + ) !Action { + assert(self.state == .list_windows); + + // This stores our new window state from this list-windows output. + var windows: std.ArrayList(Window) = .empty; + errdefer windows.deinit(self.alloc); + + // Parse all our windows + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + const data = output.parseFormatStruct( + Format.list_windows.Struct(), + line, + Format.list_windows.delim, + ) catch |err| { + log.info("failed to parse list-windows line: {s}", .{line}); + return err; + }; + + try windows.append(self.alloc, .{ + .id = data.window_id, + .width = data.window_width, + .height = data.window_height, + }); + } + + // TODO: Diff our prior windows + + // Replace our window list + self.windows.deinit(self.alloc); + self.windows = windows; + + return .exit; + } + + fn defunct(self: *Viewer) Action { + self.state = .defunct; + // In the future we may want to deallocate a bunch of memory + // when we go defunct. + return .exit; + } }; const State = enum { @@ -208,13 +277,15 @@ const Format = struct { }; test "immediate exit" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); try testing.expectEqual(.exit, viewer.next(.exit).?); try testing.expect(viewer.next(.exit) == null); } test "initial flow" { - var viewer: Viewer = .init; + var viewer = Viewer.init(testing.allocator); + defer viewer.deinit(); // First we receive the initial block end try testing.expect(viewer.next(.{ .block_end = "" }) == null); @@ -228,6 +299,17 @@ test "initial flow" { try testing.expect(action == .command); try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); try testing.expectEqual(42, viewer.session_id); + // log.warn("{s}", .{action.command}); + } + + // Simulate our list-windows command + { + const action = viewer.next(.{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + }).?; + _ = action; } try testing.expectEqual(.exit, viewer.next(.exit).?); From c1d686534efc3db38db6d6dd29be86939f652073 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 13:20:54 -0800 Subject: [PATCH 04/28] terminal/tmux: list windows --- src/terminal/tmux/viewer.zig | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 60666b2aa..7ee97fa8c 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -52,6 +52,13 @@ pub const Viewer = struct { /// it is; just send it to tmux as-is. This will include the /// trailing newline so you can send it directly. command: []const u8, + + /// Windows changed. This may add, remove or change windows. The + /// caller is responsible for diffing the new window list against + /// the prior one. Remember that for a given Viewer, window IDs + /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) + /// never re-uses window IDs within a server process lifetime. + windows: []const Window, }; pub const Window = struct { @@ -141,7 +148,7 @@ pub const Viewer = struct { self.session_id = info.id; self.state = .list_windows; return .{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'", + "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }; }, @@ -209,13 +216,11 @@ pub const Viewer = struct { }); } - // TODO: Diff our prior windows - // Replace our window list self.windows.deinit(self.alloc); self.windows = windows; - return .exit; + return .{ .windows = self.windows.items }; } fn defunct(self: *Viewer) Action { @@ -309,7 +314,8 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , }).?; - _ = action; + try testing.expect(action == .windows); + try testing.expectEqual(1, action.windows.len); } try testing.expectEqual(.exit, viewer.next(.exit).?); From 3cbc232e31fd59f63a1eaea9df068c4d8df0153a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 07:15:53 -0800 Subject: [PATCH 05/28] terminal/tmux: return allocated list of actions --- src/terminal/tmux/viewer.zig | 197 +++++++++++++++++++++++++---------- 1 file changed, 142 insertions(+), 55 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7ee97fa8c..dc3fdbcfa 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const control = @import("control.zig"); @@ -42,6 +43,10 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The arena used for the prior action allocated state. This contains + /// the contents for the actions as well as the actions slice itself. + action_arena: ArenaAllocator.State, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -61,6 +66,11 @@ pub const Viewer = struct { windows: []const Window, }; + pub const Input = union(enum) { + /// Data from tmux was received that needs to be processed. + tmux: control.Notification, + }; + pub const Window = struct { id: usize, width: usize, @@ -81,32 +91,49 @@ pub const Viewer = struct { // set this to a real value. .session_id = 0, .windows = .empty, + .action_arena = .{}, }; } pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + self.action_arena.promote(self.alloc).deinit(); } - /// Send in the next tmux notification we got from the control mode - /// protocol. The return value is any action that needs to be taken - /// in reaction to this notification (could be none). - pub fn next(self: *Viewer, n: control.Notification) ?Action { - return switch (self.state) { - .startup_block => self.nextStartupBlock(n), - .startup_session => self.nextStartupSession(n), - .defunct => defunct: { - log.info("received notification in defunct state, ignoring", .{}); - break :defunct null; - }, - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + /// Send in an input event (such as a tmux protocol notification, + /// keyboard input for a pane, etc.) and process it. The returned + /// list is a set of actions to take as a result of the input prior + /// to the next input. This list may be empty. + pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + return switch (input) { + .tmux => try self.nextTmux(input.tmux), }; } - fn nextStartupBlock(self: *Viewer, n: control.Notification) ?Action { + fn nextTmux( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + return switch (self.state) { + .defunct => defunct: { + log.info("received notification in defunct state, ignoring", .{}); + break :defunct &.{}; + }, + + .startup_block => try self.nextStartupBlock(n), + .startup_session => try self.nextStartupSession(n), + .idle => try self.nextIdle(n), + + // Once we're in the main states, there's a bunch of shared + // logic so we centralize it. + .list_windows => try self.nextCommand(n), + }; + } + + fn nextStartupBlock( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_block); switch (n) { @@ -117,7 +144,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return self.defunct(), + .exit => return try self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -126,69 +153,88 @@ pub const Viewer = struct { // queue the notification, then do notificatins. .block_end, .block_err => { self.state = .startup_session; - return null; + return &.{}; }, // I don't like catch-all else branches but startup is such // a special case of looking for very specific things that // are unlikely to expand. - else => return null, + else => return &.{}, } } - fn nextStartupSession(self: *Viewer, n: control.Notification) ?Action { + fn nextStartupSession( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return self.defunct(), + .exit => return try self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return .{ .command = std.fmt.comptimePrint( + return try self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, - ) }; + ) }); }, - else => return null, + else => return &.{}, } } - fn nextCommand(self: *Viewer, n: control.Notification) ?Action { - assert(self.state != .startup_block); - assert(self.state != .startup_session); - assert(self.state != .defunct); + fn nextIdle( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + assert(self.state == .idle); switch (n) { .enter => unreachable, + .exit => return try self.defunct(), + else => return &.{}, + } + } - .exit => return self.defunct(), + fn nextCommand( + self: *Viewer, + n: control.Notification, + ) Allocator.Error![]const Action { + switch (n) { + .enter => unreachable, + + .exit => return try self.defunct(), inline .block_end, .block_err, => |content, tag| switch (self.state) { - .startup_block, .startup_session, .defunct => unreachable, + .startup_block, + .startup_session, + .idle, + .defunct, + => unreachable, .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch self.defunct(); + if (comptime tag == .block_err) return try self.defunct(); + return self.receivedListWindows(content) catch return try self.defunct(); }, }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return null, + else => return &.{}, } } fn receivedListWindows( self: *Viewer, content: []const u8, - ) !Action { + ) ![]const Action { assert(self.state == .list_windows); // This stores our new window state from this list-windows output. @@ -220,18 +266,46 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - return .{ .windows = self.windows.items }; + // Go into the idle state + self.state = .idle; + + // TODO: Diff with prior window state, dispatch capture-pane + // requests to collect all of the screen contents, other terminal + // state, etc. + + return try self.singleAction(.{ .windows = self.windows.items }); } - fn defunct(self: *Viewer) Action { + /// Helper to return a single action. The input action must not use + /// any allocated memory from `action_arena` since this will reset + /// the arena. + fn singleAction( + self: *Viewer, + action: Action, + ) Allocator.Error![]const Action { + // Make our actual arena + var arena = self.action_arena.promote(self.alloc); + + // Need to be careful to update our internal state after + // doing allocations since the arena takes a copy of the state. + defer self.action_arena = arena.state; + + // Free everything. We could retain some state here if we wanted + // but I don't think its worth it. + _ = arena.reset(.free_all); + + // Make our single action slice. + const alloc = arena.allocator(); + return try alloc.dupe(Action, &.{action}); + } + + fn defunct(self: *Viewer) Allocator.Error![]const Action { self.state = .defunct; - // In the future we may want to deallocate a bunch of memory - // when we go defunct. - return .exit; + return try self.singleAction(.exit); } }; -const State = enum { +const State = union(enum) { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -246,8 +320,13 @@ const State = enum { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. + /// We're waiting on a list-windows response from tmux. This will + /// be used to resynchronize our entire window state. list_windows, + + /// Idle state, we're not actually doing anything right now except + /// waiting for more events from tmux that may change our behavior. + idle, }; /// Format strings used for commands in our viewer. @@ -284,8 +363,11 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, actions.len); + try testing.expectEqual(.exit, actions[0]); + const actions2 = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, actions2.len); } test "initial flow" { @@ -293,31 +375,36 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - try testing.expect(viewer.next(.{ .block_end = "" }) == null); + const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const action = viewer.next(.{ .session_changed = .{ + const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", - } }).?; - try testing.expect(action == .command); - try testing.expect(std.mem.startsWith(u8, action.command, "list-windows")); + } } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .command); + try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); try testing.expectEqual(42, viewer.session_id); - // log.warn("{s}", .{action.command}); } // Simulate our list-windows command { - const action = viewer.next(.{ + const actions = try viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , - }).?; - try testing.expect(action == .windows); - try testing.expectEqual(1, action.windows.len); + } }); + try testing.expectEqual(1, actions.len); + try testing.expect(actions[0] == .windows); + try testing.expectEqual(1, actions[0].windows.len); } - try testing.expectEqual(.exit, viewer.next(.exit).?); - try testing.expect(viewer.next(.exit) == null); + const exit_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(1, exit_actions.len); + try testing.expectEqual(.exit, exit_actions[0]); + const final_actions = try viewer.next(.{ .tmux = .exit }); + try testing.expectEqual(0, final_actions.len); } From 52dbca3d26426937be2e13e2f216177d4e8b467b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:10:54 -0800 Subject: [PATCH 06/28] termio: hook up tmux viewer --- src/termio/stream_handler.zig | 77 +++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index e25d635c9..be5cb6418 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -70,6 +70,9 @@ pub const StreamHandler = struct { /// such as XTGETTCAP. dcs: terminal.dcs.Handler = .{}, + /// The tmux control mode viewer state. + tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -81,9 +84,18 @@ pub const StreamHandler = struct { pub const Stream = terminal.Stream(StreamHandler); + /// True if we have tmux control mode built in. + pub const tmux_enabled = terminal.options.tmux_control_mode; + pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + if (comptime tmux_enabled) tmux: { + const viewer = self.tmux_viewer orelse break :tmux; + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + } } /// This queues a render operation with the renderer thread. The render @@ -371,10 +383,69 @@ pub const StreamHandler = struct { .tmux => |tmux| tmux: { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. - if (comptime !terminal.options.tmux_control_mode) break :tmux; + if (comptime !tmux_enabled) break :tmux; + log.info("tmux control mode event cmd={}", .{tmux}); - // TODO: process it - log.warn("tmux control mode event unimplemented cmd={}", .{tmux}); + switch (tmux) { + .enter => { + // Setup our viewer state + assert(self.tmux_viewer == null); + const viewer = try self.alloc.create(terminal.tmux.Viewer); + errdefer self.alloc.destroy(viewer); + viewer.* = .init(self.alloc); + self.tmux_viewer = viewer; + break :tmux; + }, + + .exit => if (self.tmux_viewer) |viewer| { + // Free our viewer state + viewer.deinit(); + self.alloc.destroy(viewer); + self.tmux_viewer = null; + break :tmux; + }, + + else => {}, + } + + assert(tmux != .enter); + assert(tmux != .exit); + + const viewer = self.tmux_viewer orelse { + // This can only really happen if we failed to + // initialize the viewer on enter. + log.info( + "received tmux control mode command without viewer: {}", + .{tmux}, + ); + + break :tmux; + }; + + for (try viewer.next(.{ .tmux = tmux })) |action| { + log.info("tmux viewer action={}", .{action}); + switch (action) { + .exit => { + // We ignore this because we will fully exit when + // our DCS connection ends. We may want to handle + // this in the future to notify our GUI we're + // disconnected though. + }, + + .command => |command| { + assert(command.len > 0); + assert(command[command.len - 1] == '\n'); + self.messageWriter(try termio.Message.writeReq( + self.alloc, + command, + )); + }, + + .windows => { + // TODO + }, + } + } }, .xtgettcap => |*gettcap| { From b26c42f4a64d9fdb686c3678d08b4ce31b3f7fd6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 7 Dec 2025 14:28:00 -0800 Subject: [PATCH 07/28] terminal/tmux: better formatting for notifications and actions --- src/terminal/tmux/control.zig | 26 +++++++++++++++++++++++++- src/terminal/tmux/viewer.zig | 24 ++++++++++++++++++++++++ src/termio/stream_handler.zig | 6 +++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 3624173dd..79ed530ec 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -531,7 +531,31 @@ pub const Notification = union(enum) { 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; diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index dc3fdbcfa..32da1b4e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -64,6 +64,30 @@ pub const Viewer = struct { /// are guaranteed to be stable. Additionally, tmux (as of Dec 2025) /// never re-uses window IDs within a server process lifetime. windows: []const Window, + + pub fn format(self: Action, writer: *std.Io.Writer) !void { + const T = Action; + 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(" }"); + } + } }; pub const Input = union(enum) { diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index be5cb6418..8218315be 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -384,7 +384,7 @@ pub const StreamHandler = struct { // If tmux control mode is disabled at the build level, // then this whole block shouldn't be analyzed. if (comptime !tmux_enabled) break :tmux; - log.info("tmux control mode event cmd={}", .{tmux}); + log.info("tmux control mode event cmd={f}", .{tmux}); switch (tmux) { .enter => { @@ -415,7 +415,7 @@ pub const StreamHandler = struct { // This can only really happen if we failed to // initialize the viewer on enter. log.info( - "received tmux control mode command without viewer: {}", + "received tmux control mode command without viewer: {f}", .{tmux}, ); @@ -423,7 +423,7 @@ pub const StreamHandler = struct { }; for (try viewer.next(.{ .tmux = tmux })) |action| { - log.info("tmux viewer action={}", .{action}); + log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { // We ignore this because we will fully exit when From ec5a60a11993467f19ae99d5723304870f060cb8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:25:59 -0800 Subject: [PATCH 08/28] terminal/tmux: make sure we always have space for one action --- src/terminal/tmux/viewer.zig | 90 ++++++++++++++++------------------- src/termio/stream_handler.zig | 2 +- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 32da1b4e4..275f93d5e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -47,6 +47,11 @@ pub const Viewer = struct { /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, + /// A single action pre-allocated that we use for single-action + /// returns (common). This ensures that we can never get allocation + /// errors on single-action returns, especially those such as `.exit`. + action_single: [1]Action, + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -116,6 +121,7 @@ pub const Viewer = struct { .session_id = 0, .windows = .empty, .action_arena = .{}, + .action_single = undefined, }; } @@ -128,36 +134,39 @@ pub const Viewer = struct { /// keyboard input for a pane, etc.) and process it. The returned /// list is a set of actions to take as a result of the input prior /// to the next input. This list may be empty. - pub fn next(self: *Viewer, input: Input) Allocator.Error![]const Action { + pub fn next(self: *Viewer, input: Input) []const Action { + // Developer note: this function must never return an error. If + // an error occurs we must go into a defunct state or some other + // state to gracefully handle it. return switch (input) { - .tmux => try self.nextTmux(input.tmux), + .tmux => self.nextTmux(input.tmux), }; } fn nextTmux( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { return switch (self.state) { .defunct => defunct: { log.info("received notification in defunct state, ignoring", .{}); break :defunct &.{}; }, - .startup_block => try self.nextStartupBlock(n), - .startup_session => try self.nextStartupSession(n), - .idle => try self.nextIdle(n), + .startup_block => self.nextStartupBlock(n), + .startup_session => self.nextStartupSession(n), + .idle => self.nextIdle(n), // Once we're in the main states, there's a bunch of shared // logic so we centralize it. - .list_windows => try self.nextCommand(n), + .list_windows => self.nextCommand(n), }; } fn nextStartupBlock( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_block); switch (n) { @@ -168,7 +177,7 @@ pub const Viewer = struct { // I don't think this is technically possible (reading the // tmux source code), but if we see an exit we can semantically // handle this without issue. - .exit => return try self.defunct(), + .exit => return self.defunct(), // Any begin and end (even error) is fine! Now we wait for // session-changed to get the initial session ID. session-changed @@ -190,18 +199,18 @@ pub const Viewer = struct { fn nextStartupSession( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .startup_session); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), .session_changed => |info| { self.session_id = info.id; self.state = .list_windows; - return try self.singleAction(.{ .command = std.fmt.comptimePrint( + return self.singleAction(.{ .command = std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, ) }); @@ -214,12 +223,12 @@ pub const Viewer = struct { fn nextIdle( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { assert(self.state == .idle); switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), else => return &.{}, } } @@ -227,11 +236,11 @@ pub const Viewer = struct { fn nextCommand( self: *Viewer, n: control.Notification, - ) Allocator.Error![]const Action { + ) []const Action { switch (n) { .enter => unreachable, - .exit => return try self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, @@ -244,8 +253,8 @@ pub const Viewer = struct { .list_windows => { // Move to defunct on error blocks. - if (comptime tag == .block_err) return try self.defunct(); - return self.receivedListWindows(content) catch return try self.defunct(); + if (comptime tag == .block_err) return self.defunct(); + return self.receivedListWindows(content) catch return self.defunct(); }, }, @@ -297,35 +306,20 @@ pub const Viewer = struct { // requests to collect all of the screen contents, other terminal // state, etc. - return try self.singleAction(.{ .windows = self.windows.items }); + return self.singleAction(.{ .windows = self.windows.items }); } - /// Helper to return a single action. The input action must not use - /// any allocated memory from `action_arena` since this will reset - /// the arena. - fn singleAction( - self: *Viewer, - action: Action, - ) Allocator.Error![]const Action { - // Make our actual arena - var arena = self.action_arena.promote(self.alloc); - - // Need to be careful to update our internal state after - // doing allocations since the arena takes a copy of the state. - defer self.action_arena = arena.state; - - // Free everything. We could retain some state here if we wanted - // but I don't think its worth it. - _ = arena.reset(.free_all); - + /// Helper to return a single action. The input action may use the arena + /// for allocated memory; this will not touch the arena. + fn singleAction(self: *Viewer, action: Action) []const Action { // Make our single action slice. - const alloc = arena.allocator(); - return try alloc.dupe(Action, &.{action}); + self.action_single[0] = action; + return &self.action_single; } - fn defunct(self: *Viewer) Allocator.Error![]const Action { + fn defunct(self: *Viewer) []const Action { self.state = .defunct; - return try self.singleAction(.exit); + return self.singleAction(.exit); } }; @@ -387,10 +381,10 @@ const Format = struct { test "immediate exit" { var viewer = Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = try viewer.next(.{ .tmux = .exit }); + const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); try testing.expectEqual(.exit, actions[0]); - const actions2 = try viewer.next(.{ .tmux = .exit }); + const actions2 = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, actions2.len); } @@ -399,12 +393,12 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = try viewer.next(.{ .tmux = .{ .block_end = "" } }); + const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); try testing.expectEqual(0, actions0.len); // Then we receive session-changed with the initial session { - const actions = try viewer.next(.{ .tmux = .{ .session_changed = .{ + const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ .id = 42, .name = "main", } } }); @@ -416,7 +410,7 @@ test "initial flow" { // Simulate our list-windows command { - const actions = try viewer.next(.{ .tmux = .{ + const actions = viewer.next(.{ .tmux = .{ .block_end = \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , @@ -426,9 +420,9 @@ test "initial flow" { try testing.expectEqual(1, actions[0].windows.len); } - const exit_actions = try viewer.next(.{ .tmux = .exit }); + const exit_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, exit_actions.len); try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = try viewer.next(.{ .tmux = .exit }); + const final_actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(0, final_actions.len); } diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 8218315be..ba207ce7b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -422,7 +422,7 @@ pub const StreamHandler = struct { break :tmux; }; - for (try viewer.next(.{ .tmux = tmux })) |action| { + for (viewer.next(.{ .tmux = tmux })) |action| { log.info("tmux viewer action={f}", .{action}); switch (action) { .exit => { From 86cd4897012758a59d8068b796b449ff7ff37f16 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 07:09:11 -0800 Subject: [PATCH 09/28] terminal/tmux: introduce command queue for viewer --- src/terminal/tmux/viewer.zig | 196 ++++++++++++++++++++++++++-------- src/termio/stream_handler.zig | 3 +- 2 files changed, 156 insertions(+), 43 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 275f93d5e..384ad609b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const CircBuf = @import("../../datastruct/main.zig").CircBuf; const control = @import("control.zig"); const output = @import("output.zig"); @@ -19,6 +20,12 @@ const log = std.log.scoped(.terminal_tmux_viewer); // in case something breaks in the future we can consider it. We should // be able to easily unit test all variations seen in the real world. +/// The initial capacity of the command queue. We dynamically resize +/// as necessary so the initial value isn't that important, but if we +/// want to feel good about it we should make it large enough to support +/// our most realistic use cases without resizing. +const COMMAND_QUEUE_INITIAL = 8; + /// A viewer is a tmux control mode client that attempts to create /// a remote view of a tmux session, including providing the ability to send /// new input to the session. @@ -40,6 +47,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The list of commands we've sent that we want to send and wait + /// for a response for. We only send one command at a time just + /// to avoid any possible confusion around ordering. + command_queue: CommandQueue, + /// The windows in the current session. windows: std.ArrayList(Window), @@ -52,6 +64,8 @@ pub const Viewer = struct { /// errors on single-action returns, especially those such as `.exit`. action_single: [1]Action, + pub const CommandQueue = CircBuf(Command, undefined); + pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end /// our viewer session in some way. @@ -111,7 +125,11 @@ pub const Viewer = struct { /// /// The given allocator is used for all internal state. You must /// call deinit when you're done with the viewer to free it. - pub fn init(alloc: Allocator) Viewer { + pub fn init(alloc: Allocator) Allocator.Error!Viewer { + // Create our initial command queue + var command_queue: CommandQueue = try .init(alloc, COMMAND_QUEUE_INITIAL); + errdefer command_queue.deinit(alloc); + return .{ .alloc = alloc, .state = .startup_block, @@ -119,6 +137,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .command_queue = command_queue, .windows = .empty, .action_arena = .{}, .action_single = undefined, @@ -127,6 +146,11 @@ pub const Viewer = struct { pub fn deinit(self: *Viewer) void { self.windows.deinit(self.alloc); + { + var it = self.command_queue.iterator(.forward); + while (it.next()) |command| command.deinit(self.alloc); + self.command_queue.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -155,11 +179,7 @@ pub const Viewer = struct { .startup_block => self.nextStartupBlock(n), .startup_session => self.nextStartupSession(n), - .idle => self.nextIdle(n), - - // Once we're in the main states, there's a bunch of shared - // logic so we centralize it. - .list_windows => self.nextCommand(n), + .command_queue => self.nextCommand(n), }; } @@ -209,11 +229,11 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .list_windows; - return self.singleAction(.{ .command = std.fmt.comptimePrint( - "list-windows -F '{s}'\n", - .{comptime Format.list_windows.comptimeFormat()}, - ) }); + self.state = .command_queue; + return self.singleAction(self.queueCommand(.list_windows) catch { + log.warn("failed to queue command, becoming defunct", .{}); + return self.defunct(); + }); }, else => return &.{}, @@ -237,39 +257,85 @@ pub const Viewer = struct { self: *Viewer, n: control.Notification, ) []const Action { - switch (n) { - .enter => unreachable, + // We have to be in a command queue, but the command queue MAY + // be empty. If it is empty, then receivedCommandOutput will + // handle it by ignoring any command output. That's okay! + assert(self.state == .command_queue); - .exit => return self.defunct(), + return switch (n) { + .enter => unreachable, + .exit => self.defunct(), inline .block_end, .block_err, - => |content, tag| switch (self.state) { - .startup_block, - .startup_session, - .idle, - .defunct, - => unreachable, - - .list_windows => { - // Move to defunct on error blocks. - if (comptime tag == .block_err) return self.defunct(); - return self.receivedListWindows(content) catch return self.defunct(); - }, + => |content, tag| self.receivedCommandOutput( + content, + tag == .block_err, + ) catch err: { + log.warn("failed to process command output, becoming defunct", .{}); + break :err self.defunct(); }, // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. - else => return &.{}, + else => &.{}, + }; + } + + fn receivedCommandOutput( + self: *Viewer, + content: []const u8, + is_err: bool, + ) ![]const Action { + // If we have no pending commands, this is unexpected. + const command = self.command_queue.first() orelse { + log.info("unexpected block output err={}", .{is_err}); + return &.{}; + }; + self.command_queue.deleteOldest(1); + + // We always free any memory associated with the command + defer command.deinit(self.alloc); + + // We'll use our arena for the return value here so we can + // easily accumulate actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Build up our actions to start with the next command if + // we have one. + var actions: std.ArrayList(Action) = .empty; + if (self.command_queue.first()) |next_command| { + try actions.append( + arena_alloc, + .{ .command = next_command.string() }, + ); } + + // Process our command + switch (command.*) { + .user => {}, + .list_windows => try self.receivedListWindows( + arena_alloc, + &actions, + content, + ), + } + + // Our command processing should not change our state + assert(self.state == .command_queue); + + return actions.items; } fn receivedListWindows( self: *Viewer, + arena_alloc: Allocator, + actions: *std.ArrayList(Action), content: []const u8, - ) ![]const Action { - assert(self.state == .list_windows); - + ) !void { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -299,14 +365,27 @@ pub const Viewer = struct { self.windows.deinit(self.alloc); self.windows = windows; - // Go into the idle state - self.state = .idle; - // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. - return self.singleAction(.{ .windows = self.windows.items }); + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + } + + /// This queues the command at the end of the command queue + /// and returns an action representing the next command that + /// should be run (the head). + /// + /// The next command is not removed, because the expectation is + /// that the head of our command list is always sent to tmux. + fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + // Add our command + try self.command_queue.ensureUnusedCapacity(self.alloc, 1); + self.command_queue.appendAssumeCapacity(command); + + // Get our first command to send, guaranteed to exist since we + // just appended one. + return .{ .command = self.command_queue.first().?.string() }; } /// Helper to return a single action. The input action may use the arena @@ -323,7 +402,7 @@ pub const Viewer = struct { } }; -const State = union(enum) { +const State = enum { /// We start in this state just after receiving the initial /// DCS 1000p opening sequence. We wait for an initial /// begin/end block that is guaranteed to be sent by tmux for @@ -338,13 +417,46 @@ const State = union(enum) { /// Tmux has closed the control mode connection defunct, - /// We're waiting on a list-windows response from tmux. This will - /// be used to resynchronize our entire window state. + /// We're sitting on the command queue waiting for command output + /// in the order provided in the `command_queue` field. This field + /// isn't part of the state because it can be queued at any state. + /// + /// Precondition: if self.command_queue.len > 0, then the first + /// command in the queue has already been sent to tmux (via a + /// `command` Action). The next output is assumed to be the result + /// of this command. + /// + /// To satisfy the above, any transitions INTO this state should + /// send a command Action for the first command in the queue. + command_queue, +}; + +const Command = union(enum) { + /// List all windows so we can sync our window state. list_windows, - /// Idle state, we're not actually doing anything right now except - /// waiting for more events from tmux that may change our behavior. - idle, + /// User command. This is a command provided by the user. Since + /// this is user provided, we can't be sure what it is. + user: []const u8, + + pub fn deinit(self: Command, alloc: Allocator) void { + return switch (self) { + .list_windows => {}, + .user => |v| alloc.free(v), + }; + } + + /// Returns the command to execute. The memory of the return + /// value is always safe as long as this command value is alive. + pub fn string(self: Command) []const u8 { + return switch (self) { + .list_windows => std.fmt.comptimePrint( + "list-windows -F '{s}'\n", + .{comptime Format.list_windows.comptimeFormat()}, + ), + .user => |v| v, + }; + } }; /// Format strings used for commands in our viewer. @@ -379,7 +491,7 @@ const Format = struct { }; test "immediate exit" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); const actions = viewer.next(.{ .tmux = .exit }); try testing.expectEqual(1, actions.len); @@ -389,7 +501,7 @@ test "immediate exit" { } test "initial flow" { - var viewer = Viewer.init(testing.allocator); + var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); // First we receive the initial block end diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index ba207ce7b..eabfd6a4b 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -392,7 +392,8 @@ pub const StreamHandler = struct { assert(self.tmux_viewer == null); const viewer = try self.alloc.create(terminal.tmux.Viewer); errdefer self.alloc.destroy(viewer); - viewer.* = .init(self.alloc); + viewer.* = try .init(self.alloc); + errdefer viewer.deinit(); self.tmux_viewer = viewer; break :tmux; }, From ea09d257a1cd27b66236de474a2e26b05e843631 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 10:45:28 -0800 Subject: [PATCH 10/28] terminal/tmux: initialize panes --- src/terminal/tmux/viewer.zig | 143 ++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 384ad609b..7b0307a8f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Terminal = @import("../Terminal.zig"); +const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); const output = @import("output.zig"); @@ -55,6 +57,9 @@ pub const Viewer = struct { /// The windows in the current session. windows: std.ArrayList(Window), + /// The panes in the current session, mapped by pane ID. + panes: PanesMap, + /// The arena used for the prior action allocated state. This contains /// the contents for the actions as well as the actions slice itself. action_arena: ArenaAllocator.State, @@ -65,6 +70,7 @@ pub const Viewer = struct { action_single: [1]Action, pub const CommandQueue = CircBuf(Command, undefined); + pub const PanesMap = std.AutoArrayHashMapUnmanaged(usize, Pane); pub const Action = union(enum) { /// Tmux has closed the control mode connection, we should end @@ -118,7 +124,20 @@ pub const Viewer = struct { id: usize, width: usize, height: usize, - // TODO: more fields, obviously! + layout_arena: ArenaAllocator.State, + layout: Layout, + + pub fn deinit(self: *Window, alloc: Allocator) void { + self.layout_arena.promote(alloc).deinit(); + } + }; + + pub const Pane = struct { + terminal: Terminal, + + pub fn deinit(self: *Pane, alloc: Allocator) void { + self.terminal.deinit(alloc); + } }; /// Initialize a new viewer. @@ -139,18 +158,27 @@ pub const Viewer = struct { .session_id = 0, .command_queue = command_queue, .windows = .empty, + .panes = .empty, .action_arena = .{}, .action_single = undefined, }; } pub fn deinit(self: *Viewer) void { - self.windows.deinit(self.alloc); + { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.deinit(self.alloc); + } { var it = self.command_queue.iterator(.forward); while (it.next()) |command| command.deinit(self.alloc); self.command_queue.deinit(self.alloc); } + { + var it = self.panes.iterator(); + while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + } self.action_arena.promote(self.alloc).deinit(); } @@ -354,22 +382,131 @@ pub const Viewer = struct { return err; }; + // Parse the layout + var arena: ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const window_alloc = arena.allocator(); + const layout: Layout = Layout.parseWithChecksum( + window_alloc, + data.window_layout, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ data.window_id, data.window_layout }, + ); + return err; + }; + try windows.append(self.alloc, .{ .id = data.window_id, .width = data.window_width, .height = data.window_height, + .layout_arena = arena.state, + .layout = layout, }); } + // Setup our windows action so the caller can process GUI + // window changes. + try actions.append(arena_alloc, .{ .windows = windows.items }); + + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + panes.deinit(self.alloc); + } + for (windows.items) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + arena_alloc, + actions, + window.layout, + ); + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + // Replace our window list + for (self.windows.items) |*window| window.deinit(self.alloc); self.windows.deinit(self.alloc); self.windows = windows; + // Replace our panes + { + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + self.panes.deinit(self.alloc); + self.panes = panes; + } + // TODO: Diff with prior window state, dispatch capture-pane // requests to collect all of the screen contents, other terminal // state, etc. + } - try actions.append(arena_alloc, .{ .windows = self.windows.items }); + fn initLayout( + gpa_alloc: Allocator, + panes_old: *PanesMap, + panes_new: *PanesMap, + actions_alloc: Allocator, + actions: *std.ArrayList(Action), + layout: Layout, + ) !void { + switch (layout.content) { + // Nested layouts, continue going. + .horizontal, .vertical => |layouts| { + for (layouts) |l| { + try initLayout( + gpa_alloc, + panes_old, + panes_new, + actions_alloc, + actions, + l, + ); + } + }, + + // A leaf! Initialize. + .pane => |id| pane: { + const gop = try panes_new.getOrPut(gpa_alloc, id); + if (gop.found_existing) { + // We already have the pane setup. It should not exist + // in the old map because we remove that when we set + // it up. + assert(!panes_old.contains(id)); + break :pane; + } + errdefer _ = panes_new.swapRemove(gop.key_ptr.*); + + // We don't have it in our new map. If it exists in our old + // map then we copy it over and we're done. + if (panes_old.fetchSwapRemove(id)) |entry| { + gop.value_ptr.* = entry.value; + break :pane; + } + + // TODO: We need to gracefully handle overflow of our + // max cols/width here. In practice we shouldn't hit this + // so we cast but its not safe. + var t: Terminal = try .init(gpa_alloc, .{ + .cols = @intCast(layout.width), + .rows = @intCast(layout.height), + }); + errdefer t.deinit(gpa_alloc); + + gop.value_ptr.* = .{ + .terminal = t, + }; + }, + } } /// This queues the command at the end of the command queue From 766c306e0437a2f301c11cd9d8e7c1dcf969383b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 8 Dec 2025 19:45:46 -0800 Subject: [PATCH 11/28] terminal/tmux: pane history --- src/terminal/tmux/viewer.zig | 79 +++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 7b0307a8f..1c5007625 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,14 +315,27 @@ pub const Viewer = struct { content: []const u8, is_err: bool, ) ![]const Action { - // If we have no pending commands, this is unexpected. - const command = self.command_queue.first() orelse { + // Get the command we're expecting output for. We need to get the + // non-pointer value because we are deleting it from the circular + // buffer immediately. This shallow copy is all we need since + // all the memory in Command is owned by GPA. + const command: Command = if (self.command_queue.first()) |ptr| switch (ptr.*) { + // I truly can't explain this. A simple `ptr.*` copy will cause + // our memory to become undefined when deleteOldest is called + // below. I logged all the pointers and they don't match so I + // don't know how its being set to undefined. But a copy like + // this does work. + inline else => |v, tag| @unionInit( + Command, + @tagName(tag), + v, + ), + } else { + // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); return &.{}; }; self.command_queue.deleteOldest(1); - - // We always free any memory associated with the command defer command.deinit(self.alloc); // We'll use our arena for the return value here so we can @@ -336,20 +349,25 @@ pub const Viewer = struct { // we have one. var actions: std.ArrayList(Action) = .empty; if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); try actions.append( arena_alloc, - .{ .command = next_command.string() }, + .{ .command = builder.writer.buffered() }, ); } // Process our command - switch (command.*) { + switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), + .pane_history => { + // TODO + }, } // Our command processing should not change our state @@ -515,6 +533,10 @@ pub const Viewer = struct { /// /// The next command is not removed, because the expectation is /// that the head of our command list is always sent to tmux. + /// + /// Note: this modifies the `action_arena` since this will put + /// the command string into the arena. It does not clear the arena + /// so any previously allocated values remain valid. fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); @@ -522,7 +544,13 @@ pub const Viewer = struct { // Get our first command to send, guaranteed to exist since we // just appended one. - return .{ .command = self.command_queue.first().?.string() }; + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const next_command = self.command_queue.first().?; + next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; + return .{ .command = builder.writer.buffered() }; } /// Helper to return a single action. The input action may use the arena @@ -572,27 +600,48 @@ const Command = union(enum) { /// List all windows so we can sync our window state. list_windows, + /// Capture history for the given pane ID. + pane_history: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { - .list_windows => {}, + .list_windows, + .pane_history, + => {}, .user => |v| alloc.free(v), }; } - /// Returns the command to execute. The memory of the return - /// value is always safe as long as this command value is alive. - pub fn string(self: Command) []const u8 { - return switch (self) { - .list_windows => std.fmt.comptimePrint( + /// Format the command into the command that should be executed + /// by tmux. Trailing newlines are appended so this can be sent as-is + /// to tmux. + pub fn formatCommand( + self: Command, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + switch (self) { + .list_windows => try writer.writeAll(std.fmt.comptimePrint( "list-windows -F '{s}'\n", .{comptime Format.list_windows.comptimeFormat()}, + )), + + .pane_history => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -S - = start at the top of history ("-") + // -E -1 = end at the last line of history (1 before the + // visible area is -1). + // -t %{d} = target a specific pane ID + "capture-pane -p -e -S - -E -1 -t %{d}", + .{id}, ), - .user => |v| v, - }; + + .user => |v| try writer.writeAll(v), + } } }; From f02a2d5eed7cf59f2eed24cd5b16f225129d9d32 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 07:29:59 -0800 Subject: [PATCH 12/28] terminal/tmux: capture pane --- src/terminal/tmux/viewer.zig | 164 +++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 54 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 1c5007625..82aed6c2a 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -257,11 +257,17 @@ pub const Viewer = struct { .session_changed => |info| { self.session_id = info.id; - self.state = .command_queue; - return self.singleAction(self.queueCommand(.list_windows) catch { + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + return self.enterCommandQueue( + arena.allocator(), + .list_windows, + ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); - }); + }; }, else => return &.{}, @@ -348,14 +354,6 @@ pub const Viewer = struct { // Build up our actions to start with the next command if // we have one. var actions: std.ArrayList(Action) = .empty; - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } // Process our command switch (command) { @@ -370,6 +368,18 @@ pub const Viewer = struct { }, } + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. + if (self.command_queue.first()) |next_command| { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + try next_command.formatCommand(&builder.writer); + try actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ); + } + // Our command processing should not change our state assert(self.state == .command_queue); @@ -382,6 +392,9 @@ pub const Viewer = struct { actions: *std.ArrayList(Action), content: []const u8, ) !void { + // If there is an error, reset our actions to what it was before. + errdefer actions.shrinkRetainingCapacity(actions.items.len); + // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; errdefer windows.deinit(self.alloc); @@ -433,19 +446,50 @@ pub const Viewer = struct { // list. var panes: PanesMap = .empty; errdefer { + // Clear out all the new panes. var panes_it = panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } panes.deinit(self.alloc); } for (windows.items) |window| try initLayout( self.alloc, &self.panes, &panes, - arena_alloc, - actions, window.layout, ); + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = pane_id }, + }); + } + } + // No more errors after this point. We're about to replace all // our owned state with our temporary state, and our errdefers // above will double-free if there is an error. @@ -458,8 +502,15 @@ pub const Viewer = struct { // Replace our panes { - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| kv.value_ptr.deinit(self.alloc); + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. self.panes.deinit(self.alloc); self.panes = panes; } @@ -471,10 +522,8 @@ pub const Viewer = struct { fn initLayout( gpa_alloc: Allocator, - panes_old: *PanesMap, + panes_old: *const PanesMap, panes_new: *PanesMap, - actions_alloc: Allocator, - actions: *std.ArrayList(Action), layout: Layout, ) !void { switch (layout.content) { @@ -485,8 +534,6 @@ pub const Viewer = struct { gpa_alloc, panes_old, panes_new, - actions_alloc, - actions, l, ); } @@ -495,19 +542,13 @@ pub const Viewer = struct { // A leaf! Initialize. .pane => |id| pane: { const gop = try panes_new.getOrPut(gpa_alloc, id); - if (gop.found_existing) { - // We already have the pane setup. It should not exist - // in the old map because we remove that when we set - // it up. - assert(!panes_old.contains(id)); - break :pane; - } + if (gop.found_existing) break :pane; errdefer _ = panes_new.swapRemove(gop.key_ptr.*); - // We don't have it in our new map. If it exists in our old - // map then we copy it over and we're done. - if (panes_old.fetchSwapRemove(id)) |entry| { - gop.value_ptr.* = entry.value; + // If we already have this pane, it is already initialized + // so just copy it over. + if (panes_old.getEntry(id)) |entry| { + gop.value_ptr.* = entry.value_ptr.*; break :pane; } @@ -527,30 +568,45 @@ pub const Viewer = struct { } } - /// This queues the command at the end of the command queue - /// and returns an action representing the next command that - /// should be run (the head). - /// - /// The next command is not removed, because the expectation is - /// that the head of our command list is always sent to tmux. - /// - /// Note: this modifies the `action_arena` since this will put - /// the command string into the arena. It does not clear the arena - /// so any previously allocated values remain valid. - fn queueCommand(self: *Viewer, command: Command) Allocator.Error!Action { + /// Enters the command queue state from any other state, queueing + /// the command and returning an action to execute the first command. + fn enterCommandQueue( + self: *Viewer, + arena_alloc: Allocator, + command: Command, + ) Allocator.Error![]const Action { + assert(self.state != .command_queue); + + // Build our command string to send for the action. + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + // Add our command try self.command_queue.ensureUnusedCapacity(self.alloc, 1); self.command_queue.appendAssumeCapacity(command); - // Get our first command to send, guaranteed to exist since we - // just appended one. - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - const arena_alloc = arena.allocator(); - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const next_command = self.command_queue.first().?; - next_command.formatCommand(&builder.writer) catch return error.OutOfMemory; - return .{ .command = builder.writer.buffered() }; + // Move into the command queue state + self.state = .command_queue; + + return self.singleAction(action); + } + + /// Queue multiple commands to execute. This doesn't add anything + /// to the actions queue or return actions or anything because the + /// command_queue state will automatically send the next command when + /// it receives output. + fn queueCommands( + self: *Viewer, + commands: []const Command, + ) Allocator.Error!void { + try self.command_queue.ensureUnusedCapacity( + self.alloc, + commands.len, + ); + for (commands) |command| { + self.command_queue.appendAssumeCapacity(command); + } } /// Helper to return a single action. The input action may use the arena @@ -636,7 +692,7 @@ const Command = union(enum) { // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}", + "capture-pane -p -e -S - -E -1 -t %{d}\n", .{id}, ), @@ -713,7 +769,7 @@ test "initial flow" { \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] , } }); - try testing.expectEqual(1, actions.len); + try testing.expect(actions.len > 0); try testing.expect(actions[0] == .windows); try testing.expectEqual(1, actions[0].windows.len); } From e1e2791fb72d27c0383140dcc2bf2a10021bec45 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 09:48:17 -0800 Subject: [PATCH 13/28] terminal/tmux: pane_history replays it into terminal --- src/terminal/tmux/viewer.zig | 40 ++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 82aed6c2a..e9d318e7f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -358,14 +358,17 @@ pub const Viewer = struct { // Process our command switch (command) { .user => {}, + .list_windows => try self.receivedListWindows( arena_alloc, &actions, content, ), - .pane_history => { - // TODO - }, + + .pane_history => |id| try self.receivedPaneHistory( + id, + content, + ), } // After processing commands, we add our next command to @@ -514,10 +517,29 @@ pub const Viewer = struct { self.panes.deinit(self.alloc); self.panes = panes; } + } - // TODO: Diff with prior window state, dispatch capture-pane - // requests to collect all of the screen contents, other terminal - // state, etc. + fn receivedPaneHistory( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane history for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Get a VT stream from the terminal so we can send data as-is into + // it. This will populate the active area too so it won't be exactly + // correct but we'll get the active contents soon. + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane history for pane id={}: {}", .{ id, err }); + return err; + }; } fn initLayout( @@ -747,8 +769,10 @@ test "initial flow" { defer viewer.deinit(); // First we receive the initial block end - const actions0 = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions0.len); + { + const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); + try testing.expectEqual(0, actions.len); + } // Then we receive session-changed with the initial session { From 41bf54100524858f59a9cc2e63a3e86eafd8fa1b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 10:17:03 -0800 Subject: [PATCH 14/28] terminal/tmux: test helper --- src/terminal/tmux/viewer.zig | 178 +++++++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 40 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index e9d318e7f..8d3194748 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -754,53 +754,151 @@ const Format = struct { } }; +const TestStep = struct { + input: Viewer.Input, + contains_tags: []const std.meta.Tag(Viewer.Action) = &.{}, + contains_command: []const u8 = "", + check: ?*const fn (viewer: *Viewer, []const Viewer.Action) anyerror!void = null, + check_command: ?*const fn (viewer: *Viewer, []const u8) anyerror!void = null, + + fn run(self: TestStep, viewer: *Viewer) !void { + const actions = viewer.next(self.input); + + // Common mistake, forgetting the newline on a command. + for (actions) |action| { + if (action == .command) { + try testing.expect(std.mem.endsWith(u8, action.command, "\n")); + } + } + + for (self.contains_tags) |tag| { + var found = false; + for (actions) |action| { + if (action == tag) { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.contains_command.len > 0) { + var found = false; + for (actions) |action| { + if (action == .command and + std.mem.startsWith(u8, action.command, self.contains_command)) + { + found = true; + break; + } + } + try testing.expect(found); + } + + if (self.check) |check_fn| { + try check_fn(viewer, actions); + } + + if (self.check_command) |check_fn| { + var found = false; + for (actions) |action| { + if (action == .command) { + found = true; + try check_fn(viewer, action.command); + } + } + try testing.expect(found); + } + } +}; + +/// A helper to run a series of test steps against a viewer and assert +/// that the expected actions are produced. +/// +/// I'm generally not a fan of these types of abstracted tests because +/// it makes diagnosing failures harder, but being able to construct +/// simulated tmux inputs and verify outputs is going to be extremely +/// important since the tmux control mode protocol is very complex and +/// fragile. +fn testViewer(viewer: *Viewer, steps: []const TestStep) !void { + for (steps, 0..) |step, i| { + step.run(viewer) catch |err| { + log.warn("testViewer step failed i={} step={}", .{ i, step }); + return err; + }; + } +} + test "immediate exit" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - const actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, actions.len); - try testing.expectEqual(.exit, actions[0]); - const actions2 = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, actions2.len); + + try testViewer(&viewer, &.{ + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + .{ + .input = .{ .tmux = .exit }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, + }); } test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); - // First we receive the initial block end - { - const actions = viewer.next(.{ .tmux = .{ .block_end = "" } }); - try testing.expectEqual(0, actions.len); - } - - // Then we receive session-changed with the initial session - { - const actions = viewer.next(.{ .tmux = .{ .session_changed = .{ - .id = 42, - .name = "main", - } } }); - try testing.expectEqual(1, actions.len); - try testing.expect(actions[0] == .command); - try testing.expect(std.mem.startsWith(u8, actions[0].command, "list-windows")); - try testing.expectEqual(42, viewer.session_id); - } - - // Simulate our list-windows command - { - const actions = viewer.next(.{ .tmux = .{ - .block_end = - \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] - , - } }); - try testing.expect(actions.len > 0); - try testing.expect(actions[0] == .windows); - try testing.expectEqual(1, actions[0].windows.len); - } - - const exit_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(1, exit_actions.len); - try testing.expectEqual(.exit, exit_actions[0]); - const final_actions = viewer.next(.{ .tmux = .exit }); - try testing.expectEqual(0, final_actions.len); + try testViewer(&viewer, &.{ + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 42, + .name = "main", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(42, v.session_id); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ + .block_end = + \\Hello, world! + \\ + , + } }, + // Moves on to the next pane + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); } From b7fe9a926da6e479ccd3d06fd13c49f4f68c07a7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:19:47 -0800 Subject: [PATCH 15/28] terminal/tmux: capture visible area after history --- src/terminal/tmux/viewer.zig | 66 +++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 8d3194748..28a2aaf1e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -369,6 +369,11 @@ pub const Viewer = struct { id, content, ), + + .pane_visible => |id| try self.receivedPaneVisible( + id, + content, + ), } // After processing commands, we add our next command to @@ -489,6 +494,7 @@ pub const Viewer = struct { if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ .{ .pane_history = pane_id }, + .{ .pane_visible = pane_id }, }); } } @@ -542,6 +548,31 @@ pub const Viewer = struct { }; } + fn receivedPaneVisible( + self: *Viewer, + id: usize, + content: []const u8, + ) !void { + // Get our pane + const entry = self.panes.getEntry(id) orelse { + log.info("received pane visible for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + + // Erase the active area and reset the cursor to the top-left + // before writing the visible content. + pane.terminal.eraseDisplay(.complete, false); + pane.terminal.setCursorPos(1, 1); + + var stream = pane.terminal.vtStream(); + defer stream.deinit(); + stream.nextSlice(content) catch |err| { + log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -681,6 +712,9 @@ const Command = union(enum) { /// Capture history for the given pane ID. pane_history: usize, + /// Capture visible area for the given pane ID. + pane_visible: usize, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -689,6 +723,7 @@ const Command = union(enum) { return switch (self) { .list_windows, .pane_history, + .pane_visible, => {}, .user => |v| alloc.free(v), }; @@ -718,6 +753,15 @@ const Command = union(enum) { .{id}, ), + .pane_visible => |id| try writer.print( + // -p = output to stdout instead of buffer + // -e = output escape sequences for SGR + // -t %{d} = target a specific pane ID + // (no -S/-E = capture visible area only) + "capture-pane -p -e -t %{d}\n", + .{id}, + ), + .user => |v| try writer.writeAll(v), } } @@ -888,7 +932,27 @@ test "initial flow" { \\ , } }, - // Moves on to the next pane + // Moves on to pane_visible for pane 0 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { From a3e01581bea9907c3d03d180c1eb57850b9d89c5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 11:29:27 -0800 Subject: [PATCH 16/28] terminal/tmux: history capture clears active area --- src/terminal/tmux/viewer.zig | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 28a2aaf1e..aa9c91a03 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -4,6 +4,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Screen = @import("../Screen.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -536,16 +537,34 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into // it. This will populate the active area too so it won't be exactly // correct but we'll get the active contents soon. - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane history for pane id={}: {}", .{ id, err }); return err; }; + + // Populate the active area to be empty since this is only history. + // We'll fill it with blanks and move the cursor to the top-left. + t.carriageReturn(); + for (0..t.rows) |_| try t.index(); + t.setCursorPos(1, 1); + + // Our active area should be empty + if (comptime std.debug.runtime_safety) { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + screen.dumpString(&discarding.writer, .{ + .tl = screen.pages.getTopLeft(.active), + .unwrap = false, + }) catch unreachable; + assert(discarding.count == 0); + } } fn receivedPaneVisible( @@ -929,7 +948,6 @@ test "initial flow" { .input = .{ .tmux = .{ .block_end = \\Hello, world! - \\ , } }, // Moves on to pane_visible for pane 0 @@ -939,6 +957,28 @@ test "initial flow" { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); } }).check, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("Hello, world!", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + } + }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, From 50ac848672d3752e67af125101f9bccd75748f8b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 12:53:18 -0800 Subject: [PATCH 17/28] terminal/tmux: capture both primary/alt screen --- src/terminal/tmux/viewer.zig | 116 ++++++++++++++++++++++++++++------- 1 file changed, 95 insertions(+), 21 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index aa9c91a03..5df5b83bb 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -5,6 +5,7 @@ const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; const CircBuf = @import("../../datastruct/main.zig").CircBuf; const Screen = @import("../Screen.zig"); +const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); const Layout = @import("layout.zig").Layout; const control = @import("control.zig"); @@ -366,13 +367,15 @@ pub const Viewer = struct { content, ), - .pane_history => |id| try self.receivedPaneHistory( - id, + .pane_history => |cap| try self.receivedPaneHistory( + cap.screen_key, + cap.id, content, ), - .pane_visible => |id| try self.receivedPaneVisible( - id, + .pane_visible => |cap| try self.receivedPaneVisible( + cap.screen_key, + cap.id, content, ), } @@ -494,8 +497,10 @@ pub const Viewer = struct { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; try self.queueCommands(&.{ - .{ .pane_history = pane_id }, - .{ .pane_visible = pane_id }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } } @@ -528,6 +533,7 @@ pub const Viewer = struct { fn receivedPaneHistory( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -538,6 +544,7 @@ pub const Viewer = struct { }; const pane: *Pane = entry.value_ptr; const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); const screen: *Screen = t.screens.active; // Get a VT stream from the terminal so we can send data as-is into @@ -569,6 +576,7 @@ pub const Viewer = struct { fn receivedPaneVisible( self: *Viewer, + screen_key: ScreenSet.Key, id: usize, content: []const u8, ) !void { @@ -578,13 +586,15 @@ pub const Viewer = struct { return; }; const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + _ = try t.switchScreen(screen_key); // Erase the active area and reset the cursor to the top-left // before writing the visible content. - pane.terminal.eraseDisplay(.complete, false); - pane.terminal.setCursorPos(1, 1); + t.eraseDisplay(.complete, false); + t.setCursorPos(1, 1); - var stream = pane.terminal.vtStream(); + var stream = t.vtStream(); defer stream.deinit(); stream.nextSlice(content) catch |err| { log.info("failed to process pane visible for pane id={}: {}", .{ id, err }); @@ -729,15 +739,20 @@ const Command = union(enum) { list_windows, /// Capture history for the given pane ID. - pane_history: usize, + pane_history: CapturePane, /// Capture visible area for the given pane ID. - pane_visible: usize, + pane_visible: CapturePane, /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, + const CapturePane = struct { + id: usize, + screen_key: ScreenSet.Key, + }; + pub fn deinit(self: Command, alloc: Allocator) void { return switch (self) { .list_windows, @@ -761,24 +776,34 @@ const Command = union(enum) { .{comptime Format.list_windows.comptimeFormat()}, )), - .pane_history => |id| try writer.print( + .pane_history => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -S - = start at the top of history ("-") // -E -1 = end at the last line of history (1 before the // visible area is -1). // -t %{d} = target a specific pane ID - "capture-pane -p -e -S - -E -1 -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-S - -E -1 -t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), - .pane_visible => |id| try writer.print( + .pane_visible => |cap| try writer.print( // -p = output to stdout instead of buffer // -e = output escape sequences for SGR + // -a = capture alternate screen (only valid for alternate) + // -q = quiet, don't error if alternate screen doesn't exist // -t %{d} = target a specific pane ID // (no -S/-E = capture visible area only) - "capture-pane -p -e -t %{d}\n", - .{id}, + "capture-pane -p -e -q {s}-t %{d}\n", + .{ + if (cap.screen_key == .alternate) "-a " else "", + cap.id, + }, ), .user => |v| try writer.writeAll(v), @@ -938,9 +963,11 @@ test "initial flow" { } }, .contains_tags = &.{ .windows, .command }, .contains_command = "capture-pane", + // pane_history for pane 0 (primary) .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, @@ -950,11 +977,12 @@ test "initial flow" { \\Hello, world! , } }, - // Moves on to pane_visible for pane 0 + // Moves on to pane_visible for pane 0 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, .check = (struct { @@ -982,21 +1010,67 @@ test "initial flow" { }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_history for pane 1 + // Moves on to pane_history for pane 0 (alternate) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { - try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, .{ .input = .{ .tmux = .{ .block_end = "" } }, - // Moves on to pane_visible for pane 1 + // Moves on to pane_visible for pane 0 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %0")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (primary) .contains_command = "capture-pane", .check_command = (struct { fn check(_: *Viewer, command: []const u8) anyerror!void { try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (primary) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(!std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_history for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + // Moves on to pane_visible for pane 1 (alternate) + .contains_command = "capture-pane", + .check_command = (struct { + fn check(_: *Viewer, command: []const u8) anyerror!void { + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-t %1")); + try testing.expect(std.mem.containsAtLeast(u8, command, 1, "-a")); } }).check, }, From 938e419e042bfd9322b5180e6ac54c122f558a36 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:11:58 -0800 Subject: [PATCH 18/28] terminal/tmux: handle output events --- src/terminal/tmux/viewer.zig | 67 ++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5df5b83bb..f6cf6292b 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -13,6 +13,12 @@ const output = @import("output.zig"); const log = std.log.scoped(.terminal_tmux_viewer); +// TODO: A list of TODOs as I think about them. +// - We need to make startup more robust so session and block can happen +// out of order. +// - We need to ignore `output` for panes that aren't yet initialized +// (until capture-panes are complete). + // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications // and assurances about what is sent when are based on reading the tmux @@ -312,6 +318,20 @@ pub const Viewer = struct { break :err self.defunct(); }, + .output => |out| output: { + self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); + }; + + break :output &.{}; + }, + // TODO: Use exhaustive matching here, determine if we need // to handle the other cases. else => &.{}, @@ -602,6 +622,26 @@ pub const Viewer = struct { }; } + fn receivedOutput( + self: *Viewer, + id: usize, + data: []const u8, + ) !void { + const entry = self.panes.getEntry(id) orelse { + log.info("received output for untracked pane id={}", .{id}); + return; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice(data) catch |err| { + log.info("failed to process output for pane id={}: {}", .{ id, err }); + return err; + }; + } + fn initLayout( gpa_alloc: Allocator, panes_old: *const PanesMap, @@ -1074,6 +1114,33 @@ test "initial flow" { } }).check, }, + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new output")); + } + }).check, + }, + .{ + .input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = "ignored" } } }, + .check = (struct { + fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, actions.len); + } + }).check, + }, .{ .input = .{ .tmux = .exit }, .contains_tags = &.{.exit}, From 64ef640127c7a48172a27990f240a8c068b0ea70 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 13:52:53 -0800 Subject: [PATCH 19/28] terminal/tmux: exhaustive switch for command --- src/terminal/tmux/viewer.zig | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index f6cf6292b..9c6fa1b1f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -18,6 +18,8 @@ const log = std.log.scoped(.terminal_tmux_viewer); // out of order. // - We need to ignore `output` for panes that aren't yet initialized // (until capture-panes are complete). +// - We should note what the active window pane is on the tmux side; +// we can use this at least for initial focus. // NOTE: There is some fragility here that can possibly break if tmux // changes their implementation. In particular, the order of notifications @@ -332,9 +334,30 @@ pub const Viewer = struct { break :output &.{}; }, - // TODO: Use exhaustive matching here, determine if we need - // to handle the other cases. - else => &.{}, + // TODO: There's real logic to do for these. + .session_changed, + .layout_change, + .window_add, + => &.{}, + + // The active pane changed. We don't care about this because + // we handle our own focus. + .window_pane_changed => &.{}, + + // We ignore this one. It means a session was created or + // destroyed. If it was our own session we will get an exit + // notification very soon. If it is another session we don't + // care. + .sessions_changed => &.{}, + + // We don't use window names for anything, currently. + .window_renamed => &.{}, + + // This is for other clients, which we don't do anything about. + // For us, we'll get `exit` or `session_changed`, respectively. + .client_detached, + .client_session_changed, + => &.{}, }; } From 071070faa3a5d9d56e4f802218cd6e8a31075670 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 14:11:25 -0800 Subject: [PATCH 20/28] terminal/tmux: handle session_changed inside command loop --- src/terminal/tmux/viewer.zig | 136 ++++++++++++++++++++++++++++++++++- 1 file changed, 133 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 9c6fa1b1f..3b401f44e 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -315,9 +315,9 @@ pub const Viewer = struct { => |content, tag| self.receivedCommandOutput( content, tag == .block_err, - ) catch err: { + ) catch { log.warn("failed to process command output, becoming defunct", .{}); - break :err self.defunct(); + return self.defunct(); }, .output => |out| output: { @@ -334,8 +334,14 @@ pub const Viewer = struct { break :output &.{}; }, + // Session changed means we switched to a different tmux session. + // We need to reset our state and start fresh with list-windows. + .session_changed => |info| self.sessionChanged(info.id) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .session_changed, .layout_change, .window_add, => &.{}, @@ -361,6 +367,47 @@ pub const Viewer = struct { }; } + /// When a session changes, we have to basically reset our whole state. + /// To do this, we emit an empty windows event (so callers can clear all + /// windows), reset ourself, and start all over. + fn sessionChanged( + self: *Viewer, + session_id: usize, + ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + // Build up a new viewer. Its the easiest way to reset ourselves. + var replacement: Viewer = try .init(self.alloc); + errdefer replacement.deinit(); + + // Build actions: empty windows notification + list-windows command + var arena = replacement.action_arena.promote(replacement.alloc); + const arena_alloc = arena.allocator(); + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = &.{} }); + + // Setup our command queue + try actions.appendSlice( + arena_alloc, + try replacement.enterCommandQueue( + arena_alloc, + .list_windows, + ), + ); + + // Save arena state back before swap + replacement.action_arena = arena.state; + + // Swap our self, no more error handling after this. + errdefer comptime unreachable; + self.deinit(); + self.* = replacement; + + // Set our session ID and jump directly to the list + self.session_id = session_id; + + assert(self.state == .command_queue); + return actions.items; + } + fn receivedCommandOutput( self: *Viewer, content: []const u8, @@ -1000,6 +1047,89 @@ test "immediate exit" { }); } +test "session changed resets state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "first", + } } }, + .contains_command = "list-windows", + }, + // Receive window layout with two panes (same format as "initial flow" test) + .{ + .input = .{ .tmux = .{ + .block_end = + \\$1 @0 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + // Now session changes - should reset everything + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 2, + .name = "second", + } } }, + .contains_tags = &.{ .windows, .command }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Session ID should be updated + try testing.expectEqual(2, v.session_id); + // Windows should be cleared (empty windows action sent) + var found_empty_windows = false; + for (actions) |action| { + if (action == .windows and action.windows.len == 0) { + found_empty_windows = true; + } + } + try testing.expect(found_empty_windows); + // Old windows should be cleared + try testing.expectEqual(0, v.windows.items.len); + // Old panes should be cleared + try testing.expectEqual(0, v.panes.count()); + } + }).check, + }, + // Receive new window layout for new session (same layout, different session/window) + // Uses same pane IDs 0,1 - they should be re-created since old panes were cleared + .{ + .input = .{ .tmux = .{ + .block_end = + \\$2 @1 83 44 027b,83x44,0,0[83x20,0,0,0,83x23,0,21,1] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.session_id); + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.windows.items[0].id); + // Panes 0 and 1 should be created (fresh, since old ones were cleared) + try testing.expectEqual(2, v.panes.count()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + test "initial flow" { var viewer = try Viewer.init(testing.allocator); defer viewer.deinit(); From 1a2b3c165ac049ded7c893f23ea5ee1205bd35d2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 15:31:44 -0800 Subject: [PATCH 21/28] terminal/tmux: layoutChanged handling --- src/terminal/tmux/viewer.zig | 436 ++++++++++++++++++++++++++++------- 1 file changed, 356 insertions(+), 80 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 3b401f44e..b8579d1d5 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -341,10 +341,20 @@ pub const Viewer = struct { return self.defunct(); }, + // Layout changed of a single window. + .layout_change => |info| self.layoutChanged( + info.window_id, + info.layout, + ) catch { + // Note: in the future, we can probably handle a failure + // here with a fallback to remove this one window, list + // windows again, and try again. + log.warn("failed to handle layout change, becoming defunct", .{}); + return self.defunct(); + }, + // TODO: There's real logic to do for these. - .layout_change, - .window_add, - => &.{}, + .window_add => &.{}, // The active pane changed. We don't care about this because // we handle our own focus. @@ -367,6 +377,164 @@ pub const Viewer = struct { }; } + /// When the layout changes for a single window, a pane may be added + /// or removed that we've never seen, in addition to the layout itself + /// physically changing. + /// + /// To handle this, its similar to list-windows except we expect the + /// window to already exist. We update the layout, do the initLayout + /// call for any diffs, setup commands to capture any new panes, + /// prune any removed panes. + fn layoutChanged( + self: *Viewer, + window_id: usize, + layout_str: []const u8, + ) ![]const Action { + // Find the window this layout change is for. + const window: *Window = window: for (self.windows.items) |*w| { + if (w.id == window_id) break :window w; + } else { + log.info("layout change for unknown window id={}", .{window_id}); + return &.{}; + }; + + // Clear our prior window arena and setup our layout + window.layout = layout: { + var arena = window.layout_arena.promote(self.alloc); + defer window.layout_arena = arena.state; + _ = arena.reset(.retain_capacity); + break :layout Layout.parseWithChecksum( + arena.allocator(), + layout_str, + ) catch |err| { + log.info( + "failed to parse window layout id={} layout={s}", + .{ window_id, layout_str }, + ); + return err; + }; + }; + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Reset our arena so we can build up actions. + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + // Our initial action is to definitely let the caller know that + // some windows changed. + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, .{ .windows = self.windows.items }); + + // Sync up our panes + try self.syncLayouts(self.windows.items); + + // If our command queue was empty and now its not we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty and !self.command_queue.empty()) { + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + try actions.append(arena_alloc, action); + } + + return actions.items; + } + + fn syncLayouts( + self: *Viewer, + windows: []const Window, + ) !void { + // Go through the window layout and setup all our panes. We move + // this into a new panes map so that we can easily prune our old + // list. + var panes: PanesMap = .empty; + errdefer { + // Clear out all the new panes. + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + if (!self.panes.contains(kv.key_ptr.*)) { + kv.value_ptr.deinit(self.alloc); + } + } + panes.deinit(self.alloc); + } + for (windows) |window| try initLayout( + self.alloc, + &self.panes, + &panes, + window.layout, + ); + + // Build up the list of removed panes. + var removed: std.ArrayList(usize) = removed: { + var removed: std.ArrayList(usize) = .empty; + errdefer removed.deinit(self.alloc); + var panes_it = self.panes.iterator(); + while (panes_it.next()) |kv| { + if (panes.contains(kv.key_ptr.*)) continue; + try removed.append(self.alloc, kv.key_ptr.*); + } + + break :removed removed; + }; + defer removed.deinit(self.alloc); + + // Ensure we can add the windows + try self.windows.ensureTotalCapacity(self.alloc, windows.len); + + // Get our list of added panes and setup our command queue + // to populate them. + // TODO: errdefer cleanup + { + var panes_it = panes.iterator(); + while (panes_it.next()) |kv| { + const pane_id: usize = kv.key_ptr.*; + if (self.panes.contains(pane_id)) continue; + try self.queueCommands(&.{ + .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, + .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, + .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, + }); + } + } + + // No more errors after this point. We're about to replace all + // our owned state with our temporary state, and our errdefers + // above will double-free if there is an error. + errdefer comptime unreachable; + + // Replace our window list if it changed. We assume it didn't + // change if our pointer is pointing to the same data. + if (windows.ptr != self.windows.items.ptr) { + for (self.windows.items) |*window| window.deinit(self.alloc); + self.windows.clearRetainingCapacity(); + self.windows.appendSliceAssumeCapacity(windows); + } + + // Replace our panes + { + // First remove our old panes + for (removed.items) |id| if (self.panes.fetchSwapRemove( + id, + )) |entry_const| { + var entry = entry_const; + entry.value.deinit(self.alloc); + }; + // We can now deinit self.panes because the existing + // entries are preserved. + self.panes.deinit(self.alloc); + self.panes = panes; + } + } + /// When a session changes, we have to basically reset our whole state. /// To do this, we emit an empty windows event (so callers can clear all /// windows), reset ourself, and start all over. @@ -499,7 +667,7 @@ pub const Viewer = struct { // This stores our new window state from this list-windows output. var windows: std.ArrayList(Window) = .empty; - errdefer windows.deinit(self.alloc); + defer windows.deinit(self.alloc); // Parse all our windows var it = std.mem.splitScalar(u8, content, '\n'); @@ -543,82 +711,8 @@ pub const Viewer = struct { // window changes. try actions.append(arena_alloc, .{ .windows = windows.items }); - // Go through the window layout and setup all our panes. We move - // this into a new panes map so that we can easily prune our old - // list. - var panes: PanesMap = .empty; - errdefer { - // Clear out all the new panes. - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - if (!self.panes.contains(kv.key_ptr.*)) { - kv.value_ptr.deinit(self.alloc); - } - } - panes.deinit(self.alloc); - } - for (windows.items) |window| try initLayout( - self.alloc, - &self.panes, - &panes, - window.layout, - ); - - // Build up the list of removed panes. - var removed: std.ArrayList(usize) = removed: { - var removed: std.ArrayList(usize) = .empty; - errdefer removed.deinit(self.alloc); - var panes_it = self.panes.iterator(); - while (panes_it.next()) |kv| { - if (panes.contains(kv.key_ptr.*)) continue; - try removed.append(self.alloc, kv.key_ptr.*); - } - - break :removed removed; - }; - defer removed.deinit(self.alloc); - - // Get our list of added panes and setup our command queue - // to populate them. - // TODO: errdefer cleanup - { - var panes_it = panes.iterator(); - while (panes_it.next()) |kv| { - const pane_id: usize = kv.key_ptr.*; - if (self.panes.contains(pane_id)) continue; - try self.queueCommands(&.{ - .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, - .{ .pane_history = .{ .id = pane_id, .screen_key = .alternate } }, - .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, - }); - } - } - - // No more errors after this point. We're about to replace all - // our owned state with our temporary state, and our errdefers - // above will double-free if there is an error. - errdefer comptime unreachable; - - // Replace our window list - for (self.windows.items) |*window| window.deinit(self.alloc); - self.windows.deinit(self.alloc); - self.windows = windows; - - // Replace our panes - { - // First remove our old panes - for (removed.items) |id| if (self.panes.fetchSwapRemove( - id, - )) |entry_const| { - var entry = entry_const; - entry.value.deinit(self.alloc); - }; - // We can now deinit self.panes because the existing - // entries are preserved. - self.panes.deinit(self.alloc); - self.panes = panes; - } + // Sync up our layouts. This will populate unknown panes, prune, etc. + try self.syncLayouts(windows.items); } fn receivedPaneHistory( @@ -1300,3 +1394,185 @@ test "initial flow" { }, }); } + +test "layout change" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + try testing.expectEqual(1, v.panes.count()); + try testing.expect(v.panes.contains(0)); + } + }).check, + }, + // Complete all capture-pane commands for pane 0 (primary and alternate) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Now send a layout_change that splits into two panes + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Should still have 1 window + try testing.expectEqual(1, v.windows.items.len); + // Should now have 2 panes (0 and 2) + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(2)); + // Commands should be queued for the new pane + try testing.expectEqual(4, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change does not return command when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send a layout_change that splits into two panes. + // This should NOT return a command action since queue was not empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{.windows}, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + // Should not contain a command action + for (actions) |action| { + try testing.expect(action != .command); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "layout_change returns command when queue was empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send a layout_change that splits into two panes. + // This should return a command action since we're queuing commands + // for the new pane and the queue was empty. + .{ + .input = .{ .tmux = .{ .layout_change = .{ + .window_id = 0, + .layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .visible_layout = "e07b,83x44,0,0[83x22,0,0,0,83x21,0,23,2]", + .raw_flags = "*", + } } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(2, v.panes.count()); + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 582ea5d84bab67a56f061dce22b46e56f223d1e8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 17:15:23 -0800 Subject: [PATCH 22/28] terminal/tmux: window add --- src/terminal/tmux/viewer.zig | 148 ++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index b8579d1d5..c3860c6e4 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -353,8 +353,11 @@ pub const Viewer = struct { return self.defunct(); }, - // TODO: There's real logic to do for these. - .window_add => &.{}, + // A window was added to this session. + .window_add => |info| self.windowAdd(info.id) catch { + log.warn("failed to handle window add, becoming defunct", .{}); + return self.defunct(); + }, // The active pane changed. We don't care about this because // we handle our own focus. @@ -447,6 +450,40 @@ pub const Viewer = struct { return actions.items; } + /// When a window is added to the session, we need to refresh our window + /// list to get the new window's information. + fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + _ = window_id; // We refresh all windows via list-windows + + // If our command queue started out empty and becomes non-empty, + // then we need to send down the command. + const command_queue_empty = self.command_queue.empty(); + + // Queue list-windows to get the updated window list + try self.queueCommands(&.{.list_windows}); + + // If our command queue was empty and now it's not, we need to add + // a command to the output. + assert(self.state == .command_queue); + if (command_queue_empty) { + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + _ = arena.reset(.free_all); + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + const command = self.command_queue.first().?; + command.formatCommand(&builder.writer) catch return error.OutOfMemory; + const action: Action = .{ .command = builder.writer.buffered() }; + + var actions: std.ArrayList(Action) = .empty; + try actions.append(arena_alloc, action); + return actions.items; + } + + return &.{}; + } + fn syncLayouts( self: *Viewer, windows: []const Window, @@ -1576,3 +1613,110 @@ test "layout_change returns command when queue was empty" { }, }); } + +test "window_add queues list_windows when queue empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + }, + // Complete all capture-pane commands for pane 0 + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Queue should now be empty + .{ + .input = .{ .tmux = .{ .block_end = "" } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expect(v.command_queue.empty()); + } + }).check, + }, + // Now send window_add - should trigger list-windows command + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Command queue should have list_windows + try testing.expect(!v.command_queue.empty()); + try testing.expectEqual(1, v.command_queue.len()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} + +test "window_add queues list_windows when queue not empty" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial startup + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 1, + .name = "test", + } } }, + .contains_command = "list-windows", + }, + // Receive initial window layout with one pane + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 83 44 b7dd,83x44,0,0,0 + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Queue should have capture-pane commands + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + // Do NOT complete capture-pane commands - queue still has commands. + // Send window_add - should queue list-windows but NOT return command action + .{ + .input = .{ .tmux = .{ .window_add = .{ .id = 1 } } }, + .check = (struct { + fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void { + // Should not contain a command action since queue was not empty + for (actions) |action| { + try testing.expect(action != .command); + } + // But list_windows should be in the queue + try testing.expect(!v.command_queue.empty()); + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 4c30c5aa765c1c76c5c4c1b1285b66af61f1840a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:19:20 -0800 Subject: [PATCH 23/28] terminal/tmux: cleanup command queue logic --- src/terminal/tmux/viewer.zig | 220 +++++++++++++++++------------------ 1 file changed, 109 insertions(+), 111 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index c3860c6e4..306bcd69d 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -306,43 +306,73 @@ pub const Viewer = struct { // handle it by ignoring any command output. That's okay! assert(self.state == .command_queue); - return switch (n) { + // Clear our prior arena so it is ready to be used for any + // actions immediately. + { + var arena = self.action_arena.promote(self.alloc); + _ = arena.reset(.free_all); + self.action_arena = arena.state; + } + + // Setup our empty actions list that commands can populate. + var actions: std.ArrayList(Action) = .empty; + + // Track whether the in-flight command slot is available. Starts true + // if queue is empty (no command in flight). Set to true when a command + // completes (block_end/block_err) or the queue is reset (session_changed). + var command_consumed = self.command_queue.empty(); + + switch (n) { .enter => unreachable, - .exit => self.defunct(), + .exit => return self.defunct(), inline .block_end, .block_err, - => |content, tag| self.receivedCommandOutput( - content, - tag == .block_err, - ) catch { - log.warn("failed to process command output, becoming defunct", .{}); - return self.defunct(); - }, - - .output => |out| output: { - self.receivedOutput( - out.pane_id, - out.data, - ) catch |err| { - log.warn( - "failed to process output for pane id={}: {}", - .{ out.pane_id, err }, - ); + => |content, tag| { + self.receivedCommandOutput( + &actions, + content, + tag == .block_err, + ) catch { + log.warn("failed to process command output, becoming defunct", .{}); + return self.defunct(); }; - break :output &.{}; + // Command is consumed since a block end/err is the output + // from a command. + command_consumed = true; + }, + + .output => |out| self.receivedOutput( + out.pane_id, + out.data, + ) catch |err| { + log.warn( + "failed to process output for pane id={}: {}", + .{ out.pane_id, err }, + ); }, // Session changed means we switched to a different tmux session. // We need to reset our state and start fresh with list-windows. - .session_changed => |info| self.sessionChanged(info.id) catch { - log.warn("failed to handle session change, becoming defunct", .{}); - return self.defunct(); + // This completely replaces the viewer, so treat it like a fresh start. + .session_changed => |info| { + self.sessionChanged( + &actions, + info.id, + ) catch { + log.warn("failed to handle session change, becoming defunct", .{}); + return self.defunct(); + }; + + // Command is consumed because sessionChanged resets + // our entire viewer. + command_consumed = true; }, // Layout changed of a single window. .layout_change => |info| self.layoutChanged( + &actions, info.window_id, info.layout, ) catch { @@ -361,23 +391,53 @@ pub const Viewer = struct { // The active pane changed. We don't care about this because // we handle our own focus. - .window_pane_changed => &.{}, + .window_pane_changed => {}, // We ignore this one. It means a session was created or // destroyed. If it was our own session we will get an exit // notification very soon. If it is another session we don't // care. - .sessions_changed => &.{}, + .sessions_changed => {}, // We don't use window names for anything, currently. - .window_renamed => &.{}, + .window_renamed => {}, // This is for other clients, which we don't do anything about. // For us, we'll get `exit` or `session_changed`, respectively. .client_detached, .client_session_changed, - => &.{}, - }; + => {}, + } + + // After processing commands, we add our next command to + // execute if we have one. We do this last because command + // processing may itself queue more commands. We only emit a + // command if a prior command was consumed (or never existed). + if (self.state == .command_queue and command_consumed) { + if (self.command_queue.first()) |next_command| { + // We should not have any commands, because our nextCommand + // always queues them. + if (comptime std.debug.runtime_safety) { + for (actions.items) |action| { + if (action == .command) assert(false); + } + } + + var arena = self.action_arena.promote(self.alloc); + defer self.action_arena = arena.state; + const arena_alloc = arena.allocator(); + + var builder: std.Io.Writer.Allocating = .init(arena_alloc); + next_command.formatCommand(&builder.writer) catch + return self.defunct(); + actions.append( + arena_alloc, + .{ .command = builder.writer.buffered() }, + ) catch return self.defunct(); + } + } + + return actions.items; } /// When the layout changes for a single window, a pane may be added @@ -390,15 +450,16 @@ pub const Viewer = struct { /// prune any removed panes. fn layoutChanged( self: *Viewer, + actions: *std.ArrayList(Action), window_id: usize, layout_str: []const u8, - ) ![]const Action { + ) !void { // Find the window this layout change is for. const window: *Window = window: for (self.windows.items) |*w| { if (w.id == window_id) break :window w; } else { log.info("layout change for unknown window id={}", .{window_id}); - return &.{}; + return; }; // Clear our prior window arena and setup our layout @@ -418,70 +479,29 @@ pub const Viewer = struct { }; }; - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Reset our arena so we can build up actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); // Our initial action is to definitely let the caller know that // some windows changed. - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = self.windows.items }); // Sync up our panes try self.syncLayouts(self.windows.items); - - // If our command queue was empty and now its not we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty and !self.command_queue.empty()) { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - try actions.append(arena_alloc, action); - } - - return actions.items; } /// When a window is added to the session, we need to refresh our window /// list to get the new window's information. - fn windowAdd(self: *Viewer, window_id: usize) ![]const Action { + fn windowAdd( + self: *Viewer, + window_id: usize, + ) !void { _ = window_id; // We refresh all windows via list-windows - // If our command queue started out empty and becomes non-empty, - // then we need to send down the command. - const command_queue_empty = self.command_queue.empty(); - // Queue list-windows to get the updated window list try self.queueCommands(&.{.list_windows}); - - // If our command queue was empty and now it's not, we need to add - // a command to the output. - assert(self.state == .command_queue); - if (command_queue_empty) { - var arena = self.action_arena.promote(self.alloc); - defer self.action_arena = arena.state; - _ = arena.reset(.free_all); - const arena_alloc = arena.allocator(); - - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - const command = self.command_queue.first().?; - command.formatCommand(&builder.writer) catch return error.OutOfMemory; - const action: Action = .{ .command = builder.writer.buffered() }; - - var actions: std.ArrayList(Action) = .empty; - try actions.append(arena_alloc, action); - return actions.items; - } - - return &.{}; } fn syncLayouts( @@ -577,26 +597,26 @@ pub const Viewer = struct { /// windows), reset ourself, and start all over. fn sessionChanged( self: *Viewer, + actions: *std.ArrayList(Action), session_id: usize, - ) (Allocator.Error || std.Io.Writer.Error)![]const Action { + ) (Allocator.Error || std.Io.Writer.Error)!void { // Build up a new viewer. Its the easiest way to reset ourselves. var replacement: Viewer = try .init(self.alloc); errdefer replacement.deinit(); + // Our actions must start out empty so we don't mix arenas + assert(actions.items.len == 0); + errdefer actions.* = .empty; + // Build actions: empty windows notification + list-windows command var arena = replacement.action_arena.promote(replacement.alloc); const arena_alloc = arena.allocator(); - var actions: std.ArrayList(Action) = .empty; try actions.append(arena_alloc, .{ .windows = &.{} }); - // Setup our command queue - try actions.appendSlice( - arena_alloc, - try replacement.enterCommandQueue( - arena_alloc, - .list_windows, - ), - ); + // Setup our command queue and put ourselves in the command queue + // state. + try replacement.queueCommands(&.{.list_windows}); + replacement.state = .command_queue; // Save arena state back before swap replacement.action_arena = arena.state; @@ -610,14 +630,14 @@ pub const Viewer = struct { self.session_id = session_id; assert(self.state == .command_queue); - return actions.items; } fn receivedCommandOutput( self: *Viewer, + actions: *std.ArrayList(Action), content: []const u8, is_err: bool, - ) ![]const Action { + ) !void { // Get the command we're expecting output for. We need to get the // non-pointer value because we are deleting it from the circular // buffer immediately. This shallow copy is all we need since @@ -636,7 +656,7 @@ pub const Viewer = struct { } else { // If we have no pending commands, this is unexpected. log.info("unexpected block output err={}", .{is_err}); - return &.{}; + return; }; self.command_queue.deleteOldest(1); defer command.deinit(self.alloc); @@ -645,20 +665,15 @@ pub const Viewer = struct { // easily accumulate actions. var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; - _ = arena.reset(.free_all); const arena_alloc = arena.allocator(); - // Build up our actions to start with the next command if - // we have one. - var actions: std.ArrayList(Action) = .empty; - // Process our command switch (command) { .user => {}, .list_windows => try self.receivedListWindows( arena_alloc, - &actions, + actions, content, ), @@ -674,23 +689,6 @@ pub const Viewer = struct { content, ), } - - // After processing commands, we add our next command to - // execute if we have one. We do this last because command - // processing may itself queue more commands. - if (self.command_queue.first()) |next_command| { - var builder: std.Io.Writer.Allocating = .init(arena_alloc); - try next_command.formatCommand(&builder.writer); - try actions.append( - arena_alloc, - .{ .command = builder.writer.buffered() }, - ); - } - - // Our command processing should not change our state - assert(self.state == .command_queue); - - return actions.items; } fn receivedListWindows( From bf46c4ebe74d0e668762e84e690e86ca1389e486 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 9 Dec 2025 20:49:03 -0800 Subject: [PATCH 24/28] terminal/tmux: many more output formats --- src/terminal/tmux/output.zig | 318 ++++++++++++++++++++++++++++++++++- 1 file changed, 312 insertions(+), 6 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index cff1a982d..02dca23e6 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -95,16 +95,107 @@ pub fn FormatStruct(comptime vars: []const Variable) type { /// a subset of them here that are relevant to the use case of implementing /// control mode for terminal emulators. pub const Variable = enum { + /// 1 if pane is in alternate screen. + alternate_on, + /// Saved cursor X in alternate screen. + alternate_saved_x, + /// Saved cursor Y in alternate screen. + alternate_saved_y, + /// 1 if bracketed paste mode is enabled. + bracketed_paste, + /// 1 if the cursor is blinking. + cursor_blinking, + /// Cursor colour in pane. Possible formats: + /// - Named colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, + /// `cyan`, `white`, `default`, `terminal`, or bright variants. + /// - 256 colors: `colour` where N is 0-255 (e.g., `colour100`). + /// - RGB hex: `#RRGGBB` (e.g., `#ff0000`). + /// - Empty string if unset. + cursor_colour, + /// Pane cursor flag. + cursor_flag, + /// Cursor shape in pane. Possible values: `block`, `underline`, `bar`, + /// or `default`. + cursor_shape, + /// Cursor X position in pane. + cursor_x, + /// Cursor Y position in pane. + cursor_y, + /// 1 if focus reporting is enabled. + focus_flag, + /// Pane insert flag. + insert_flag, + /// Pane keypad cursor flag. + keypad_cursor_flag, + /// Pane keypad flag. + keypad_flag, + /// Pane mouse all flag. + mouse_all_flag, + /// Pane mouse any flag. + mouse_any_flag, + /// Pane mouse button flag. + mouse_button_flag, + /// Pane mouse SGR flag. + mouse_sgr_flag, + /// Pane mouse standard flag. + mouse_standard_flag, + /// Pane mouse UTF-8 flag. + mouse_utf8_flag, + /// Pane origin flag. + origin_flag, + /// Unique pane ID prefixed with `%` (e.g., `%0`, `%42`). + pane_id, + /// Pane tab positions as a comma-separated list of 0-indexed column + /// numbers (e.g., `8,16,24,32`). Empty string if no tabs are set. + pane_tabs, + /// Bottom of scroll region in pane. + scroll_region_lower, + /// Top of scroll region in pane. + scroll_region_upper, + /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, + /// Width of window. window_width, + /// Height of window. window_height, + /// Window layout description, ignoring zoomed window panes. Format is + /// `,` where checksum is a 4-digit hex CRC16 and layout + /// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal + /// splits and `[...]` for vertical splits. window_layout, + /// Pane wrap flag. + wrap_flag, /// Parse the given string value into the appropriate resulting /// type for this variable. pub fn parse(comptime self: Variable, value: []const u8) !Type(self) { return switch (self) { + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => std.mem.eql(u8, value, "1"), + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + => try std.fmt.parseInt(usize, value, 10), .session_id => if (value.len >= 2 and value[0] == '$') try std.fmt.parseInt(usize, value[1..], 10) else @@ -113,24 +204,105 @@ pub const Variable = enum { try std.fmt.parseInt(usize, value[1..], 10) else return error.FormatError, + .pane_id => if (value.len >= 2 and value[0] == '%') + try std.fmt.parseInt(usize, value[1..], 10) + else + return error.FormatError, .window_width => try std.fmt.parseInt(usize, value, 10), .window_height => try std.fmt.parseInt(usize, value, 10), - .window_layout => value, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => value, }; } /// The type of the parsed value for this variable type. pub fn Type(comptime self: Variable) type { return switch (self) { - .session_id => usize, - .window_id => usize, - .window_width => usize, - .window_height => usize, - .window_layout => []const u8, + .alternate_on, + .bracketed_paste, + .cursor_blinking, + .cursor_flag, + .focus_flag, + .insert_flag, + .keypad_cursor_flag, + .keypad_flag, + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_sgr_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .origin_flag, + .wrap_flag, + => bool, + .alternate_saved_x, + .alternate_saved_y, + .cursor_x, + .cursor_y, + .scroll_region_lower, + .scroll_region_upper, + .session_id, + .window_id, + .pane_id, + .window_width, + .window_height, + => usize, + .cursor_colour, + .cursor_shape, + .pane_tabs, + .window_layout, + => []const u8, }; } }; +test "parse alternate_on" { + try testing.expectEqual(true, try Variable.parse(.alternate_on, "1")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "0")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "true")); + try testing.expectEqual(false, try Variable.parse(.alternate_on, "yes")); +} + +test "parse alternate_saved_x" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_x, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_x, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_x, "abc")); +} + +test "parse alternate_saved_y" { + try testing.expectEqual(0, try Variable.parse(.alternate_saved_y, "0")); + try testing.expectEqual(42, try Variable.parse(.alternate_saved_y, "42")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.alternate_saved_y, "abc")); +} + +test "parse cursor_x" { + try testing.expectEqual(0, try Variable.parse(.cursor_x, "0")); + try testing.expectEqual(79, try Variable.parse(.cursor_x, "79")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_x, "abc")); +} + +test "parse cursor_y" { + try testing.expectEqual(0, try Variable.parse(.cursor_y, "0")); + try testing.expectEqual(23, try Variable.parse(.cursor_y, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.cursor_y, "abc")); +} + +test "parse scroll_region_upper" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_upper, "0")); + try testing.expectEqual(5, try Variable.parse(.scroll_region_upper, "5")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_upper, "abc")); +} + +test "parse scroll_region_lower" { + try testing.expectEqual(0, try Variable.parse(.scroll_region_lower, "0")); + try testing.expectEqual(23, try Variable.parse(.scroll_region_lower, "23")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.scroll_region_lower, "abc")); +} + test "parse session id" { try testing.expectEqual(42, try Variable.parse(.session_id, "$42")); try testing.expectEqual(0, try Variable.parse(.session_id, "$0")); @@ -176,6 +348,140 @@ test "parse window layout" { try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)")); } +test "parse cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_flag, "true")); +} + +test "parse insert_flag" { + try testing.expectEqual(true, try Variable.parse(.insert_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "")); + try testing.expectEqual(false, try Variable.parse(.insert_flag, "true")); +} + +test "parse keypad_cursor_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_cursor_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_cursor_flag, "true")); +} + +test "parse keypad_flag" { + try testing.expectEqual(true, try Variable.parse(.keypad_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "")); + try testing.expectEqual(false, try Variable.parse(.keypad_flag, "true")); +} + +test "parse mouse_any_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_any_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_any_flag, "true")); +} + +test "parse mouse_button_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_button_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_button_flag, "true")); +} + +test "parse mouse_sgr_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_sgr_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_sgr_flag, "true")); +} + +test "parse mouse_standard_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_standard_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_standard_flag, "true")); +} + +test "parse mouse_utf8_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_utf8_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_utf8_flag, "true")); +} + +test "parse wrap_flag" { + try testing.expectEqual(true, try Variable.parse(.wrap_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "")); + try testing.expectEqual(false, try Variable.parse(.wrap_flag, "true")); +} + +test "parse bracketed_paste" { + try testing.expectEqual(true, try Variable.parse(.bracketed_paste, "1")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "0")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "")); + try testing.expectEqual(false, try Variable.parse(.bracketed_paste, "true")); +} + +test "parse cursor_blinking" { + try testing.expectEqual(true, try Variable.parse(.cursor_blinking, "1")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "0")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "")); + try testing.expectEqual(false, try Variable.parse(.cursor_blinking, "true")); +} + +test "parse focus_flag" { + try testing.expectEqual(true, try Variable.parse(.focus_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "")); + try testing.expectEqual(false, try Variable.parse(.focus_flag, "true")); +} + +test "parse mouse_all_flag" { + try testing.expectEqual(true, try Variable.parse(.mouse_all_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "")); + try testing.expectEqual(false, try Variable.parse(.mouse_all_flag, "true")); +} + +test "parse origin_flag" { + try testing.expectEqual(true, try Variable.parse(.origin_flag, "1")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "0")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "")); + try testing.expectEqual(false, try Variable.parse(.origin_flag, "true")); +} + +test "parse pane_id" { + try testing.expectEqual(42, try Variable.parse(.pane_id, "%42")); + try testing.expectEqual(0, try Variable.parse(.pane_id, "%0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "@0")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "%")); + try testing.expectError(error.FormatError, Variable.parse(.pane_id, "")); + try testing.expectError(error.InvalidCharacter, Variable.parse(.pane_id, "%abc")); +} + +test "parse cursor_colour" { + try testing.expectEqualStrings("red", try Variable.parse(.cursor_colour, "red")); + try testing.expectEqualStrings("#ff0000", try Variable.parse(.cursor_colour, "#ff0000")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_colour, "")); +} + +test "parse cursor_shape" { + try testing.expectEqualStrings("block", try Variable.parse(.cursor_shape, "block")); + try testing.expectEqualStrings("underline", try Variable.parse(.cursor_shape, "underline")); + try testing.expectEqualStrings("bar", try Variable.parse(.cursor_shape, "bar")); + try testing.expectEqualStrings("", try Variable.parse(.cursor_shape, "")); +} + +test "parse pane_tabs" { + try testing.expectEqualStrings("0,8,16,24", try Variable.parse(.pane_tabs, "0,8,16,24")); + try testing.expectEqualStrings("", try Variable.parse(.pane_tabs, "")); + try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); From 58000f5821040060fe8c07c97073bff80886ebd1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 09:28:52 -0800 Subject: [PATCH 25/28] terminal/tmux: build up pane states --- src/terminal/tmux/viewer.zig | 372 ++++++++++++++++++++++++++++++++++- 1 file changed, 370 insertions(+), 2 deletions(-) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 306bcd69d..5384e293f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -3,7 +3,9 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const testing = std.testing; const assert = @import("../../quirks.zig").inlineAssert; +const size = @import("../size.zig"); const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const CursorStyle = @import("../cursor.zig").Style; const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); const Terminal = @import("../Terminal.zig"); @@ -551,9 +553,11 @@ pub const Viewer = struct { // TODO: errdefer cleanup { var panes_it = panes.iterator(); + var added: bool = false; while (panes_it.next()) |kv| { const pane_id: usize = kv.key_ptr.*; if (self.panes.contains(pane_id)) continue; + added = true; try self.queueCommands(&.{ .{ .pane_history = .{ .id = pane_id, .screen_key = .primary } }, .{ .pane_visible = .{ .id = pane_id, .screen_key = .primary } }, @@ -561,6 +565,10 @@ pub const Viewer = struct { .{ .pane_visible = .{ .id = pane_id, .screen_key = .alternate } }, }); } + + // If we added any panes, then we also want to resync the pane + // state (terminal modes and cursor positions and so on). + if (added) try self.queueCommands(&.{.pane_state}); } // No more errors after this point. We're about to replace all @@ -671,6 +679,8 @@ pub const Viewer = struct { switch (command) { .user => {}, + .pane_state => try self.receivedPaneState(content), + .list_windows => try self.receivedListWindows( arena_alloc, actions, @@ -750,6 +760,137 @@ pub const Viewer = struct { try self.syncLayouts(windows.items); } + fn receivedPaneState( + self: *Viewer, + content: []const u8, + ) !void { + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |line_raw| { + const line = std.mem.trim(u8, line_raw, " \t\r"); + if (line.len == 0) continue; + + const data = output.parseFormatStruct( + Format.list_panes.Struct(), + line, + Format.list_panes.delim, + ) catch |err| { + log.info("failed to parse list-panes line: {s}", .{line}); + return err; + }; + + // Get the pane for this ID + const entry = self.panes.getEntry(data.pane_id) orelse { + log.info("received pane state for untracked pane id={}", .{data.pane_id}); + continue; + }; + const pane: *Pane = entry.value_ptr; + const t: *Terminal = &pane.terminal; + + // Determine which screen to use based on alternate_on + const screen_key: ScreenSet.Key = if (data.alternate_on) .alternate else .primary; + + // Set cursor position on the appropriate screen (tmux uses 0-based) + if (t.screens.get(screen_key)) |screen| { + cursor: { + const cursor_x = std.math.cast( + size.CellCountInt, + data.cursor_x, + ) orelse break :cursor; + const cursor_y = std.math.cast( + size.CellCountInt, + data.cursor_y, + ) orelse break :cursor; + if (cursor_x >= screen.pages.cols or + cursor_y >= screen.pages.rows) break :cursor; + screen.cursorAbsolute(cursor_x, cursor_y); + } + + // Set cursor shape on this screen + if (data.cursor_shape.len > 0) { + if (std.mem.eql(u8, data.cursor_shape, "block")) { + screen.cursor.cursor_style = .block; + } else if (std.mem.eql(u8, data.cursor_shape, "underline")) { + screen.cursor.cursor_style = .underline; + } else if (std.mem.eql(u8, data.cursor_shape, "bar")) { + screen.cursor.cursor_style = .bar; + } + } + // "default" or unknown: leave as-is + } + + // Set alternate screen saved cursor position + if (t.screens.get(.alternate)) |alt_screen| cursor: { + const alt_x = std.math.cast( + size.CellCountInt, + data.alternate_saved_x, + ) orelse break :cursor; + const alt_y = std.math.cast( + size.CellCountInt, + data.alternate_saved_y, + ) orelse break :cursor; + + // If our coordinates are outside our screen we ignore it. + // tmux actually sends MAX_INT for when there isn't a set + // cursor position, so this isn't theoretical. + if (alt_x >= alt_screen.pages.cols or + alt_y >= alt_screen.pages.rows) break :cursor; + + alt_screen.cursorAbsolute(alt_x, alt_y); + } + + // Set cursor visibility + t.modes.set(.cursor_visible, data.cursor_flag); + + // Set cursor blinking + t.modes.set(.cursor_blinking, data.cursor_blinking); + + // Terminal modes + t.modes.set(.insert, data.insert_flag); + t.modes.set(.wraparound, data.wrap_flag); + t.modes.set(.keypad_keys, data.keypad_flag); + t.modes.set(.cursor_keys, data.keypad_cursor_flag); + t.modes.set(.origin, data.origin_flag); + + // Mouse modes + t.modes.set(.mouse_event_any, data.mouse_all_flag); + t.modes.set(.mouse_event_button, data.mouse_any_flag); + t.modes.set(.mouse_event_normal, data.mouse_button_flag); + t.modes.set(.mouse_event_x10, data.mouse_standard_flag); + t.modes.set(.mouse_format_utf8, data.mouse_utf8_flag); + t.modes.set(.mouse_format_sgr, data.mouse_sgr_flag); + + // Focus and bracketed paste + t.modes.set(.focus_event, data.focus_flag); + t.modes.set(.bracketed_paste, data.bracketed_paste); + + // Scroll region (tmux uses 0-based values) + scroll: { + const scroll_top = std.math.cast( + size.CellCountInt, + data.scroll_region_upper, + ) orelse break :scroll; + const scroll_bottom = std.math.cast( + size.CellCountInt, + data.scroll_region_lower, + ) orelse break :scroll; + t.scrolling_region.top = scroll_top; + t.scrolling_region.bottom = scroll_bottom; + } + + // Tab stops - parse comma-separated list and set + t.tabstops.reset(0); // Clear all tabstops first + if (data.pane_tabs.len > 0) { + var tabs_it = std.mem.splitScalar(u8, data.pane_tabs, ','); + while (tabs_it.next()) |tab_str| { + const col = std.fmt.parseInt(usize, tab_str, 10) catch continue; + const col_cell = std.math.cast(size.CellCountInt, col) orelse continue; + if (col_cell >= t.cols) continue; + t.tabstops.set(col_cell); + } + } + } + } + fn receivedPaneHistory( self: *Viewer, screen_key: ScreenSet.Key, @@ -983,6 +1124,10 @@ const Command = union(enum) { /// Capture visible area for the given pane ID. pane_visible: CapturePane, + /// Capture the pane terminal state as best we can. The pane ID(s) + /// are part of the output so we can map it back to our panes. + pane_state, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -997,6 +1142,7 @@ const Command = union(enum) { .list_windows, .pane_history, .pane_visible, + .pane_state, => {}, .user => |v| alloc.free(v), }; @@ -1045,6 +1191,11 @@ const Command = union(enum) { }, ), + .pane_state => try writer.writeAll(std.fmt.comptimePrint( + "list-panes -F '{s}'\n", + .{comptime Format.list_panes.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1059,6 +1210,45 @@ const Format = struct { /// guaranteed to not appear in any of the variable outputs. delim: u8, + const list_panes: Format = .{ + .delim = ';', + .vars = &.{ + .pane_id, + // Cursor position & appearance + .cursor_x, + .cursor_y, + .cursor_flag, + .cursor_shape, + .cursor_colour, + .cursor_blinking, + // Alternate screen + .alternate_on, + .alternate_saved_x, + .alternate_saved_y, + // Terminal modes + .insert_flag, + .wrap_flag, + .keypad_flag, + .keypad_cursor_flag, + .origin_flag, + // Mouse modes + .mouse_all_flag, + .mouse_any_flag, + .mouse_button_flag, + .mouse_standard_flag, + .mouse_utf8_flag, + .mouse_sgr_flag, + // Focus & special features + .focus_flag, + .bracketed_paste, + // Scroll region + .scroll_region_upper, + .scroll_region_lower, + // Tab stops + .pane_tabs, + }, + }; + const list_windows: Format = .{ .delim = ' ', .vars = &.{ @@ -1461,6 +1651,8 @@ test "layout change" { }).check, }, // Complete all capture-pane commands for pane 0 (primary and alternate) + // plus pane_state + .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, .{ .input = .{ .tmux = .{ .block_end = "" } } }, @@ -1482,8 +1674,8 @@ test "layout change" { try testing.expectEqual(2, v.panes.count()); try testing.expect(v.panes.contains(0)); try testing.expect(v.panes.contains(2)); - // Commands should be queued for the new pane - try testing.expectEqual(4, v.command_queue.len()); + // Commands should be queued for the new pane (4 capture-pane + 1 pane_state) + try testing.expectEqual(5, v.command_queue.len()); } }).check, }, @@ -1718,3 +1910,179 @@ test "window_add queues list_windows when queue not empty" { }, }); } + +test "two pane flow with pane state" { + var viewer = try Viewer.init(testing.allocator); + defer viewer.deinit(); + + try testViewer(&viewer, &.{ + // Initial block_end from attach + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // Session changed notification + .{ + .input = .{ .tmux = .{ .session_changed = .{ + .id = 0, + .name = "0", + } } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(0, v.session_id); + } + }).check, + }, + // list-windows output with 2 panes in a vertical split + .{ + .input = .{ .tmux = .{ + .block_end = + \\$0 @0 165 79 ca97,165x79,0,0[165x40,0,0,0,165x38,0,41,4] + , + } }, + .contains_tags = &.{ .windows, .command }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqual(1, v.windows.items.len); + const window = v.windows.items[0]; + try testing.expectEqual(0, window.id); + try testing.expectEqual(165, window.width); + try testing.expectEqual(79, window.height); + try testing.expectEqual(2, v.panes.count()); + try testing.expect(v.panes.contains(0)); + try testing.expect(v.panes.contains(4)); + } + }).check, + }, + // capture-pane pane 0 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + \\prompt % + , + } }, + }, + // capture-pane pane 0 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + // History has 2 lines with "prompt %" (padded to screen width) + try testing.expect(std.mem.containsAtLeast(u8, str, 2, "prompt %")); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + } + }).check, + }, + // capture-pane pane 0 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 0 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 primary history + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + }, + // capture-pane pane 4 primary visible + .{ + .input = .{ .tmux = .{ + .block_end = + \\prompt % + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const screen: *Screen = pane.terminal.screens.active; + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .history = .{} }, + ); + defer testing.allocator.free(str); + try testing.expectEqualStrings("prompt %", str); + } + { + const str = try screen.dumpStringAlloc( + testing.allocator, + .{ .active = .{} }, + ); + defer testing.allocator.free(str); + // Active screen starts with "prompt %" at beginning + try testing.expect(std.mem.startsWith(u8, str, "prompt %")); + } + } + }).check, + }, + // capture-pane pane 4 alternate history (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // capture-pane pane 4 alternate visible (empty) + .{ .input = .{ .tmux = .{ .block_end = "" } } }, + // list-panes output with terminal state + .{ + .input = .{ .tmux = .{ + .block_end = + \\%0;42;0;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;39;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + \\%4;10;5;1;;;;0;4294967295;4294967295;0;1;0;0;0;0;0;0;0;0;0;;;0;37;8,16,24,32,40,48,56,64,72,80,88,96,104,112,120,128,136,144,152,160 + , + } }, + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + // Pane 0: cursor at (42, 0), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(0).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(42, screen.cursor.x); + try testing.expectEqual(0, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + // Pane 4: cursor at (10, 5), cursor visible, wraparound on + { + const pane: *Viewer.Pane = v.panes.getEntry(4).?.value_ptr; + const t: *Terminal = &pane.terminal; + const screen: *Screen = t.screens.get(.primary).?; + try testing.expectEqual(10, screen.cursor.x); + try testing.expectEqual(5, screen.cursor.y); + try testing.expect(t.modes.get(.cursor_visible)); + try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.modes.get(.insert)); + try testing.expect(!t.modes.get(.origin)); + try testing.expect(!t.modes.get(.keypad_keys)); + try testing.expect(!t.modes.get(.cursor_keys)); + } + } + }).check, + }, + .{ + .input = .{ .tmux = .exit }, + .contains_tags = &.{.exit}, + }, + }); +} From 29bb18d8cd20ea092d4023a89563bfc9f0f90fbd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:33:56 -0800 Subject: [PATCH 26/28] terminal/tmux: grab tmux version on startup --- src/terminal/tmux/output.zig | 11 ++++ src/terminal/tmux/viewer.zig | 120 ++++++++++++++++++++++++++++++++--- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index 02dca23e6..6b8073e44 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -154,6 +154,8 @@ pub const Variable = enum { scroll_region_upper, /// Unique session ID prefixed with `$` (e.g., `$0`, `$42`). session_id, + /// Server version (e.g., `3.5a`). + version, /// Unique window ID prefixed with `@` (e.g., `@0`, `@42`). window_id, /// Width of window. @@ -213,6 +215,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => value, }; @@ -253,6 +256,7 @@ pub const Variable = enum { .cursor_colour, .cursor_shape, .pane_tabs, + .version, .window_layout, => []const u8, }; @@ -482,6 +486,13 @@ test "parse pane_tabs" { try testing.expectEqualStrings("0", try Variable.parse(.pane_tabs, "0")); } +test "parse version" { + try testing.expectEqualStrings("3.5a", try Variable.parse(.version, "3.5a")); + try testing.expectEqualStrings("3.5", try Variable.parse(.version, "3.5")); + try testing.expectEqualStrings("next-3.5", try Variable.parse(.version, "next-3.5")); + try testing.expectEqualStrings("", try Variable.parse(.version, "")); +} + test "parseFormatStruct single field" { const T = FormatStruct(&.{.session_id}); const result = try parseFormatStruct(T, "$42", ' '); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 5384e293f..002f85c5f 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -61,6 +61,11 @@ pub const Viewer = struct { /// The current session ID we're attached to. session_id: usize, + /// The tmux server version string (e.g., "3.5a"). We capture this + /// on startup because it will allow us to change behavior between + /// versions as necessary. + tmux_version: []const u8, + /// The list of commands we've sent that we want to send and wait /// for a response for. We only send one command at a time just /// to avoid any possible confusion around ordering. @@ -168,6 +173,7 @@ pub const Viewer = struct { // until we receive a session-changed notification which will // set this to a real value. .session_id = 0, + .tmux_version = "", .command_queue = command_queue, .windows = .empty, .panes = .empty, @@ -191,6 +197,9 @@ pub const Viewer = struct { while (it.next()) |kv| kv.value_ptr.deinit(self.alloc); self.panes.deinit(self.alloc); } + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } self.action_arena.promote(self.alloc).deinit(); } @@ -273,9 +282,10 @@ pub const Viewer = struct { var arena = self.action_arena.promote(self.alloc); defer self.action_arena = arena.state; _ = arena.reset(.free_all); + return self.enterCommandQueue( arena.allocator(), - .list_windows, + &.{ .tmux_version, .list_windows }, ) catch { log.warn("failed to queue command, becoming defunct", .{}); return self.defunct(); @@ -626,6 +636,9 @@ pub const Viewer = struct { try replacement.queueCommands(&.{.list_windows}); replacement.state = .command_queue; + // Transfer preserved version to replacement + replacement.tmux_version = try replacement.alloc.dupe(u8, self.tmux_version); + // Save arena state back before swap replacement.action_arena = arena.state; @@ -698,9 +711,33 @@ pub const Viewer = struct { cap.id, content, ), + + .tmux_version => try self.receivedTmuxVersion(content), } } + fn receivedTmuxVersion( + self: *Viewer, + content: []const u8, + ) !void { + const line = std.mem.trim(u8, content, " \t\r\n"); + if (line.len == 0) return; + + const data = output.parseFormatStruct( + Format.tmux_version.Struct(), + line, + Format.tmux_version.delim, + ) catch |err| { + log.info("failed to parse tmux version: {s}", .{line}); + return err; + }; + + if (self.tmux_version.len > 0) { + self.alloc.free(self.tmux_version); + } + self.tmux_version = try self.alloc.dupe(u8, data.version); + } + fn receivedListWindows( self: *Viewer, arena_alloc: Allocator, @@ -1031,22 +1068,23 @@ pub const Viewer = struct { } /// Enters the command queue state from any other state, queueing - /// the command and returning an action to execute the first command. + /// the commands and returning an action to execute the first command. fn enterCommandQueue( self: *Viewer, arena_alloc: Allocator, - command: Command, + commands: []const Command, ) Allocator.Error![]const Action { assert(self.state != .command_queue); + assert(commands.len > 0); // Build our command string to send for the action. var builder: std.Io.Writer.Allocating = .init(arena_alloc); - command.formatCommand(&builder.writer) catch return error.OutOfMemory; + commands[0].formatCommand(&builder.writer) catch return error.OutOfMemory; const action: Action = .{ .command = builder.writer.buffered() }; - // Add our command - try self.command_queue.ensureUnusedCapacity(self.alloc, 1); - self.command_queue.appendAssumeCapacity(command); + // Add our commands + try self.command_queue.ensureUnusedCapacity(self.alloc, commands.len); + for (commands) |cmd| self.command_queue.appendAssumeCapacity(cmd); // Move into the command queue state self.state = .command_queue; @@ -1128,6 +1166,9 @@ const Command = union(enum) { /// are part of the output so we can map it back to our panes. pane_state, + /// Get the tmux server version. + tmux_version, + /// User command. This is a command provided by the user. Since /// this is user provided, we can't be sure what it is. user: []const u8, @@ -1143,6 +1184,7 @@ const Command = union(enum) { .pane_history, .pane_visible, .pane_state, + .tmux_version, => {}, .user => |v| alloc.free(v), }; @@ -1196,6 +1238,11 @@ const Command = union(enum) { .{comptime Format.list_panes.comptimeFormat()}, )), + .tmux_version => try writer.writeAll(std.fmt.comptimePrint( + "display-message -p '{s}'\n", + .{comptime Format.tmux_version.comptimeFormat()}, + )), + .user => |v| try writer.writeAll(v), } } @@ -1260,6 +1307,11 @@ const Format = struct { }, }; + const tmux_version: Format = .{ + .delim = ' ', + .vars = &.{.version}, + }; + /// The format string, available at comptime. pub fn comptimeFormat(comptime self: Format) []const u8 { return output.comptimeFormat(self.vars, self.delim); @@ -1378,6 +1430,11 @@ test "session changed resets state" { .id = 1, .name = "first", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive window layout with two panes (same format as "initial flow" test) @@ -1393,10 +1450,11 @@ test "session changed resets state" { try testing.expectEqual(1, v.session_id); try testing.expectEqual(1, v.windows.items.len); try testing.expectEqual(2, v.panes.count()); + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, - // Now session changes - should reset everything + // Now session changes - should reset everything but keep version .{ .input = .{ .tmux = .{ .session_changed = .{ .id = 2, @@ -1420,6 +1478,8 @@ test "session changed resets state" { try testing.expectEqual(0, v.windows.items.len); // Old panes should be cleared try testing.expectEqual(0, v.panes.count()); + // Version should still be preserved + try testing.expectEqualStrings("3.5a", v.tmux_version); } }).check, }, @@ -1460,13 +1520,23 @@ test "initial flow" { .id = 42, .name = "main", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(42, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + .check = (struct { + fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { + try testing.expectEqualStrings("3.5a", v.tmux_version); + } + }).check, + }, .{ .input = .{ .tmux = .{ .block_end = @@ -1632,6 +1702,11 @@ test "layout change" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1698,6 +1773,11 @@ test "layout_change does not return command when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1754,6 +1834,11 @@ test "layout_change returns command when queue was empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1816,6 +1901,11 @@ test "window_add queues list_windows when queue empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1872,6 +1962,11 @@ test "window_add queues list_windows when queue not empty" { .id = 1, .name = "test", } } }, + .contains_command = "display-message", + }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, .contains_command = "list-windows", }, // Receive initial window layout with one pane @@ -1924,13 +2019,18 @@ test "two pane flow with pane state" { .id = 0, .name = "0", } } }, - .contains_command = "list-windows", + .contains_command = "display-message", .check = (struct { fn check(v: *Viewer, _: []const Viewer.Action) anyerror!void { try testing.expectEqual(0, v.session_id); } }).check, }, + // Receive version response, which triggers list-windows + .{ + .input = .{ .tmux = .{ .block_end = "3.5a" } }, + .contains_command = "list-windows", + }, // list-windows output with 2 panes in a vertical split .{ .input = .{ .tmux = .{ From b3e7c922630398457a301c3fcd2921bdc282e24b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:34:35 -0800 Subject: [PATCH 27/28] fmt --- src/terminal/tmux/control.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index 79ed530ec..dbc64b340 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -555,7 +555,7 @@ pub const Notification = union(enum) { try writer.writeAll(" }"); } } - }; +}; test "tmux begin/end empty" { const testing = std.testing; From 37f467c023e901c9125e940fdc379d1bf36c1d06 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 10 Dec 2025 10:37:48 -0800 Subject: [PATCH 28/28] terminal/tmux: docs --- src/terminal/tmux/viewer.zig | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 002f85c5f..0fcaaf207 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -51,6 +51,110 @@ const COMMAND_QUEUE_INITIAL = 8; /// /// This struct helps move through a state machine of connecting to a tmux /// session, negotiating capabilities, listing window state, etc. +/// +/// ## Viewer Lifecycle +/// +/// The viewer progresses through several states from initial connection +/// to steady-state operation. Here is the full flow: +/// +/// ``` +/// ┌─────────────────────────────────────────────┐ +/// │ TMUX CONTROL MODE START │ +/// │ (DCS 1000p received by host) │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_block │ +/// │ │ +/// │ Wait for initial %begin/%end block from │ +/// │ tmux. This is the response to the initial │ +/// │ command (e.g., "attach -t 0"). │ +/// └─────────────────┬───────────────────────────┘ +/// │ %end / %error +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ startup_session │ +/// │ │ +/// │ Wait for %session-changed notification │ +/// │ to get the initial session ID. │ +/// └─────────────────┬───────────────────────────┘ +/// │ %session-changed +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ command_queue │ +/// │ │ +/// │ Main operating state. Process commands │ +/// │ sequentially and handle notifications. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ┌───────────────────────────┼───────────────────────────┐ +/// │ │ │ +/// ▼ ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ ┌────────────────────────┐ +/// │ tmux_version │ │ list_windows │ │ %output / %layout- │ +/// │ │ │ │ │ change / etc. │ +/// │ Query tmux version for │ │ Get all windows in the │ │ │ +/// │ compatibility checks. │ │ current session. │ │ Handle live updates │ +/// └──────────────────────────┘ └────────────┬─────────────┘ │ from tmux server. │ +/// │ └────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ syncLayouts │ +/// │ │ +/// │ For each window, parse layout and sync │ +/// │ panes. New panes trigger capture commands. │ +/// └─────────────────┬───────────────────────────┘ +/// │ +/// ┌───────────────────────────┴───────────────────────────┐ +/// │ For each new pane: │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (primary screen) │ │ (primary screen) │ +/// │ │ │ │ +/// │ Capture scrollback │ │ Capture visible area │ +/// │ history into terminal. │ │ into terminal. │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// ▼ ▼ +/// ┌──────────────────────────┐ ┌──────────────────────────┐ +/// │ pane_history │ │ pane_visible │ +/// │ (alternate screen) │ │ (alternate screen) │ +/// └──────────────────────────┘ └──────────────────────────┘ +/// │ │ +/// └───────────────────────────┬───────────────────────────┘ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ pane_state │ +/// │ │ +/// │ Query cursor position, cursor style, │ +/// │ and alternate screen mode for all panes. │ +/// └─────────────────────────────────────────────┘ +/// │ +/// ▼ +/// ┌─────────────────────────────────────────────┐ +/// │ READY FOR OPERATION │ +/// │ │ +/// │ Panes are populated with content. The │ +/// │ viewer handles %output for live updates, │ +/// │ %layout-change for pane changes, and │ +/// │ %session-changed for session switches. │ +/// └─────────────────────────────────────────────┘ +/// ``` +/// +/// ## Error Handling +/// +/// At any point, if an unrecoverable error occurs or tmux sends `%exit`, +/// the viewer transitions to the `defunct` state and emits an `.exit` action. +/// +/// ## Session Changes +/// +/// When `%session-changed` is received during `command_queue` state, the +/// viewer resets itself completely: clears all windows/panes, emits an +/// empty windows action, and restarts the `list_windows` flow for the new +/// session. +/// pub const Viewer = struct { /// Allocator used for all internal state. alloc: Allocator,