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