repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

zmx / src
Eric Bower  ·  2026-04-26

util.zig

   1const std = @import("std");
   2const posix = std.posix;
   3const ghostty_vt = @import("ghostty-vt");
   4const ipc = @import("ipc.zig");
   5const socket = @import("socket.zig");
   6const testing = std.testing;
   7
   8pub const SessionEntry = struct {
   9    name: []const u8,
  10    pid: ?i32,
  11    clients_len: ?usize,
  12    is_error: bool,
  13    error_name: ?[]const u8,
  14    cmd: ?[]const u8 = null,
  15    cwd: ?[]const u8 = null,
  16    created_at: u64,
  17    task_ended_at: ?u64,
  18    task_exit_code: ?u8,
  19
  20    pub fn deinit(self: SessionEntry, alloc: std.mem.Allocator) void {
  21        alloc.free(self.name);
  22        if (self.cmd) |cmd| alloc.free(cmd);
  23        if (self.cwd) |cwd| alloc.free(cwd);
  24    }
  25
  26    pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
  27        return std.mem.order(u8, a.name, b.name) == .lt;
  28    }
  29};
  30
  31pub fn get_session_entries(
  32    alloc: std.mem.Allocator,
  33    socket_dir: []const u8,
  34) !std.ArrayList(SessionEntry) {
  35    var dir = try std.fs.openDirAbsolute(socket_dir, .{ .iterate = true });
  36    defer dir.close();
  37    var iter = dir.iterate();
  38
  39    var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
  40
  41    while (try iter.next()) |entry| {
  42        const exists = socket.sessionExists(dir, entry.name) catch continue;
  43        if (exists) {
  44            const name = try alloc.dupe(u8, entry.name);
  45            errdefer alloc.free(name);
  46
  47            const socket_path = socket.getSocketPath(alloc, socket_dir, entry.name) catch |err| switch (err) {
  48                error.NameTooLong => continue,
  49                error.OutOfMemory => return err,
  50            };
  51            defer alloc.free(socket_path);
  52
  53            const result = ipc.probeSession(alloc, socket_path) catch |err| {
  54                try sessions.append(alloc, .{
  55                    .name = name,
  56                    .pid = null,
  57                    .clients_len = null,
  58                    .is_error = true,
  59                    .error_name = @errorName(err),
  60                    .created_at = 0,
  61                    .task_exit_code = 1,
  62                    .task_ended_at = 0,
  63                });
  64                // Only clean up when the daemon is definitively gone. A busy
  65                // daemon can miss the probe timeout; deleting its socket
  66                // orphans it permanently.
  67                if (err == error.ConnectionRefused) {
  68                    socket.cleanupStaleSocket(dir, entry.name);
  69                }
  70                continue;
  71            };
  72            posix.close(result.fd);
  73
  74            // Extract cmd and cwd from the fixed-size arrays. Lengths come
  75            // off the wire (u16 range), so clamp to the actual array size.
  76            const cmd_len = @min(result.info.cmd_len, ipc.MAX_CMD_LEN);
  77            const cwd_len = @min(result.info.cwd_len, ipc.MAX_CWD_LEN);
  78            const cmd: ?[]const u8 = if (cmd_len > 0)
  79                alloc.dupe(u8, result.info.cmd[0..cmd_len]) catch null
  80            else
  81                null;
  82            const cwd: ?[]const u8 = if (cwd_len > 0)
  83                alloc.dupe(u8, result.info.cwd[0..cwd_len]) catch null
  84            else
  85                null;
  86
  87            try sessions.append(alloc, .{
  88                .name = name,
  89                .pid = result.info.pid,
  90                .clients_len = result.info.clients_len,
  91                .is_error = false,
  92                .error_name = null,
  93                .cmd = cmd,
  94                .cwd = cwd,
  95                .created_at = result.info.created_at,
  96                .task_ended_at = result.info.task_ended_at,
  97                .task_exit_code = result.info.task_exit_code,
  98            });
  99        }
 100    }
 101
 102    return sessions;
 103}
 104
 105pub fn shellNeedsQuoting(arg: []const u8) bool {
 106    if (arg.len == 0) return true;
 107    for (arg) |ch| {
 108        switch (ch) {
 109            ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']' => return true,
 110            '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
 111            else => {},
 112        }
 113    }
 114    return false;
 115}
 116
 117pub fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
 118    // Always use single quotes (like Python's shlex.quote). Inside single
 119    // quotes nothing is special except ' itself, which we handle with the
 120    // '\'' trick (end quote, escaped literal quote, reopen quote).
 121    var len: usize = 2;
 122    for (arg) |ch| {
 123        len += if (ch == '\'') 4 else 1;
 124    }
 125    const buf = try alloc.alloc(u8, len);
 126    var i: usize = 0;
 127    buf[i] = '\'';
 128    i += 1;
 129    for (arg) |ch| {
 130        if (ch == '\'') {
 131            @memcpy(buf[i..][0..4], "'\\''");
 132            i += 4;
 133        } else {
 134            buf[i] = ch;
 135            i += 1;
 136        }
 137    }
 138    buf[i] = '\'';
 139    return buf;
 140}
 141
 142const DA1_QUERY = "\x1b[c";
 143const DA1_QUERY_EXPLICIT = "\x1b[0c";
 144const DA2_QUERY = "\x1b[>c";
 145const DA2_QUERY_EXPLICIT = "\x1b[>0c";
 146const DA1_RESPONSE = "\x1b[?62;22c";
 147const DA2_RESPONSE = "\x1b[>1;10;0c";
 148
 149pub fn respondToDeviceAttributes(alloc: std.mem.Allocator, buf: *std.ArrayList(u8), data: []const u8) void {
 150    // Scan for DA queries in PTY output and respond on behalf of the terminal.
 151    // This handles the case where no client is attached (e.g. zmx run)
 152    // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered.
 153    //
 154    // Responses are queued into the daemon's pty_write_buf (not written
 155    // directly) so they don't interleave with any already-buffered input —
 156    // e.g. a large `zmx run` payload still draining after the client
 157    // disconnected.
 158    //
 159    // DA1 query: ESC [ c  or  ESC [ 0 c
 160    // DA2 query: ESC [ > c  or  ESC [ > 0 c
 161    // DA1 response (from terminal): ESC [ ? ... c  (has '?' after '[')
 162    //
 163    // We must NOT match DA responses (which contain '?') as queries.
 164    var i: usize = 0;
 165    while (i < data.len) {
 166        if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') {
 167            // Skip DA responses which have '?' after CSI
 168            if (i + 2 < data.len and data[i + 2] == '?') {
 169                i += 3;
 170                continue;
 171            }
 172            if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) {
 173                buf.appendSlice(alloc, DA2_RESPONSE) catch {};
 174            } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) {
 175                buf.appendSlice(alloc, DA1_RESPONSE) catch {};
 176            }
 177        }
 178        i += 1;
 179    }
 180}
 181
 182fn matchSeq(data: []const u8, seq: []const u8) bool {
 183    if (data.len < seq.len) return false;
 184    return std.mem.eql(u8, data[0..seq.len], seq);
 185}
 186
 187/// OSC 133;A (prompt start) marker.
 188const OSC_133_A = "\x1b]133;A";
 189
 190/// Rewrite OSC 133;A sequences to include `redraw=0`, which tells the outer
 191/// terminal not to clear prompt lines on resize. This is necessary because
 192/// zmx sits between the shell and the outer terminal: from the outer terminal's
 193/// perspective, the foreground process (zmx client) cannot redraw prompts.
 194/// Without this, the outer terminal clears the prompt on resize expecting the
 195/// shell to redraw it, but the shell's redraw goes through zmx's IPC path with
 196/// cursor coordinates relative to the inner PTY, causing a cursor desync that
 197/// makes the prompt invisible.
 198/// See: https://github.com/neurosnap/zmx/issues/111
 199pub fn rewritePromptRedraw(alloc: std.mem.Allocator, data: []const u8) ?[]const u8 {
 200    // Quick scan: is there any OSC 133;A in this chunk?
 201    if (std.mem.indexOf(u8, data, OSC_133_A) == null) return null;
 202
 203    var result = std.ArrayList(u8).initCapacity(alloc, data.len + 200) catch return null;
 204    errdefer result.deinit(alloc);
 205    result.appendSlice(alloc, data) catch return null;
 206
 207    // Work backwards so index shifts don't invalidate later positions.
 208    var search_from: usize = result.items.len;
 209    while (search_from > 0) {
 210        const haystack = result.items[0..search_from];
 211        const pos = std.mem.lastIndexOf(u8, haystack, OSC_133_A) orelse break;
 212        search_from = pos;
 213
 214        const after = pos + OSC_133_A.len;
 215        if (after >= result.items.len) continue;
 216
 217        // Find the string terminator (BEL \x07 or ST \x1b\\).
 218        var term_pos: ?usize = null;
 219        var j = after;
 220        while (j < result.items.len) : (j += 1) {
 221            if (result.items[j] == '\x07') {
 222                term_pos = j;
 223                break;
 224            }
 225            if (result.items[j] == '\x1b' and j + 1 < result.items.len and result.items[j + 1] == '\\') {
 226                term_pos = j;
 227                break;
 228            }
 229        }
 230        const end = term_pos orelse continue;
 231
 232        // Check the parameter region between OSC_133_A and the terminator.
 233        const params = result.items[after..end];
 234
 235        // If redraw=0 already present, skip.
 236        if (std.mem.indexOf(u8, params, "redraw=0") != null) continue;
 237
 238        // If redraw= exists with a different value, replace it.
 239        if (std.mem.indexOf(u8, params, "redraw=")) |rdw_offset| {
 240            const abs_rdw = after + rdw_offset;
 241            const value_start = abs_rdw + "redraw=".len;
 242            var value_end = value_start;
 243            while (value_end < end and result.items[value_end] != ';') : (value_end += 1) {}
 244            result.replaceRange(alloc, value_start, value_end - value_start, "0") catch return null;
 245            continue;
 246        }
 247
 248        // No redraw= present. Insert ;redraw=0 before the terminator.
 249        result.replaceRange(alloc, end, 0, ";redraw=0") catch return null;
 250    }
 251
 252    // If nothing changed, free and return null.
 253    if (std.mem.eql(u8, result.items, data)) {
 254        result.deinit(alloc);
 255        return null;
 256    }
 257
 258    return result.toOwnedSlice(alloc) catch null;
 259}
 260
 261test "rewritePromptRedraw: no OSC 133;A returns null" {
 262    const result = rewritePromptRedraw(std.testing.allocator, "hello world");
 263    try std.testing.expect(result == null);
 264}
 265
 266test "rewritePromptRedraw: injects redraw=0 with BEL terminator" {
 267    const input = "\x1b]133;A\x07";
 268    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 269    defer std.testing.allocator.free(result);
 270    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
 271}
 272
 273test "rewritePromptRedraw: injects redraw=0 with ST terminator" {
 274    const input = "\x1b]133;A\x1b\\";
 275    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 276    defer std.testing.allocator.free(result);
 277    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x1b\\", result);
 278}
 279
 280test "rewritePromptRedraw: replaces existing redraw=1" {
 281    const input = "\x1b]133;A;redraw=1\x07";
 282    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 283    defer std.testing.allocator.free(result);
 284    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
 285}
 286
 287test "rewritePromptRedraw: replaces existing redraw=last" {
 288    const input = "\x1b]133;A;redraw=last\x07";
 289    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 290    defer std.testing.allocator.free(result);
 291    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
 292}
 293
 294test "rewritePromptRedraw: preserves redraw=0 (no-op)" {
 295    const result = rewritePromptRedraw(std.testing.allocator, "\x1b]133;A;redraw=0\x07");
 296    try std.testing.expect(result == null);
 297}
 298
 299test "rewritePromptRedraw: preserves other parameters" {
 300    const input = "\x1b]133;A;aid=14;cl=line\x07";
 301    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 302    defer std.testing.allocator.free(result);
 303    try std.testing.expectEqualStrings("\x1b]133;A;aid=14;cl=line;redraw=0\x07", result);
 304}
 305
 306test "rewritePromptRedraw: handles multiple markers" {
 307    const input = "before\x1b]133;A\x07middle\x1b]133;A;redraw=1\x07after";
 308    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 309    defer std.testing.allocator.free(result);
 310    try std.testing.expectEqualStrings("before\x1b]133;A;redraw=0\x07middle\x1b]133;A;redraw=0\x07after", result);
 311}
 312
 313test "rewritePromptRedraw: does not touch OSC 133;B or 133;C" {
 314    const input = "\x1b]133;B\x07\x1b]133;C\x07";
 315    const result = rewritePromptRedraw(std.testing.allocator, input);
 316    try std.testing.expect(result == null);
 317}
 318
 319test "rewritePromptRedraw: embedded in larger output" {
 320    const input = "some output\r\n\x1b]133;A\x07prompt$ \x1b]133;B\x07";
 321    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 322    defer std.testing.allocator.free(result);
 323    try std.testing.expectEqualStrings("some output\r\n\x1b]133;A;redraw=0\x07prompt$ \x1b]133;B\x07", result);
 324}
 325
 326pub fn findTaskExitMarker(output: []const u8) ?u8 {
 327    const marker = "ZMX_TASK_COMPLETED:";
 328
 329    // Search for marker in output
 330    if (std.mem.indexOf(u8, output, marker)) |idx| {
 331        const after_marker = output[idx + marker.len ..];
 332
 333        // Find the exit code number and newline
 334        var end_idx: usize = 0;
 335        while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
 336            end_idx += 1;
 337        }
 338
 339        const exit_code_str = after_marker[0..end_idx];
 340
 341        // Parse exit code
 342        if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
 343            return exit_code;
 344        } else |_| {
 345            std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str});
 346            return null;
 347        }
 348    }
 349
 350    return null;
 351}
 352
 353/// Detects Kitty keyboard protocol escape sequence for Ctrl+\.
 354pub fn isCtrlBackslash(buf: []const u8) bool {
 355    if (buf.len == 0) return false;
 356    return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100);
 357}
 358
 359/// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
 360pub fn isUpArrow(buf: []const u8) bool {
 361    return std.mem.eql(u8, buf, "\x1b[A") or std.mem.eql(u8, buf, "\x1b[1;1:1A");
 362}
 363
 364fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 365    // Scan for any CSI u sequence encoding in the buffer.
 366    var i: usize = 0;
 367    while (i + 2 < buf.len) : (i += 1) {
 368        if (buf[i] == 0x1b and buf[i + 1] == '[') {
 369            if (keypressWithMod(buf[i + 2 ..], expected_key, expected_mods)) return true;
 370        }
 371    }
 372    return false;
 373}
 374
 375/// Parses the general CSI u form:
 376///   CSI key-code[:alternates] ; modifiers[:event-type] [; text-codepoints] u
 377///
 378/// Event type is press (1 or absent) or repeat (2). Rejects release (3).
 379/// Tolerates additional modifiers (caps_lock, num_lock)
 380/// and alternate key sub-fields from the kitty protocol's progressive
 381/// enhancement flags.
 382fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 383    var pos: usize = 0;
 384
 385    // 1. Parse key code.
 386    const key_code = parseDecimal(buf, &pos) orelse return false;
 387    if (key_code != expected_key) return false;
 388
 389    // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key).
 390    while (pos < buf.len and buf[pos] == ':') {
 391        pos += 1; // consume ':'
 392        _ = parseDecimal(buf, &pos); // consume digits (may be empty for ::base)
 393    }
 394
 395    // 3. Expect ';' separator before modifiers.
 396    if (pos >= buf.len or buf[pos] != ';') return false;
 397    pos += 1;
 398
 399    // 4. Parse modifier value. Kitty encodes as 1 + bitfield.
 400    const mod_encoded = parseDecimal(buf, &pos) orelse return false;
 401    if (mod_encoded < 1) return false;
 402    const mod_raw = mod_encoded - 1;
 403
 404    // 5. Only accept intentional modifiers. Lock modifiers
 405    //    (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because
 406    //    they are ambient state, not deliberate key combinations.
 407    const intentional_mods = mod_raw & 0b00111111;
 408    if (expected_mods > 0 and expected_mods != intentional_mods) return false;
 409
 410    // 6. Parse optional event type after ':'.
 411    if (pos < buf.len and buf[pos] == ':') {
 412        pos += 1;
 413        const event_type = parseDecimal(buf, &pos) orelse return false;
 414        // 3 = release -- reject. Accept press (1) and repeat (2).
 415        if (event_type == 3) return false;
 416    }
 417
 418    // 7. Skip optional ';text-codepoints' section.
 419    if (pos < buf.len and buf[pos] == ';') {
 420        pos += 1;
 421        // Consume remaining digits and colons until 'u'.
 422        while (pos < buf.len and (std.ascii.isDigit(buf[pos]) or buf[pos] == ':')) {
 423            pos += 1;
 424        }
 425    }
 426
 427    // 8. Expect terminal 'u'.
 428    return pos < buf.len and buf[pos] == 'u';
 429}
 430
 431/// Parse a decimal integer from buf starting at pos, advancing pos past the
 432/// consumed digits. Returns null if no digits are present.
 433fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
 434    const start = pos.*;
 435    var value: u32 = 0;
 436    while (pos.* < buf.len and std.ascii.isDigit(buf[pos.*])) {
 437        value = value *% 10 +% (buf[pos.*] - '0');
 438        pos.* += 1;
 439    }
 440    if (pos.* == start) return null;
 441    return value;
 442}
 443
 444/// Detect if the payload contains user input that should be printed to the screen or
 445/// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
 446pub fn isUserInput(payload: []const u8) bool {
 447    var parser = ghostty_vt.Parser.init();
 448    for (payload) |c| {
 449        const actions = parser.next(c);
 450        for (actions) |action_opt| {
 451            const action = action_opt orelse continue;
 452            switch (action) {
 453                .print => return true, // printable characters
 454                .csi_dispatch => |csi| {
 455                    // kitty keyboard: CSI ... u or CSI ... ~
 456                    // legacy modified keys: CSI 27 ; ... ~
 457                    // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
 458                    if (csi.final == 'u' or csi.final == '~') return true;
 459                    // modified arrow keys (e.g., Ctrl+F sends CSI 1;5C in legacy mode)
 460                    if (csi.final >= 'A' and csi.final <= 'D' and csi.params.len > 1) return true;
 461                    // mouse events: CSI M (basic) or CSI < (SGR extended) - EXCLUDE these
 462                    // only intentional keyboard input should trigger leader switch
 463                    if (csi.final == 'M' or csi.final == '<') return false;
 464                    // focus events: CSI I (focus in) or CSI O (focus out) - EXCLUDE these
 465                    // these are automatic terminal events, not user typing
 466                    if (csi.final == 'I' or csi.final == 'O') return false;
 467                },
 468                .execute => |code| {
 469                    // looking for CR, LF, tab, and backspace
 470                    if (code == 0x0D or code == 0x0A or code == 0x09 or code == 0x08) return true;
 471                },
 472                else => {},
 473            }
 474        }
 475    }
 476    return false;
 477}
 478
 479pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
 480    var builder: std.Io.Writer.Allocating = .init(alloc);
 481    defer builder.deinit();
 482
 483    // Synchronized output (DECSET 2026) is a transient rendering handshake
 484    // between a program and its current terminal client. Replaying it to a
 485    // newly attached client can leave that client deferring renders until its
 486    // local timeout fires, so temporarily exclude it from restored state and
 487    // restore the original mode before returning.
 488    const had_synchronized_output = term.modes.get(.synchronized_output);
 489    if (had_synchronized_output) {
 490        term.modes.set(.synchronized_output, false);
 491    }
 492
 493    const pages = &term.screens.active.pages;
 494    const screen_top = pages.getTopLeft(.screen);
 495    const active_top = pages.getTopLeft(.active);
 496    const has_scrollback = !screen_top.eql(active_top);
 497
 498    // Two-phase serialization to preserve scrollback without corrupting
 499    // cursor positions. This matters for nested zmx sessions (zmx→SSH→zmx)
 500    // where the outer daemon's ghostty-vt accumulates inner session scrollback.
 501    //
 502    // Phase 1: Emit scrollback content (plain text with styles, no terminal extras).
 503    // These lines scroll past the visible area into the terminal's scrollback buffer.
 504    // Phase 2: Clear visible screen, then emit visible content with full extras.
 505    // The clear ensures visible content starts from a clean slate regardless of
 506    // how much scrollback preceded it. CUP cursor positioning is then correct.
 507    //
 508    // See: https://github.com/neurosnap/zmx/issues/31
 509
 510    // Phase 1: scrollback only (if any exists)
 511    if (has_scrollback) {
 512        if (active_top.up(1)) |sb_bottom_row| {
 513            var sb_bottom = sb_bottom_row;
 514            sb_bottom.x = @intCast(pages.cols - 1);
 515
 516            var scroll_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
 517            scroll_fmt.content = .{
 518                .selection = ghostty_vt.Selection.init(
 519                    screen_top,
 520                    sb_bottom,
 521                    false,
 522                ),
 523            };
 524            scroll_fmt.extra = .none; // no modes, cursor, keyboard — just content
 525            scroll_fmt.format(&builder.writer) catch |err| {
 526                std.log.warn("failed to format scrollback err={s}", .{@errorName(err)});
 527            };
 528        }
 529
 530        // Clear visible screen after scrollback. \x1b[2J clears only the visible
 531        // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets
 532        // SGR style so phase 1 styles don't bleed into phase 2.
 533        builder.writer.writeAll("\x1b[2J\x1b[H\x1b[0m") catch {};
 534    }
 535
 536    // Phase 2: visible screen with full extras (modes, cursor, keyboard, etc.)
 537    var vis_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
 538
 539    // Restrict content to the active viewport only
 540    const active_tl = pages.pin(.{ .active = .{ .x = 0, .y = 0 } });
 541    const active_br = pages.pin(.{
 542        .active = .{
 543            .x = @intCast(pages.cols - 1),
 544            .y = @intCast(pages.rows - 1),
 545        },
 546    });
 547
 548    if (active_tl != null and active_br != null) {
 549        vis_fmt.content = .{
 550            .selection = ghostty_vt.Selection.init(
 551                active_tl.?,
 552                active_br.?,
 553                false,
 554            ),
 555        };
 556    }
 557    // Fallback: if pins are somehow invalid, use null selection (all content)
 558
 559    vis_fmt.extra = .{
 560        .palette = false,
 561        .modes = true,
 562        .scrolling_region = true,
 563        .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
 564        .pwd = true,
 565        .keyboard = true,
 566        .screen = .all,
 567    };
 568
 569    vis_fmt.format(&builder.writer) catch |err| {
 570        std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
 571        return null;
 572    };
 573
 574    const output = builder.writer.buffered();
 575    if (output.len == 0) return null;
 576
 577    // Restore the original synchronized_output mode before returning
 578    if (had_synchronized_output) {
 579        term.modes.set(.synchronized_output, true);
 580    }
 581
 582    return alloc.dupe(u8, output) catch |err| {
 583        std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
 584        return null;
 585    };
 586}
 587
 588pub const HistoryFormat = enum(u8) {
 589    plain = 0,
 590    vt = 1,
 591    html = 2,
 592};
 593
 594pub fn serializeTerminal(
 595    alloc: std.mem.Allocator,
 596    term: *ghostty_vt.Terminal,
 597    format: HistoryFormat,
 598) ?[]const u8 {
 599    var builder: std.Io.Writer.Allocating = .init(alloc);
 600    defer builder.deinit();
 601
 602    const opts: ghostty_vt.formatter.Options = switch (format) {
 603        .plain => .plain,
 604        .vt => .vt,
 605        .html => .html,
 606    };
 607    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
 608    term_formatter.content = .{ .selection = null };
 609    term_formatter.extra = switch (format) {
 610        .plain => .none,
 611        .vt => .{
 612            .palette = false,
 613            .modes = true,
 614            .scrolling_region = true,
 615            .tabstops = false,
 616            .pwd = true,
 617            .keyboard = true,
 618            .screen = .all,
 619        },
 620        .html => .styles,
 621    };
 622
 623    term_formatter.format(&builder.writer) catch |err| {
 624        std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
 625        return null;
 626    };
 627
 628    const output = builder.writer.buffered();
 629    if (output.len == 0) return null;
 630
 631    return alloc.dupe(u8, output) catch |err| {
 632        std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
 633        return null;
 634    };
 635}
 636
 637pub fn detectShell() [:0]const u8 {
 638    return std.posix.getenv("SHELL") orelse "/bin/sh";
 639}
 640
 641/// Formats a session entry for list output (only the name when `short` is
 642/// true), adding a prefix to indicate the current session, if there is one.
 643pub fn writeSessionLine(
 644    writer: *std.Io.Writer,
 645    session: SessionEntry,
 646    short: bool,
 647    current_session: ?[]const u8,
 648) !void {
 649    const current_arrow = "";
 650    const prefix = if (current_session) |current|
 651        if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else "  "
 652    else
 653        "";
 654
 655    if (short) {
 656        if (session.is_error) return;
 657        try writer.print("{s}\n", .{session.name});
 658        return;
 659    }
 660
 661    if (session.is_error) {
 662        // "cleaning up" is only truthful when the probe was definitively
 663        // refused (socket deleted this pass). On Timeout/Unexpected the
 664        // daemon may just be busy, so don't lie about what we did.
 665        const status = if (std.mem.eql(u8, session.error_name.?, "ConnectionRefused"))
 666            "cleaning up"
 667        else
 668            "unreachable";
 669        try writer.print("{s}name={s}\terr={s}\tstatus={s}\n", .{
 670            prefix,
 671            session.name,
 672            session.error_name.?,
 673            status,
 674        });
 675        return;
 676    }
 677
 678    try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
 679        prefix,
 680        session.name,
 681        session.pid.?,
 682        session.clients_len.?,
 683        session.created_at,
 684    });
 685    if (session.cwd) |cwd| {
 686        try writer.print("\tstart_dir={s}", .{cwd});
 687    }
 688    if (session.cmd) |cmd| {
 689        try writer.print("\tcmd={s}", .{cmd});
 690    }
 691    if (session.task_ended_at) |ended_at| {
 692        if (ended_at > 0) {
 693            try writer.print("\tended={d}", .{ended_at});
 694
 695            if (session.task_exit_code) |exit_code| {
 696                try writer.print("\texit_code={d}", .{exit_code});
 697            }
 698        }
 699    }
 700    try writer.print("\n", .{});
 701}
 702
 703test "writeSessionLine formats output for current session and short output" {
 704    const Case = struct {
 705        session: SessionEntry,
 706        short: bool,
 707        current_session: ?[]const u8,
 708        expected: []const u8,
 709    };
 710
 711    const session = SessionEntry{
 712        .name = "dev",
 713        .pid = 123,
 714        .clients_len = 2,
 715        .is_error = false,
 716        .error_name = null,
 717        .cmd = null,
 718        .cwd = null,
 719        .created_at = 0,
 720        .task_ended_at = null,
 721        .task_exit_code = null,
 722    };
 723
 724    const cases = [_]Case{
 725        .{
 726            .session = session,
 727            .short = false,
 728            .current_session = "dev",
 729            .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
 730        },
 731        .{
 732            .session = session,
 733            .short = false,
 734            .current_session = "other",
 735            .expected = "  name=dev\tpid=123\tclients=2\tcreated=0\n",
 736        },
 737        .{
 738            .session = session,
 739            .short = false,
 740            .current_session = null,
 741            .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
 742        },
 743        .{
 744            .session = session,
 745            .short = true,
 746            .current_session = "dev",
 747            .expected = "dev\n",
 748        },
 749        .{
 750            .session = session,
 751            .short = true,
 752            .current_session = "other",
 753            .expected = "dev\n",
 754        },
 755        .{
 756            .session = session,
 757            .short = true,
 758            .current_session = null,
 759            .expected = "dev\n",
 760        },
 761    };
 762
 763    for (cases) |case| {
 764        var builder: std.Io.Writer.Allocating = .init(testing.allocator);
 765        defer builder.deinit();
 766
 767        try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
 768        try testing.expectEqualStrings(case.expected, builder.writer.buffered());
 769    }
 770}
 771
 772test "shellNeedsQuoting" {
 773    try testing.expect(shellNeedsQuoting(""));
 774    try testing.expect(shellNeedsQuoting("hello world"));
 775    try testing.expect(shellNeedsQuoting("hello!"));
 776    try testing.expect(shellNeedsQuoting("$PATH"));
 777    try testing.expect(shellNeedsQuoting("it's"));
 778    try testing.expect(shellNeedsQuoting("a|b"));
 779    try testing.expect(shellNeedsQuoting("a;b"));
 780    try testing.expect(!shellNeedsQuoting("hello"));
 781    try testing.expect(!shellNeedsQuoting("bash"));
 782    try testing.expect(!shellNeedsQuoting("-c"));
 783    try testing.expect(!shellNeedsQuoting("/usr/bin/env"));
 784}
 785
 786test "shellQuote" {
 787    const alloc = testing.allocator;
 788
 789    const empty = try shellQuote(alloc, "");
 790    defer alloc.free(empty);
 791    try testing.expectEqualStrings("''", empty);
 792
 793    const space = try shellQuote(alloc, "hello world");
 794    defer alloc.free(space);
 795    try testing.expectEqualStrings("'hello world'", space);
 796
 797    const bang = try shellQuote(alloc, "hello!");
 798    defer alloc.free(bang);
 799    try testing.expectEqualStrings("'hello!'", bang);
 800
 801    const dollar = try shellQuote(alloc, "$PATH");
 802    defer alloc.free(dollar);
 803    try testing.expectEqualStrings("'$PATH'", dollar);
 804
 805    const sq = try shellQuote(alloc, "it's");
 806    defer alloc.free(sq);
 807    try testing.expectEqualStrings("'it'\\''s'", sq);
 808
 809    const dq = try shellQuote(alloc, "say \"hi\"");
 810    defer alloc.free(dq);
 811    try testing.expectEqualStrings("'say \"hi\"'", dq);
 812
 813    const both = try shellQuote(alloc, "it's \"cool\"");
 814    defer alloc.free(both);
 815    try testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
 816
 817    // just a single quote
 818    const lone_sq = try shellQuote(alloc, "'");
 819    defer alloc.free(lone_sq);
 820    try testing.expectEqualStrings("''\\'''", lone_sq);
 821
 822    // multiple consecutive single quotes
 823    const triple_sq = try shellQuote(alloc, "'''");
 824    defer alloc.free(triple_sq);
 825    try testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
 826
 827    // backtick command substitution
 828    const backtick = try shellQuote(alloc, "`whoami`");
 829    defer alloc.free(backtick);
 830    try testing.expectEqualStrings("'`whoami`'", backtick);
 831
 832    // dollar command substitution
 833    const dollar_cmd = try shellQuote(alloc, "$(whoami)");
 834    defer alloc.free(dollar_cmd);
 835    try testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
 836
 837    // glob
 838    const glob = try shellQuote(alloc, "*.txt");
 839    defer alloc.free(glob);
 840    try testing.expectEqualStrings("'*.txt'", glob);
 841
 842    // tilde
 843    const tilde = try shellQuote(alloc, "~/file");
 844    defer alloc.free(tilde);
 845    try testing.expectEqualStrings("'~/file'", tilde);
 846
 847    // trailing backslash
 848    const trailing_bs = try shellQuote(alloc, "path\\");
 849    defer alloc.free(trailing_bs);
 850    try testing.expectEqualStrings("'path\\'", trailing_bs);
 851
 852    // semicolon (command injection)
 853    const semi = try shellQuote(alloc, "; rm -rf /");
 854    defer alloc.free(semi);
 855    try testing.expectEqualStrings("'; rm -rf /'", semi);
 856
 857    // embedded newline
 858    const newline = try shellQuote(alloc, "line1\nline2");
 859    defer alloc.free(newline);
 860    try testing.expectEqualStrings("'line1\nline2'", newline);
 861
 862    // parentheses (subshell)
 863    const parens = try shellQuote(alloc, "(echo hi)");
 864    defer alloc.free(parens);
 865    try testing.expectEqualStrings("'(echo hi)'", parens);
 866
 867    // heredoc marker
 868    const heredoc = try shellQuote(alloc, "<<EOF");
 869    defer alloc.free(heredoc);
 870    try testing.expectEqualStrings("'<<EOF'", heredoc);
 871
 872    // no quoting needed -- plain word should still be quoted
 873    // (shellQuote is only called when shellNeedsQuoting returns true,
 874    // but verify it produces valid output anyway)
 875    const plain = try shellQuote(alloc, "hello");
 876    defer alloc.free(plain);
 877    try testing.expectEqualStrings("'hello'", plain);
 878}
 879
 880test "isCtrlBackslash" {
 881    const expect = testing.expect;
 882
 883    // Basic: ctrl only (modifier 5 = 1 + 4)
 884    try expect(isCtrlBackslash("\x1b[92;5u"));
 885
 886    // Explicit press event type (:1)
 887    try expect(isCtrlBackslash("\x1b[92;5:1u"));
 888
 889    // Repeat event (:2) -- user holding Ctrl+\
 890    try expect(isCtrlBackslash("\x1b[92;5:2u"));
 891
 892    // Release event (:3) -- must NOT trigger detach
 893    try expect(!isCtrlBackslash("\x1b[92;5:3u"));
 894
 895    // Lock modifiers: caps_lock (bit 6) changes modifier value
 896    // ctrl + caps_lock = 1 + (4 + 64) = 69
 897    try expect(isCtrlBackslash("\x1b[92;69u"));
 898    try expect(isCtrlBackslash("\x1b[92;69:1u"));
 899    try expect(!isCtrlBackslash("\x1b[92;69:3u"));
 900
 901    // ctrl + num_lock = 1 + (4 + 128) = 133
 902    try expect(isCtrlBackslash("\x1b[92;133u"));
 903
 904    // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
 905    try expect(isCtrlBackslash("\x1b[92;197u"));
 906
 907    // Combined intentional modifiers -- must NOT match (ctrl+\ is the
 908    // detach key, not ctrl+shift+\ or ctrl+alt+\)
 909    // ctrl + shift = 1 + (4 + 1) = 6
 910    try expect(!isCtrlBackslash("\x1b[92;6u"));
 911
 912    // ctrl + alt = 1 + (4 + 2) = 7
 913    try expect(!isCtrlBackslash("\x1b[92;7u"));
 914
 915    // ctrl + super = 1 + (4 + 8) = 13
 916    try expect(!isCtrlBackslash("\x1b[92;13u"));
 917
 918    // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
 919    try expect(!isCtrlBackslash("\x1b[92;70u"));
 920
 921    // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
 922    try expect(!isCtrlBackslash("\x1b[92;134u"));
 923
 924    // Modifier without ctrl bit -- must NOT match
 925    // shift only = 1 + 1 = 2
 926    try expect(!isCtrlBackslash("\x1b[92;1u"));
 927    try expect(!isCtrlBackslash("\x1b[92;2u"));
 928
 929    // Alternate key sub-fields (report_alternates flag)
 930    // shifted key | (124): \x1b[92:124;5u
 931    try expect(isCtrlBackslash("\x1b[92:124;5u"));
 932
 933    // base layout key only (non-US keyboard): \x1b[92::92;5u
 934    try expect(isCtrlBackslash("\x1b[92::92;5u"));
 935
 936    // both shifted and base layout: \x1b[92:124:92;5u
 937    try expect(isCtrlBackslash("\x1b[92:124:92;5u"));
 938
 939    // Alternate keys + lock modifiers + event type
 940    try expect(isCtrlBackslash("\x1b[92:124;69:1u"));
 941    try expect(!isCtrlBackslash("\x1b[92:124;69:3u"));
 942
 943    // Text codepoints section (flag 0b10000) -- tolerated and skipped
 944    // Even though ctrl+\ text is typically empty, terminals may vary
 945    try expect(isCtrlBackslash("\x1b[92;5;28u"));
 946    try expect(isCtrlBackslash("\x1b[92;5;28:92u"));
 947
 948    // Wrong key code -- must NOT match
 949    try expect(!isCtrlBackslash("\x1b[91;5u"));
 950    try expect(!isCtrlBackslash("\x1b[93;5u"));
 951    try expect(!isCtrlBackslash("\x1b[9;5u"));
 952    try expect(!isCtrlBackslash("\x1b[920;5u"));
 953
 954    // Sequence embedded in larger buffer (e.g., preceded by other input)
 955    try expect(isCtrlBackslash("abc\x1b[92;5u"));
 956    try expect(isCtrlBackslash("\x1b[A\x1b[92;5u"));
 957
 958    // Garbage / malformed inputs
 959    try expect(!isCtrlBackslash("garbage"));
 960    try expect(!isCtrlBackslash(""));
 961    try expect(!isCtrlBackslash("\x1b["));
 962    try expect(!isCtrlBackslash("\x1b[92"));
 963    try expect(!isCtrlBackslash("\x1b[92;"));
 964    try expect(!isCtrlBackslash("\x1b[92;u"));
 965    try expect(!isCtrlBackslash("\x1b[;5u"));
 966
 967    // Other CSI u sequences that happen to contain '92' elsewhere
 968    try expect(!isCtrlBackslash("\x1b[65;92u"));
 969}
 970
 971test "serializeTerminalState excludes synchronized output replay" {
 972    const alloc = testing.allocator;
 973
 974    var term = try ghostty_vt.Terminal.init(alloc, .{
 975        .cols = 80,
 976        .rows = 24,
 977    });
 978    defer term.deinit(alloc);
 979
 980    var stream = term.vtStream();
 981    defer stream.deinit();
 982
 983    stream.nextSlice("\x1b[?2004h"); // Bracketed paste
 984    stream.nextSlice("\x1b[?2026h"); // Synchronized output
 985    stream.nextSlice("hello");
 986
 987    try testing.expect(term.modes.get(.bracketed_paste));
 988    try testing.expect(term.modes.get(.synchronized_output));
 989
 990    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
 991    defer alloc.free(output);
 992
 993    // The serialized output should contain bracketed paste (DECSET 2004)
 994    // but NOT synchronized output (DECSET 2026)
 995    try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
 996    try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
 997}
 998
 999fn testCreateTerminal(alloc: std.mem.Allocator, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
1000    var term = try ghostty_vt.Terminal.init(alloc, .{
1001        .cols = cols,
1002        .rows = rows,
1003        .max_scrollback = 10_000_000,
1004    });
1005    if (vt_data.len > 0) {
1006        var stream = term.vtStream();
1007        defer stream.deinit();
1008        stream.nextSlice(vt_data);
1009    }
1010    return term;
1011}
1012
1013fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal, actual: *ghostty_vt.Terminal) !void {
1014    const exp_str = try expected.plainString(alloc);
1015    defer alloc.free(exp_str);
1016    const act_str = try actual.plainString(alloc);
1017    defer alloc.free(act_str);
1018    try testing.expectEqualStrings(exp_str, act_str);
1019}
1020
1021fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
1022    const cursor = &term.screens.active.cursor;
1023    try testing.expectEqual(col, cursor.x);
1024    try testing.expectEqual(row, cursor.y);
1025}
1026
1027fn serializeRoundtrip(alloc: std.mem.Allocator, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
1028    const serialized = serializeTerminalState(alloc, source) orelse
1029        return error.SerializationFailed;
1030    defer alloc.free(serialized);
1031
1032    var dest = try ghostty_vt.Terminal.init(alloc, .{
1033        .cols = source.screens.active.pages.cols,
1034        .rows = source.screens.active.pages.rows,
1035        .max_scrollback = 10_000_000,
1036    });
1037    var stream = dest.vtStream();
1038    defer stream.deinit();
1039    stream.nextSlice(serialized);
1040    return dest;
1041}
1042
1043fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marker: []const u8, expected_row: usize) !void {
1044    const plain = try term.plainString(alloc);
1045    defer alloc.free(plain);
1046    var row: usize = 0;
1047    var iter = std.mem.splitScalar(u8, plain, '\n');
1048    while (iter.next()) |line| {
1049        if (std.mem.indexOf(u8, line, marker) != null) {
1050            try testing.expectEqual(expected_row, row);
1051            return;
1052        }
1053        row += 1;
1054    }
1055    std.debug.print("marker '{s}' not found in terminal output\n", .{marker});
1056    return error.TestExpectedEqual;
1057}
1058
1059test "serializeTerminalState roundtrip preserves cursor position" {
1060    const alloc = testing.allocator;
1061
1062    var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++ // clear
1063        "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
1064    );
1065    defer term.deinit(alloc);
1066
1067    try expectCursorAt(&term, 9, 19); // 0-indexed
1068
1069    var client = try serializeRoundtrip(alloc, &term);
1070    defer client.deinit(alloc);
1071
1072    try expectCursorAt(&client, 9, 19);
1073}
1074
1075test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
1076    const alloc = testing.allocator;
1077
1078    var term = try testCreateTerminal(alloc, 80, 24, "\x1b[2J" ++
1079        "\x1b[2;5HMARK_A" ++
1080        "\x1b[6;15HMARK_B" ++
1081        "\x1b[10;30HMARK_C" ++
1082        "\x1b[14;50HMARK_D" ++
1083        "\x1b[16;20H");
1084    defer term.deinit(alloc);
1085
1086    var client = try serializeRoundtrip(alloc, &term);
1087    defer client.deinit(alloc);
1088
1089    try expectScreensMatch(alloc, &term, &client);
1090    try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1091    try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1092    try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1093    try expectMarkerAtRow(alloc, &client, "MARK_D", 13);
1094    try expectCursorAt(&client, 15, 19);
1095}
1096
1097test "serializeTerminalState with scrollback preserves visible content" {
1098    const alloc = testing.allocator;
1099
1100    var term = try testCreateTerminal(alloc, 80, 24, "");
1101    defer term.deinit(alloc);
1102
1103    var stream = term.vtStream();
1104    defer stream.deinit();
1105
1106    // Generate 80 lines of scrollback (more than 24 visible rows)
1107    var buf: [32]u8 = undefined;
1108    for (0..80) |i| {
1109        const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1110        stream.nextSlice(line);
1111    }
1112
1113    // Clear screen and place markers at specific positions
1114    stream.nextSlice("\x1b[2J" ++
1115        "\x1b[2;5HMARK_A" ++
1116        "\x1b[6;15HMARK_B" ++
1117        "\x1b[10;30HMARK_C" ++
1118        "\x1b[16;20H");
1119
1120    // Verify source terminal has scrollback
1121    const pages = &term.screens.active.pages;
1122    const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
1123    try testing.expect(has_scrollback);
1124
1125    // Roundtrip: serialize → feed into fresh terminal
1126    var client = try serializeRoundtrip(alloc, &term);
1127    defer client.deinit(alloc);
1128
1129    // Visible content must match (this is the core cursor corruption test)
1130    try expectScreensMatch(alloc, &term, &client);
1131    try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1132    try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1133    try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1134    try expectCursorAt(&client, 15, 19);
1135}
1136
1137test "serializeTerminalState nested roundtrip preserves content" {
1138    // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
1139    // This is the exact nested session scenario (zmx → SSH → zmx).
1140    const alloc = testing.allocator;
1141
1142    // "Inner" terminal with scrollback + markers
1143    var inner = try testCreateTerminal(alloc, 80, 24, "");
1144    defer inner.deinit(alloc);
1145
1146    {
1147        var inner_stream = inner.vtStream();
1148        defer inner_stream.deinit();
1149        var buf: [32]u8 = undefined;
1150        for (0..60) |i| {
1151            const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1152            inner_stream.nextSlice(line);
1153        }
1154        inner_stream.nextSlice("\x1b[2J" ++
1155            "\x1b[3;10HINNER_A" ++
1156            "\x1b[12;25HINNER_B" ++
1157            "\x1b[20;5H");
1158    }
1159
1160    // Record inner's ground truth
1161    const inner_cursor_x = inner.screens.active.cursor.x;
1162    const inner_cursor_y = inner.screens.active.cursor.y;
1163
1164    // Serialize inner (simulates inner daemon re-attach to inner client)
1165    const inner_serialized = serializeTerminalState(alloc, &inner) orelse
1166        return error.SerializationFailed;
1167    defer alloc.free(inner_serialized);
1168
1169    // "Outer" terminal processes inner's serialized output
1170    var outer = try testCreateTerminal(alloc, 80, 24, "");
1171    defer outer.deinit(alloc);
1172
1173    {
1174        var outer_stream = outer.vtStream();
1175        defer outer_stream.deinit();
1176        outer_stream.nextSlice(inner_serialized);
1177    }
1178
1179    // Serialize outer (simulates outer daemon re-attach after detach)
1180    var client = try serializeRoundtrip(alloc, &outer);
1181    defer client.deinit(alloc);
1182
1183    // Client must see the same content as inner's visible screen
1184    try expectScreensMatch(alloc, &inner, &client);
1185    try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
1186    try expectMarkerAtRow(alloc, &client, "INNER_A", 2);
1187    try expectMarkerAtRow(alloc, &client, "INNER_B", 11);
1188}
1189
1190test "serializeTerminalState alternate screen not leaked" {
1191    const alloc = testing.allocator;
1192
1193    var term = try testCreateTerminal(alloc, 80, 24, "\x1b[?1049h" ++ // enter alt screen
1194        "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
1195        "\x1b[?1049l" ++ // exit alt screen
1196        "\x1b[2J\x1b[2;5HMAIN_MARK\x1b[8;20H" // write on main screen
1197    );
1198    defer term.deinit(alloc);
1199
1200    var client = try serializeRoundtrip(alloc, &term);
1201    defer client.deinit(alloc);
1202
1203    try expectScreensMatch(alloc, &term, &client);
1204
1205    const plain = try client.plainString(alloc);
1206    defer alloc.free(plain);
1207    try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
1208    try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
1209}
1210
1211test "serializeTerminalState size mismatch roundtrip" {
1212    const alloc = testing.allocator;
1213
1214    var term = try testCreateTerminal(alloc, 80, 30, "\x1b[2J" ++
1215        "\x1b[3;10HSIZE_A" ++
1216        "\x1b[12;20HSIZE_B" ++
1217        "\x1b[20;40HSIZE_C" ++
1218        "\x1b[15;15H");
1219    defer term.deinit(alloc);
1220
1221    // Resize to 24 rows (simulates outer terminal being smaller)
1222    try term.resize(alloc, 80, 24);
1223
1224    var client = try serializeRoundtrip(alloc, &term);
1225    defer client.deinit(alloc);
1226
1227    try expectScreensMatch(alloc, &term, &client);
1228    try expectCursorAt(&client, term.screens.active.cursor.y, term.screens.active.cursor.x);
1229}
1230
1231test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
1232    const alloc = testing.allocator;
1233
1234    var inner = try testCreateTerminal(alloc, 80, 30, "");
1235    defer inner.deinit(alloc);
1236
1237    {
1238        var inner_stream = inner.vtStream();
1239        defer inner_stream.deinit();
1240        var buf: [32]u8 = undefined;
1241        for (0..80) |i| {
1242            const line = std.fmt.bufPrint(&buf, "LINE_{d}\r\n", .{i}) catch unreachable;
1243            inner_stream.nextSlice(line);
1244        }
1245        inner_stream.nextSlice("\x1b[2J" ++
1246            "\x1b[3;10HSTRESS_A" ++
1247            "\x1b[12;25HSTRESS_B" ++
1248            "\x1b[16;20H");
1249    }
1250
1251    // Resize inner to 24 rows (outer terminal is smaller)
1252    try inner.resize(alloc, 80, 24);
1253
1254    const inner_cursor_x = inner.screens.active.cursor.x;
1255    const inner_cursor_y = inner.screens.active.cursor.y;
1256
1257    // Inner serialize → outer processes → outer serialize → client
1258    const inner_ser = serializeTerminalState(alloc, &inner) orelse
1259        return error.SerializationFailed;
1260    defer alloc.free(inner_ser);
1261
1262    var outer = try testCreateTerminal(alloc, 80, 24, "");
1263    defer outer.deinit(alloc);
1264    {
1265        var outer_stream = outer.vtStream();
1266        defer outer_stream.deinit();
1267        outer_stream.nextSlice(inner_ser);
1268    }
1269
1270    var client = try serializeRoundtrip(alloc, &outer);
1271    defer client.deinit(alloc);
1272
1273    try expectScreensMatch(alloc, &inner, &client);
1274    try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
1275}
1276
1277test "isUserInput: printable characters" {
1278    // Regular text should be detected as user input
1279    try testing.expect(isUserInput("hello"));
1280    try testing.expect(isUserInput("Hello World!"));
1281    try testing.expect(isUserInput("12345"));
1282    try testing.expect(isUserInput("!@#$%^&*()"));
1283}
1284
1285test "isUserInput: whitespace characters" {
1286    // Space character is printable
1287    try testing.expect(isUserInput(" "));
1288    try testing.expect(isUserInput("   "));
1289}
1290
1291test "isUserInput: line feed (LF)" {
1292    // LF triggers .execute action
1293    try testing.expect(isUserInput("\n"));
1294    try testing.expect(isUserInput("test\n"));
1295}
1296
1297test "isUserInput: carriage return (CR)" {
1298    // CR triggers .execute action
1299    try testing.expect(isUserInput("\r"));
1300    try testing.expect(isUserInput("test\r"));
1301}
1302
1303test "isUserInput: tab" {
1304    // Tab triggers .execute action
1305    try testing.expect(isUserInput("\t"));
1306    try testing.expect(isUserInput("col1\tcol2"));
1307}
1308
1309test "isUserInput: backspace" {
1310    // Backspace triggers .execute action
1311    try testing.expect(isUserInput("\x08"));
1312    try testing.expect(isUserInput("test\x08"));
1313}
1314
1315test "isUserInput: arrow keys (CSI ~)" {
1316    // Arrow keys use CSI with ~ - these have params
1317    try testing.expect(isUserInput("\x1b[3~")); // delete
1318    try testing.expect(isUserInput("\x1b[5~")); // page up
1319    try testing.expect(isUserInput("\x1b[6~")); // page down
1320}
1321
1322test "isUserInput: modified arrow keys with CSI u" {
1323    // Modified arrow keys with CSI ... u
1324    try testing.expect(isUserInput("\x1bOA")); // up with modifier
1325    try testing.expect(isUserInput("\x1bOB")); // down with modifier
1326    try testing.expect(isUserInput("\x1bOC")); // right with modifier
1327    try testing.expect(isUserInput("\x1bOD")); // left with modifier
1328}
1329
1330test "isUserInput: up arrow legacy" {
1331    // Legacy up arrow: CSI A (with params for kitty-style)
1332    try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
1333}
1334
1335test "isUserInput: up arrow kitty" {
1336    // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
1337    try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
1338}
1339
1340test "isUserInput: arrow keys with modifier params CSI A-D" {
1341    // Modified arrow keys like Ctrl+Up: CSI 1;5A
1342    try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
1343    try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
1344    try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
1345    try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
1346    try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
1347    try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
1348}
1349
1350test "isUserInput: function keys with modifiers CSI 27 ; ~" {
1351    // Legacy modified keys: CSI 27 ; ... ~
1352    try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
1353    try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
1354    try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
1355}
1356
1357test "isUserInput: enter key" {
1358    // Enter is LF (0x0A)
1359    try testing.expect(isUserInput("\x0A"));
1360}
1361
1362test "isUserInput: mixed content" {
1363    // Mix of printable and control sequences
1364    try testing.expect(isUserInput("hello\nworld"));
1365    try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
1366    try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
1367}
1368
1369test "isUserInput: non-user input (escape sequences only)" {
1370    // Cursor movement without user input
1371    try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
1372    // SGR color set (no printing)
1373    try testing.expect(!isUserInput("\x1b[0m"));
1374    // Cursor position report query
1375    try testing.expect(!isUserInput("\x1b[6n"));
1376}
1377
1378test "isUserInput: empty string" {
1379    try testing.expect(!isUserInput(""));
1380}
1381
1382test "isUserInput: only whitespace controls" {
1383    // Multiple control chars should return true
1384    try testing.expect(isUserInput("\n\r\t"));
1385}
1386
1387test "isUserInput: kitty keyboard sequences" {
1388    // Kitty keyboard protocol uses CSI u
1389    try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
1390    try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
1391}
1392
1393test "isUserInput: mouse events (CSI M) excluded" {
1394    // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
1395    // Mouse events should NOT trigger leader switch
1396    try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
1397    try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
1398}
1399
1400test "isUserInput: mouse events SGR mode CSI < excluded" {
1401    // SGR extended mouse tracking: CSI < Cb;Cx;Y M
1402    // Mouse events should NOT trigger leader switch
1403    try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
1404    try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
1405}
1406
1407test "isUserInput: focus events excluded" {
1408    // Focus in/out are automatic terminal events, not user typing
1409    try testing.expect(!isUserInput("\x1b[I")); // focus in
1410    try testing.expect(!isUserInput("\x1b[O")); // focus out
1411}
1412
1413test "isUserInput: bracketed paste included" {
1414    // Bracketed paste start/end are user-initiated paste operations
1415    try testing.expect(isUserInput("\x1b[200~")); // paste start
1416    try testing.expect(isUserInput("\x1b[201~")); // paste end
1417    // Content between start/end is also user input
1418    try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
1419}