From 9298e9fad39396d37255587e60769f348a66a015 Mon Sep 17 00:00:00 2001 From: daiimus Date: Sat, 7 Mar 2026 12:39:33 -0800 Subject: [PATCH 1/2] fix(tmux): complete control mode protocol parsing Add missing protocol handling for tmux control mode: control.zig: - Add unescapeOctal() to decode \NNN octal escapes in %output data. tmux encodes bytes <32 and backslash as octal; without decoding, all ANSI escape sequences arrived as literal text (e.g. \033 instead of ESC). 9 unit tests + 5 integration tests. - Parse %window-close notification with window ID extraction. - Parse %session-window-changed notification with session/window IDs. - Parse %exit notification explicitly (previously only generated synthetically on unexpected input in idle state). - Add window_close and session_window_changed to Notification union. output.zig: - Add window_name variable for list-windows format strings. - 1 test. --- src/terminal/tmux/control.zig | 333 +++++++++++++++++++++++++++++++++- src/terminal/tmux/output.zig | 11 ++ 2 files changed, 341 insertions(+), 3 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index dbc64b340..ae04aafa5 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -56,6 +56,35 @@ pub const Parser = struct { self.buffer.deinit(); } + /// Unescape tmux control mode octal escapes in-place. + /// tmux encodes bytes <32 and backslash as \NNN (3 octal digits). + /// Returns the decoded slice (which is a prefix of the input). + pub fn unescapeOctal(data: []u8) []u8 { + var read: usize = 0; + var write: usize = 0; + while (read < data.len) { + if (data[read] == '\\' and read + 3 < data.len and + isOctalDigit(data[read + 1]) and + isOctalDigit(data[read + 2]) and + isOctalDigit(data[read + 3])) + { + data[write] = (@as(u8, data[read + 1] - '0') << 6) | + (@as(u8, data[read + 2] - '0') << 3) | + (data[read + 3] - '0'); + read += 4; + } else { + data[write] = data[read]; + read += 1; + } + write += 1; + } + return data[0..write]; + } + + fn isOctalDigit(c: u8) bool { + return c >= '0' and c <= '7'; + } + // Handle a byte of input. // // If we reach our byte limit this will return OutOfMemory. It only @@ -197,9 +226,15 @@ pub const Parser = struct { line[@intCast(starts[1])..@intCast(ends[1])], 10, ) catch unreachable; - const data = line[@intCast(starts[2])..@intCast(ends[2])]; - // Important: do not clear buffer here since name points to it + // tmux control mode encodes bytes <32 and backslash as octal + // escapes (\NNN) in %output data. Decode in-place — this is + // safe because the buffer is heap-allocated and we own it, and + // decoded output is always <= encoded length. + const raw = @constCast(line[@intCast(starts[2])..@intCast(ends[2])]); + const data = unescapeOctal(raw); + + // Important: do not clear buffer here since data points to it self.state = .idle; return .{ .output = .{ .pane_id = id, .data = data } }; } else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: { @@ -434,6 +469,78 @@ pub const Parser = struct { // Important: do not clear buffer here since client/name point to it self.state = .idle; return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } }; + } else if (std.mem.eql(u8, cmd, "%window-close")) cmd: { + var re = oni.Regex.init( + "^%window-close @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .window_close = .{ .id = id } }; + } else if (std.mem.eql(u8, cmd, "%session-window-changed")) cmd: { + var re = oni.Regex.init( + "^%session-window-changed \\$([0-9]+) @([0-9]+)$", + .{ .capture_group = true }, + oni.Encoding.utf8, + oni.Syntax.default, + null, + ) catch |err| { + log.warn("regex init failed error={}", .{err}); + return error.RegexError; + }; + defer re.deinit(); + + var region = re.search(line, .{}) catch |err| { + log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err }); + break :cmd; + }; + defer region.deinit(); + const starts = region.starts(); + const ends = region.ends(); + + const session_id = std.fmt.parseInt( + usize, + line[@intCast(starts[1])..@intCast(ends[1])], + 10, + ) catch unreachable; + const window_id = std.fmt.parseInt( + usize, + line[@intCast(starts[2])..@intCast(ends[2])], + 10, + ) catch unreachable; + + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .session_window_changed = .{ .session_id = session_id, .window_id = window_id } }; + } else if (std.mem.eql(u8, cmd, "%exit")) { + // tmux sends %exit when the control mode client is exiting. + // Return the exit notification so the viewer and stream handler + // can perform proper cleanup. + self.buffer.clearRetainingCapacity(); + self.state = .idle; + return .{ .exit = {} }; } else { // Unknown notification, log it and return to idle state. log.warn("unknown tmux control mode notification={s}", .{cmd}); @@ -480,7 +587,7 @@ pub const Notification = union(enum) { /// Raw output from a pane. output: struct { pane_id: usize, - data: []const u8, // unescaped + data: []const u8, // unescaped (octal \NNN decoded in-place) }, /// The client is now attached to the session with ID session-id, which is @@ -519,6 +626,17 @@ pub const Notification = union(enum) { pane_id: usize, }, + /// The window with ID window-id was closed. + window_close: struct { + id: usize, + }, + + /// The session's current window changed to window-id in session-id. + session_window_changed: struct { + session_id: usize, + window_id: usize, + }, + /// The client has detached. client_detached: struct { client: []const u8, @@ -723,3 +841,212 @@ test "tmux client-session-changed" { try testing.expectEqual(2, n.client_session_changed.session_id); try testing.expectEqualStrings("mysession", n.client_session_changed.name); } + +test "unescape octal: no escapes passthrough" { + var data = "hello world".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqualStrings("hello world", result); +} + +test "unescape octal: single ESC" { + // \033 = ESC (0x1B) + var data = "\\033".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqual(@as(usize, 1), result.len); + try std.testing.expectEqual(@as(u8, 0x1B), result[0]); +} + +test "unescape octal: CR LF sequence" { + // \015\012 = CR LF + var data = "\\015\\012".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqual(@as(usize, 2), result.len); + try std.testing.expectEqual(@as(u8, 0x0D), result[0]); + try std.testing.expectEqual(@as(u8, 0x0A), result[1]); +} + +test "unescape octal: escaped backslash" { + // \134 = backslash + var data = "\\134".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqual(@as(usize, 1), result.len); + try std.testing.expectEqual(@as(u8, '\\'), result[0]); +} + +test "unescape octal: mixed escaped and literal" { + // "hello\033[31mworld" = "hello" + ESC + "[31mworld" + var data = "hello\\033[31mworld".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqual(@as(usize, 15), result.len); + try std.testing.expectEqualStrings("hello", result[0..5]); + try std.testing.expectEqual(@as(u8, 0x1B), result[5]); + try std.testing.expectEqualStrings("[31mworld", result[6..]); +} + +test "unescape octal: multiple escapes in sequence" { + // \033\033 = ESC ESC + var data = "\\033\\033".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqual(@as(usize, 2), result.len); + try std.testing.expectEqual(@as(u8, 0x1B), result[0]); + try std.testing.expectEqual(@as(u8, 0x1B), result[1]); +} + +test "unescape octal: backspace encoding from device" { + // \010ls = BS + "ls" (the exact pattern seen in device logs) + var data = "\\010ls".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqual(@as(usize, 3), result.len); + try std.testing.expectEqual(@as(u8, 0x08), result[0]); + try std.testing.expectEqualStrings("ls", result[1..]); +} + +test "unescape octal: trailing backslash not enough digits" { + // Backslash at end without 3 digits should pass through + var data = "abc\\".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqualStrings("abc\\", result); +} + +test "unescape octal: backslash with non-octal digits" { + // \8 is not octal, should pass through + var data = "\\899".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqualStrings("\\899", result); +} + +test "tmux output with octal escapes" { + const testing = std.testing; + const alloc = testing.allocator; + + // Simulate: %output %2 hello\033[31mworld + // tmux sends ESC as \033 in %output data + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %2 hello\\033[31mworld") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(2, n.output.pane_id); + // Data should be decoded: "hello" + ESC + "[31mworld" + try testing.expectEqual(@as(usize, 15), n.output.data.len); + try testing.expectEqualStrings("hello", n.output.data[0..5]); + try testing.expectEqual(@as(u8, 0x1B), n.output.data[5]); + try testing.expectEqualStrings("[31mworld", n.output.data[6..]); +} + +test "tmux output with escaped backslash" { + const testing = std.testing; + const alloc = testing.allocator; + + // %output %5 path\\134file => "path\file" + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %5 path\\134file") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(5, n.output.pane_id); + try testing.expectEqualStrings("path\\file", n.output.data); +} + +test "tmux output with CR LF" { + const testing = std.testing; + const alloc = testing.allocator; + + // %output %1 line1\015\012line2 + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%output %1 line1\\015\\012line2") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(1, n.output.pane_id); + try testing.expectEqual(@as(usize, 12), n.output.data.len); + try testing.expectEqualStrings("line1", n.output.data[0..5]); + try testing.expectEqual(@as(u8, 0x0D), n.output.data[5]); + try testing.expectEqual(@as(u8, 0x0A), n.output.data[6]); + try testing.expectEqualStrings("line2", n.output.data[7..]); +} + +test "tmux output with raw UTF-8 box drawing" { + const testing = std.testing; + const alloc = testing.allocator; + + // tmux sends UTF-8 bytes >= 0x80 raw (unescaped) in %output. + // \xe2\x94\x84 = U+2504 (box drawing light triple dash horizontal) + // \xe2\x80\xa2 = U+2022 (bullet) + // \xe2\x94\x81 = U+2501 (box drawing heavy horizontal) + // \xe2\x95\x90 = U+2550 (box drawing double horizontal) + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + + // Build the %output line with raw UTF-8 bytes + const prefix = "%output %3 "; + const box_chars = "\xe2\x94\x84\xe2\x80\xa2\xe2\x94\x81\xe2\x95\x90"; + const line = prefix ++ box_chars; + + for (line) |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(3, n.output.pane_id); + // Data should be the raw UTF-8 bytes, unchanged + try testing.expectEqual(@as(usize, 12), n.output.data.len); + try testing.expectEqualStrings(box_chars, n.output.data); +} + +test "tmux output with mixed UTF-8 and octal escapes" { + const testing = std.testing; + const alloc = testing.allocator; + + // ESC[31m (octal-escaped ESC) + box drawing (raw UTF-8) + ESC[0m (octal-escaped ESC) + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + + const input = "%output %1 \\033[31m\xe2\x94\x84\\033[0m"; + for (input) |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .output); + try testing.expectEqual(1, n.output.pane_id); + // Expected: ESC + "[31m" + e2 94 84 + ESC + "[0m" + // ESC=1 + [31m=4 + box=3 + ESC=1 + [0m=3 = 12 bytes + try testing.expectEqual(@as(usize, 12), n.output.data.len); + try testing.expectEqual(@as(u8, 0x1B), n.output.data[0]); // ESC + try testing.expectEqualStrings("[31m", n.output.data[1..5]); + try testing.expectEqualStrings("\xe2\x94\x84", n.output.data[5..8]); // box drawing + try testing.expectEqual(@as(u8, 0x1B), n.output.data[8]); // ESC + try testing.expectEqualStrings("[0m", n.output.data[9..12]); +} + +test "tmux window-close" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%window-close @7") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .window_close); + try testing.expectEqual(7, n.window_close.id); +} + +test "tmux session-window-changed" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%session-window-changed $3 @5") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .session_window_changed); + try testing.expectEqual(3, n.session_window_changed.session_id); + try testing.expectEqual(5, n.session_window_changed.window_id); +} + +test "tmux exit notification" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%exit") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .exit); +} diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index 6b8073e44..78a8fa064 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -167,6 +167,8 @@ pub const Variable = enum { /// encodes pane dimensions as `WxH,X,Y[,ID]` with `{...}` for horizontal /// splits and `[...]` for vertical splits. window_layout, + /// Name of the window (e.g., "bash", "vim"). + window_name, /// Pane wrap flag. wrap_flag, @@ -217,6 +219,7 @@ pub const Variable = enum { .pane_tabs, .version, .window_layout, + .window_name, => value, }; } @@ -258,6 +261,7 @@ pub const Variable = enum { .pane_tabs, .version, .window_layout, + .window_name, => []const u8, }; } @@ -352,6 +356,13 @@ 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 window_name" { + try testing.expectEqualStrings("bash", try Variable.parse(.window_name, "bash")); + try testing.expectEqualStrings("vim", try Variable.parse(.window_name, "vim")); + try testing.expectEqualStrings("", try Variable.parse(.window_name, "")); + try testing.expectEqualStrings("my window", try Variable.parse(.window_name, "my window")); +} + test "parse cursor_flag" { try testing.expectEqual(true, try Variable.parse(.cursor_flag, "1")); try testing.expectEqual(false, try Variable.parse(.cursor_flag, "0")); From 78615e02811cb7d92114bc23769b7e17264c91ef Mon Sep 17 00:00:00 2001 From: daiimus Date: Sat, 7 Mar 2026 13:44:25 -0800 Subject: [PATCH 2/2] fix(tmux): address review feedback for control mode parsing - Widen unescapeOctal intermediate to u16 to prevent overflow on values above 255 (\400+), treating them as literal characters - Add tests for octal overflow boundary (max u8 \377, overflow \400) - Add test for %exit with reason string (e.g. "lost-server") - Fix test name "parse window_name" -> "parse window name" - Add no-op switch arms for window_close and session_window_changed notifications in viewer.zig to fix compilation AI: Claude Code (claude-sonnet-4-20250514). Assisted with test generation and review feedback triage. All changes reviewed and understood by contributor. --- src/terminal/tmux/control.zig | 43 ++++++++++++++++++++++++++++++++--- src/terminal/tmux/output.zig | 2 +- src/terminal/tmux/viewer.zig | 6 +++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/terminal/tmux/control.zig b/src/terminal/tmux/control.zig index ae04aafa5..6e69379ae 100644 --- a/src/terminal/tmux/control.zig +++ b/src/terminal/tmux/control.zig @@ -68,10 +68,19 @@ pub const Parser = struct { isOctalDigit(data[read + 2]) and isOctalDigit(data[read + 3])) { - data[write] = (@as(u8, data[read + 1] - '0') << 6) | - (@as(u8, data[read + 2] - '0') << 3) | + // Compute the octal value using u16 to avoid overflow. + // Values above 255 (\400+) cannot represent a byte, so + // treat them as literal characters. + const val: u16 = (@as(u16, data[read + 1] - '0') << 6) | + (@as(u16, data[read + 2] - '0') << 3) | (data[read + 3] - '0'); - read += 4; + if (val <= std.math.maxInt(u8)) { + data[write] = @intCast(val); + read += 4; + } else { + data[write] = data[read]; + read += 1; + } } else { data[write] = data[read]; read += 1; @@ -915,6 +924,21 @@ test "unescape octal: backslash with non-octal digits" { try std.testing.expectEqualStrings("\\899", result); } +test "unescape octal: value exceeding u8 treated as literal" { + // \400 = 256 which overflows u8, should pass through as literal + var data = "\\400".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqualStrings("\\400", result); +} + +test "unescape octal: max u8 value" { + // \377 = 255, the maximum valid byte value + var data = "\\377".*; + const result = Parser.unescapeOctal(&data); + try std.testing.expectEqual(@as(u8, 0xFF), result[0]); + try std.testing.expectEqual(@as(usize, 1), result.len); +} + test "tmux output with octal escapes" { const testing = std.testing; const alloc = testing.allocator; @@ -1050,3 +1074,16 @@ test "tmux exit notification" { const n = (try c.put('\n')).?; try testing.expect(n == .exit); } + +test "tmux exit notification with reason" { + // tmux sends "%exit lost-server" when the server dies. + // The reason string is ignored but must not break parsing. + const testing = std.testing; + const alloc = testing.allocator; + + var c: Parser = .{ .buffer = .init(alloc) }; + defer c.deinit(); + for ("%exit lost-server") |byte| try testing.expect(try c.put(byte) == null); + const n = (try c.put('\n')).?; + try testing.expect(n == .exit); +} diff --git a/src/terminal/tmux/output.zig b/src/terminal/tmux/output.zig index 78a8fa064..bf4dd4af9 100644 --- a/src/terminal/tmux/output.zig +++ b/src/terminal/tmux/output.zig @@ -356,7 +356,7 @@ 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 window_name" { +test "parse window name" { try testing.expectEqualStrings("bash", try Variable.parse(.window_name, "bash")); try testing.expectEqualStrings("vim", try Variable.parse(.window_name, "vim")); try testing.expectEqualStrings("", try Variable.parse(.window_name, "")); diff --git a/src/terminal/tmux/viewer.zig b/src/terminal/tmux/viewer.zig index 62a0f1d00..70febfdb8 100644 --- a/src/terminal/tmux/viewer.zig +++ b/src/terminal/tmux/viewer.zig @@ -518,6 +518,12 @@ pub const Viewer = struct { // We don't use window names for anything, currently. .window_renamed => {}, + // A window was closed or a session-window link changed. + // These are currently no-ops; proper handling is pending + // viewer correctness work. + .window_close => {}, + .session_window_changed => {}, + // This is for other clients, which we don't do anything about. // For us, we'll get `exit` or `session_changed`, respectively. .client_detached,