main zmx / src / util.zig
Chance Zibolski  ·  2026-08-05
   1const std = @import("std");
   2const ghostty_vt = @import("ghostty-vt");
   3const ipc = @import("ipc.zig");
   4const socket = @import("socket.zig");
   5const cross = @import("cross.zig");
   6const label = @import("label.zig");
   7const lib_posix = @import("posix.zig");
   8const testing = std.testing;
   9
  10pub const SessionEntry = struct {
  11    name: []const u8,
  12    pid: ?i32,
  13    clients_len: ?usize,
  14    is_error: bool,
  15    error_name: ?[]const u8,
  16    cmd: ?[]const u8 = null,
  17    cwd: ?[]const u8 = null,
  18    labels: ?[]const u8 = null,
  19    created_at: u64,
  20    task_ended_at: ?u64,
  21    task_exit_code: ?u8,
  22
  23    pub fn deinit(self: SessionEntry, alloc: std.mem.Allocator) void {
  24        alloc.free(self.name);
  25        if (self.cmd) |cmd| alloc.free(cmd);
  26        if (self.cwd) |cwd| alloc.free(cwd);
  27        if (self.labels) |l| alloc.free(l);
  28    }
  29
  30    pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
  31        return std.mem.order(u8, a.name, b.name) == .lt;
  32    }
  33};
  34
  35pub fn get_session_entries(
  36    alloc: std.mem.Allocator,
  37    io: std.Io,
  38    socket_dir: []const u8,
  39) !std.ArrayList(SessionEntry) {
  40    var dir = try std.Io.Dir.openDirAbsolute(io, socket_dir, .{ .iterate = true });
  41    defer dir.close(io);
  42    var iter = dir.iterate();
  43
  44    var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
  45
  46    while (try iter.next(io)) |entry| {
  47        const exists = socket.sessionExists(io, dir, entry.name) catch continue;
  48        if (exists) {
  49            const name = try alloc.dupe(u8, entry.name);
  50            errdefer alloc.free(name);
  51
  52            const socket_path = socket.getSocketPath(alloc, socket_dir, entry.name) catch |err| switch (err) {
  53                error.NameTooLong => continue,
  54                error.OutOfMemory => return err,
  55            };
  56            defer alloc.free(socket_path);
  57
  58            const result = ipc.probeSession(alloc, socket_path) catch |err| {
  59                try sessions.append(alloc, .{
  60                    .name = name,
  61                    .pid = null,
  62                    .clients_len = null,
  63                    .is_error = true,
  64                    .error_name = @errorName(err),
  65                    .created_at = 0,
  66                    .task_exit_code = 1,
  67                    .task_ended_at = 0,
  68                    .labels = "",
  69                });
  70                // Only clean up when the daemon is definitively gone. A busy
  71                // daemon can miss the probe timeout; deleting its socket
  72                // orphans it permanently.
  73                if (err == error.ConnectionRefused) {
  74                    socket.cleanupStaleSocket(io, dir, entry.name);
  75                }
  76                continue;
  77            };
  78            defer result.deinit();
  79
  80            // Extract cmd and cwd from the fixed-size arrays. Lengths come
  81            // off the wire (u16 range), so clamp to the actual array size.
  82            const cmd_len = @min(result.info.cmd_len, ipc.MAX_CMD_LEN);
  83            const cwd_len = @min(result.info.cwd_len, ipc.MAX_CWD_LEN);
  84            const cmd: ?[]const u8 = if (cmd_len > 0)
  85                alloc.dupe(u8, result.info.cmd[0..cmd_len]) catch null
  86            else
  87                null;
  88
  89            const cwd: ?[]const u8 = if (cwd_len > 0)
  90                alloc.dupe(u8, result.info.cwd[0..cwd_len]) catch null
  91            else
  92                null;
  93
  94            const labels = if (result.labels) |lbl|
  95                alloc.dupe(u8, lbl) catch null
  96            else
  97                null;
  98
  99            try sessions.append(alloc, .{
 100                .name = name,
 101                .pid = result.info.pid,
 102                .clients_len = result.info.clients_len,
 103                .is_error = false,
 104                .error_name = null,
 105                .cmd = cmd,
 106                .cwd = cwd,
 107                .labels = labels,
 108                .created_at = result.info.created_at,
 109                .task_ended_at = result.info.task_ended_at,
 110                .task_exit_code = result.info.task_exit_code,
 111            });
 112        }
 113    }
 114
 115    return sessions;
 116}
 117
 118pub const Cwd = struct {
 119    /// A filesystem path, percent-decoded, with no scheme or host.
 120    path: []const u8,
 121    /// True when the OSC 7 host is this machine, so `path` names a directory we
 122    /// can actually chdir into. OSC 7 crosses SSH boundaries, so a session that
 123    /// ssh'd elsewhere reports a path that does not exist locally.
 124    is_local: bool,
 125};
 126
 127/// parseOsc7Cwd turns an OSC 7 value into a path that can be opened.
 128///
 129/// The value looks like `file://<host><path>` with the path percent-encoded, so
 130/// it cannot be handed to `openDirAbsolute` as-is: the escaping is never
 131/// decoded and any directory whose name needed it fails to open.
 132///
 133/// A plain absolute path is accepted and passed through, since a caller may
 134/// hand us one directly before the session has reported an OSC 7.
 135///
 136/// `buf` holds the decoded path, so the result stays valid after the source
 137/// value changes. Returns null when the value is not a path we can use.
 138pub fn parseOsc7Cwd(buf: []u8, value: []const u8, hostname: []const u8) ?Cwd {
 139    if (value.len == 0) return null;
 140
 141    if (std.fs.path.isAbsolute(value)) {
 142        if (value.len > buf.len) return null;
 143        @memcpy(buf[0..value.len], value);
 144        return .{ .path = buf[0..value.len], .is_local = true };
 145    }
 146
 147    const uri = std.Uri.parse(value) catch return null;
 148    // kitty emits kitty-shell-cwd:// from its own shell integration, and
 149    // accepts it alongside file:// on the way back in.
 150    if (!std.mem.eql(u8, uri.scheme, "file") and
 151        !std.mem.eql(u8, uri.scheme, "kitty-shell-cwd")) return null;
 152
 153    const decoded = uri.path.toRaw(buf) catch return null;
 154    if (!std.fs.path.isAbsolute(decoded)) return null;
 155    // toRaw returns the input slice when there was nothing to decode, and that
 156    // slice is owned by the caller of this fn, so copy it into buf either way.
 157    const path = if (decoded.ptr == buf.ptr) decoded else blk: {
 158        if (decoded.len > buf.len) return null;
 159        std.mem.copyForwards(u8, buf[0..decoded.len], decoded);
 160        break :blk buf[0..decoded.len];
 161    };
 162
 163    return .{ .path = path, .is_local = isLocalHost(uri.host, hostname) };
 164}
 165
 166fn isLocalHost(host: ?std.Uri.Component, hostname: []const u8) bool {
 167    // file:///path omits the host, which conventionally means the local machine.
 168    const component = host orelse return true;
 169    var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
 170    const value = component.toRaw(&host_buf) catch return false;
 171    if (value.len == 0) return true;
 172    if (std.ascii.eqlIgnoreCase(value, "localhost")) return true;
 173    if (std.ascii.eqlIgnoreCase(value, hostname)) return true;
 174    // gethostname often reports a short name while OSC 7 carries the FQDN
 175    // (or the reverse), so fall back to comparing the first label.
 176    const value_label = value[0 .. std.mem.indexOfScalar(u8, value, '.') orelse value.len];
 177    const host_label = hostname[0 .. std.mem.indexOfScalar(u8, hostname, '.') orelse hostname.len];
 178    return host_label.len > 0 and std.ascii.eqlIgnoreCase(value_label, host_label);
 179}
 180
 181/// toOsc7Cwd renders a plain path as the OSC 7 form, `file://<host><path>`.
 182///
 183/// The daemon stores its cwd in this form so `zmx list` shows the host, which
 184/// is how you can tell at a glance that a session is inside SSH. Callers that
 185/// only have a local path (`zmx run`, `zmx attach`) go through this so the
 186/// stored value has one shape regardless of where it came from.
 187///
 188/// Returns null when the result would not fit in `buf`.
 189pub fn toOsc7Cwd(buf: []u8, path: []const u8, hostname: []const u8) ?[]const u8 {
 190    var w: std.Io.Writer = .fixed(buf);
 191    w.print("file://{s}", .{hostname}) catch return null;
 192    // Percent-encode so a path with a space or a `%` in it round-trips back
 193    // through parseOsc7Cwd unchanged.
 194    std.Uri.Component.percentEncode(&w, path, isPathChar) catch return null;
 195    return w.buffered();
 196}
 197
 198/// Characters that need no escaping in a URI path. RFC 3986 pchar, minus the
 199/// sub-delims that a shell would find surprising to see left raw in `zmx list`.
 200fn isPathChar(c: u8) bool {
 201    return switch (c) {
 202        'A'...'Z', 'a'...'z', '0'...'9' => true,
 203        '-', '.', '_', '~', '/', ':', '@' => true,
 204        else => false,
 205    };
 206}
 207
 208/// getCwd get the current working directory in a std.Uri format.
 209/// Caller is responsible for releasing memory.
 210pub fn getCwd(gpa: std.mem.Allocator, io: std.Io) ![]u8 {
 211    const cur_path = try std.process.currentPathAlloc(io, gpa);
 212    defer gpa.free(cur_path);
 213
 214    var buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
 215    const hostname = try std.posix.gethostname(&buf);
 216
 217    return std.fmt.allocPrint(gpa, "file://{s}{s}", .{ hostname, cur_path });
 218}
 219
 220pub fn shellNeedsQuoting(arg: []const u8) bool {
 221    if (arg.len == 0) return true;
 222    for (arg) |ch| {
 223        switch (ch) {
 224            ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']' => return true,
 225            '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
 226            else => {},
 227        }
 228    }
 229    return false;
 230}
 231
 232pub fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
 233    // Always use single quotes (like Python's shlex.quote). Inside single
 234    // quotes nothing is special except ' itself, which we handle with the
 235    // '\'' trick (end quote, escaped literal quote, reopen quote).
 236    var len: usize = 2;
 237    for (arg) |ch| {
 238        len += if (ch == '\'') 4 else 1;
 239    }
 240    const buf = try alloc.alloc(u8, len);
 241    var i: usize = 0;
 242    buf[i] = '\'';
 243    i += 1;
 244    for (arg) |ch| {
 245        if (ch == '\'') {
 246            @memcpy(buf[i..][0..4], "'\\''");
 247            i += 4;
 248        } else {
 249            buf[i] = ch;
 250            i += 1;
 251        }
 252    }
 253    buf[i] = '\'';
 254    return buf;
 255}
 256
 257const DA1_QUERY = "\x1b[c";
 258const DA1_QUERY_EXPLICIT = "\x1b[0c";
 259const DA2_QUERY = "\x1b[>c";
 260const DA2_QUERY_EXPLICIT = "\x1b[>0c";
 261const DA1_RESPONSE = "\x1b[?62;22c";
 262const DA2_RESPONSE = "\x1b[>1;10;0c";
 263
 264pub fn respondToDeviceAttributes(alloc: std.mem.Allocator, buf: *std.ArrayList(u8), data: []const u8) void {
 265    // Scan for DA queries in PTY output and respond on behalf of the terminal.
 266    // This handles the case where no client is attached (e.g. zmx run)
 267    // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered.
 268    //
 269    // Responses are queued into the daemon's pty_write_buf (not written
 270    // directly) so they don't interleave with any already-buffered input —
 271    // e.g. a large `zmx run` payload still draining after the client
 272    // disconnected.
 273    //
 274    // DA1 query: ESC [ c  or  ESC [ 0 c
 275    // DA2 query: ESC [ > c  or  ESC [ > 0 c
 276    // DA1 response (from terminal): ESC [ ? ... c  (has '?' after '[')
 277    //
 278    // We must NOT match DA responses (which contain '?') as queries.
 279    var i: usize = 0;
 280    while (i < data.len) {
 281        if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') {
 282            // Skip DA responses which have '?' after CSI
 283            if (i + 2 < data.len and data[i + 2] == '?') {
 284                i += 3;
 285                continue;
 286            }
 287            if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) {
 288                buf.appendSlice(alloc, DA2_RESPONSE) catch {};
 289            } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) {
 290                buf.appendSlice(alloc, DA1_RESPONSE) catch {};
 291            }
 292        }
 293        i += 1;
 294    }
 295}
 296
 297fn matchSeq(data: []const u8, seq: []const u8) bool {
 298    if (data.len < seq.len) return false;
 299    return std.mem.eql(u8, data[0..seq.len], seq);
 300}
 301
 302/// OSC 133;A (prompt start) marker.
 303const OSC_133_A = "\x1b]133;A";
 304
 305/// Rewrite OSC 133;A sequences to include `redraw=0`, which tells the outer
 306/// terminal not to clear prompt lines on resize. This is necessary because
 307/// zmx sits between the shell and the outer terminal: from the outer terminal's
 308/// perspective, the foreground process (zmx client) cannot redraw prompts.
 309/// Without this, the outer terminal clears the prompt on resize expecting the
 310/// shell to redraw it, but the shell's redraw goes through zmx's IPC path with
 311/// cursor coordinates relative to the inner PTY, causing a cursor desync that
 312/// makes the prompt invisible.
 313/// See: https://github.com/neurosnap/zmx/issues/111
 314pub fn rewritePromptRedraw(alloc: std.mem.Allocator, data: []const u8) ?[]const u8 {
 315    // Fast-path: most PTY output has no escape sequences at all. A scalar
 316    // byte scan for ESC is cheaper than the full string indexOf below.
 317    if (std.mem.indexOfScalar(u8, data, '\x1b') == null) return null;
 318    if (std.mem.indexOf(u8, data, OSC_133_A) == null) return null;
 319
 320    var result = std.ArrayList(u8).initCapacity(alloc, data.len + 200) catch return null;
 321    errdefer result.deinit(alloc);
 322    result.appendSlice(alloc, data) catch return null;
 323
 324    // Work backwards so index shifts don't invalidate later positions.
 325    var search_from: usize = result.items.len;
 326    while (search_from > 0) {
 327        const haystack = result.items[0..search_from];
 328        const pos = std.mem.lastIndexOf(u8, haystack, OSC_133_A) orelse break;
 329        search_from = pos;
 330
 331        const after = pos + OSC_133_A.len;
 332        if (after >= result.items.len) continue;
 333
 334        // Find the string terminator (BEL \x07 or ST \x1b\\).
 335        var term_pos: ?usize = null;
 336        var j = after;
 337        while (j < result.items.len) : (j += 1) {
 338            if (result.items[j] == '\x07') {
 339                term_pos = j;
 340                break;
 341            }
 342            if (result.items[j] == '\x1b' and j + 1 < result.items.len and result.items[j + 1] == '\\') {
 343                term_pos = j;
 344                break;
 345            }
 346        }
 347        const end = term_pos orelse continue;
 348
 349        // Check the parameter region between OSC_133_A and the terminator.
 350        const params = result.items[after..end];
 351
 352        // If redraw=0 already present, skip.
 353        if (std.mem.indexOf(u8, params, "redraw=0") != null) continue;
 354
 355        // If redraw= exists with a different value, replace it.
 356        if (std.mem.indexOf(u8, params, "redraw=")) |rdw_offset| {
 357            const abs_rdw = after + rdw_offset;
 358            const value_start = abs_rdw + "redraw=".len;
 359            var value_end = value_start;
 360            while (value_end < end and result.items[value_end] != ';') : (value_end += 1) {}
 361            result.replaceRange(alloc, value_start, value_end - value_start, "0") catch return null;
 362            continue;
 363        }
 364
 365        // No redraw= present. Insert ;redraw=0 before the terminator.
 366        result.replaceRange(alloc, end, 0, ";redraw=0") catch return null;
 367    }
 368
 369    // If nothing changed, free and return null.
 370    if (std.mem.eql(u8, result.items, data)) {
 371        result.deinit(alloc);
 372        return null;
 373    }
 374
 375    return result.toOwnedSlice(alloc) catch null;
 376}
 377
 378test "rewritePromptRedraw: no OSC 133;A returns null" {
 379    const result = rewritePromptRedraw(std.testing.allocator, "hello world");
 380    try std.testing.expect(result == null);
 381}
 382
 383test "rewritePromptRedraw: injects redraw=0 with BEL terminator" {
 384    const input = "\x1b]133;A\x07";
 385    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 386    defer std.testing.allocator.free(result);
 387    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
 388}
 389
 390test "rewritePromptRedraw: injects redraw=0 with ST terminator" {
 391    const input = "\x1b]133;A\x1b\\";
 392    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 393    defer std.testing.allocator.free(result);
 394    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x1b\\", result);
 395}
 396
 397test "rewritePromptRedraw: replaces existing redraw=1" {
 398    const input = "\x1b]133;A;redraw=1\x07";
 399    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 400    defer std.testing.allocator.free(result);
 401    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
 402}
 403
 404test "rewritePromptRedraw: replaces existing redraw=last" {
 405    const input = "\x1b]133;A;redraw=last\x07";
 406    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 407    defer std.testing.allocator.free(result);
 408    try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
 409}
 410
 411test "rewritePromptRedraw: preserves redraw=0 (no-op)" {
 412    const result = rewritePromptRedraw(std.testing.allocator, "\x1b]133;A;redraw=0\x07");
 413    try std.testing.expect(result == null);
 414}
 415
 416test "rewritePromptRedraw: preserves other parameters" {
 417    const input = "\x1b]133;A;aid=14;cl=line\x07";
 418    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 419    defer std.testing.allocator.free(result);
 420    try std.testing.expectEqualStrings("\x1b]133;A;aid=14;cl=line;redraw=0\x07", result);
 421}
 422
 423test "rewritePromptRedraw: handles multiple markers" {
 424    const input = "before\x1b]133;A\x07middle\x1b]133;A;redraw=1\x07after";
 425    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 426    defer std.testing.allocator.free(result);
 427    try std.testing.expectEqualStrings("before\x1b]133;A;redraw=0\x07middle\x1b]133;A;redraw=0\x07after", result);
 428}
 429
 430test "rewritePromptRedraw: does not touch OSC 133;B or 133;C" {
 431    const input = "\x1b]133;B\x07\x1b]133;C\x07";
 432    const result = rewritePromptRedraw(std.testing.allocator, input);
 433    try std.testing.expect(result == null);
 434}
 435
 436test "rewritePromptRedraw: embedded in larger output" {
 437    const input = "some output\r\n\x1b]133;A\x07prompt$ \x1b]133;B\x07";
 438    const result = rewritePromptRedraw(std.testing.allocator, input).?;
 439    defer std.testing.allocator.free(result);
 440    try std.testing.expectEqualStrings("some output\r\n\x1b]133;A;redraw=0\x07prompt$ \x1b]133;B\x07", result);
 441}
 442
 443pub fn generateTaskId(io: std.Io) [4]u8 {
 444    var bytes: [2]u8 = undefined;
 445    io.random(&bytes);
 446    return std.fmt.bytesToHex(bytes, .lower);
 447}
 448
 449pub fn getTaskExitMarker(buf: []u8, id_marker: [4]u8) ![]u8 {
 450    return std.fmt.bufPrint(buf, "ZMX_TASK_COMPLETED:{s}:", .{id_marker});
 451}
 452
 453pub fn findTaskExitMarker(output: []const u8, id_marker: [4]u8) !?u8 {
 454    var buf: [1024]u8 = undefined;
 455    const marker = try getTaskExitMarker(&buf, id_marker);
 456
 457    // The command line is echoed back by the PTY (canonical mode) before the
 458    // shell evaluates it, so the *first* occurrence of the marker in the
 459    // output is often the literal, unexpanded "ZMX_TASK_COMPLETED:{id}:$?" from
 460    // the echo, not the real "ZMX_TASK_COMPLETED:{id}:<code>" written once the
 461    // shell actually runs it. Keep scanning past unparseable occurrences
 462    // instead of giving up on the first one.
 463    var search_start: usize = 0;
 464    while (std.mem.indexOfPos(u8, output, search_start, marker)) |idx| {
 465        const after_marker = output[idx + marker.len ..];
 466
 467        // Find the exit code number and newline
 468        var end_idx: usize = 0;
 469        while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
 470            end_idx += 1;
 471        }
 472
 473        const exit_code_str = after_marker[0..end_idx];
 474
 475        // Parse exit code
 476        if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
 477            return exit_code;
 478        } else |_| {
 479            search_start = idx + marker.len;
 480        }
 481    }
 482
 483    return null;
 484}
 485
 486/// Strip ANSI escape sequences from data, returning only printable characters
 487/// and essential whitespace (CR, LF, tab, backspace). Uses the ghostty VT
 488/// parser to correctly handle multi-byte sequences (CSI, OSC, DCS, etc.).
 489/// The returned slice is owned by the caller and must be freed.
 490pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
 491    var result = std.ArrayList(u8).initCapacity(alloc, data.len) catch unreachable;
 492    defer result.deinit(alloc);
 493
 494    var parser = ghostty_vt.Parser.init();
 495    for (data) |c| {
 496        const actions = parser.next(c);
 497        for (actions) |action_opt| {
 498            const action = action_opt orelse continue;
 499            switch (action) {
 500                .print => {
 501                    result.append(alloc, c) catch unreachable;
 502                },
 503                .execute => |code| {
 504                    // Pass through essential whitespace/control chars
 505                    switch (code) {
 506                        '\r', '\n', '\t', 0x08 => { // CR, LF, TAB, BS
 507                            result.append(alloc, @as(u8, @intCast(code))) catch unreachable;
 508                        },
 509                        else => {},
 510                    }
 511                },
 512                // All other actions (CSI, OSC, DCS, etc.) are silently dropped
 513                else => {},
 514            }
 515        }
 516    }
 517
 518    return result.toOwnedSlice(alloc);
 519}
 520
 521/// Dcts Ctrl+\ across raw, Kitty CSI u, and xterm modifyOtherKeys encodings.
 522pub fn isCtrlBackslash(buf: []const u8) bool {
 523    if (buf.len == 0) return false;
 524    return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100) or isModifyOtherKey(buf, 0x5c, 0b100);
 525}
 526
 527/// Scans the buffer for an xterm modifyOtherKeys-encoded keypress.
 528/// Format: CSI 27 ; <modifier> ; <keycode> ~
 529/// Reference: invisible-island.net/xterm/ctlseqs/ctlseqs.html (modifyOtherKeys).
 530fn isModifyOtherKey(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 531    var i: usize = 0;
 532    while (i + 1 < buf.len) : (i += 1) {
 533        if (buf[i] == 0x1b and buf[i + 1] == '[') {
 534            if (modifyOtherMatches(buf[i + 2 ..], expected_key, expected_mods)) return true;
 535        }
 536    }
 537    return false;
 538}
 539
 540/// Parses the body of an xterm modifyOtherKeys CSI sequence (after the leading
 541/// `\x1b[`). Mirrors keypressWithMod's tolerance for lock modifiers.
 542fn modifyOtherMatches(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 543    var pos: usize = 0;
 544
 545    // 1. Sentinel: literal "27" identifies xterm modifyOtherKeys.
 546    const sentinel = parseDecimal(buf, &pos) orelse return false;
 547    if (sentinel != 27) return false;
 548
 549    // 2. Expect ';' before modifier.
 550    if (pos >= buf.len or buf[pos] != ';') return false;
 551    pos += 1;
 552
 553    // 3. Parse modifier (xterm encodes as 1 + bitfield, same as kitty).
 554    const mod_encoded = parseDecimal(buf, &pos) orelse return false;
 555    if (mod_encoded < 1) return false;
 556    const mod_raw = mod_encoded - 1;
 557    // Tolerate ambient lock modifiers (caps_lock=64, num_lock=128).
 558    const intentional_mods = mod_raw & 0b00111111;
 559    if (expected_mods > 0 and expected_mods != intentional_mods) return false;
 560
 561    // 4. Expect ';' before keycode.
 562    if (pos >= buf.len or buf[pos] != ';') return false;
 563    pos += 1;
 564
 565    // 5. Parse keycode.
 566    const key_code = parseDecimal(buf, &pos) orelse return false;
 567    if (key_code != expected_key) return false;
 568
 569    // 6. Expect '~' terminator.
 570    return pos < buf.len and buf[pos] == '~';
 571}
 572
 573/// Returns true when the user has opted out of the ctrl+\ detach shortcut
 574/// via ZMX_NO_DETACH_KEY, e.g. to free up ctrl+\ for an inner program
 575/// like vim, which uses ctrl+\ ctrl+n to escape its own terminal mode.
 576pub fn isDetachKeyDisabled() bool {
 577    return lib_posix.getenv("ZMX_NO_DETACH_KEY") != null;
 578}
 579
 580/// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
 581pub fn isUpArrow(buf: []const u8) bool {
 582    return std.mem.eql(u8, buf, "\x1b[A") or std.mem.eql(u8, buf, "\x1b[1;1:1A");
 583}
 584
 585fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 586    // Scan for any CSI u sequence encoding in the buffer.
 587    var i: usize = 0;
 588    while (i + 2 < buf.len) : (i += 1) {
 589        if (buf[i] == 0x1b and buf[i + 1] == '[') {
 590            if (keypressWithMod(buf[i + 2 ..], expected_key, expected_mods)) return true;
 591        }
 592    }
 593    return false;
 594}
 595
 596/// Parses the general CSI u form:
 597///   CSI key-code[:alternates] ; modifiers[:event-type] [; text-codepoints] u
 598///
 599/// Event type is press (1 or absent) or repeat (2). Rejects release (3).
 600/// Tolerates additional modifiers (caps_lock, num_lock)
 601/// and alternate key sub-fields from the kitty protocol's progressive
 602/// enhancement flags.
 603fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
 604    const parsed = parseKittyCsiU(buf) orelse return false;
 605    if (parsed.key_code != expected_key) return false;
 606
 607    // Only accept intentional modifiers. Lock modifiers
 608    // (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because
 609    // they are ambient state, not deliberate key combinations.
 610    const intentional_mods = parsed.modifiers & 0b00111111;
 611    if (expected_mods > 0 and expected_mods != intentional_mods) return false;
 612
 613    // 3 = release -- reject. Accept press (1) and repeat (2).
 614    return parsed.event_type != 3;
 615}
 616
 617const KittyCsiU = struct {
 618    key_code: u32,
 619    modifiers: u32,
 620    event_type: u32,
 621    consumed: usize,
 622};
 623
 624fn parseKittyCsiU(buf: []const u8) ?KittyCsiU {
 625    var pos: usize = 0;
 626
 627    // 1. Parse key code.
 628    const key_code = parseDecimal(buf, &pos) orelse return null;
 629
 630    // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key).
 631    while (pos < buf.len and buf[pos] == ':') {
 632        pos += 1; // consume ':'
 633        _ = parseDecimal(buf, &pos); // consume digits (may be empty for ::base)
 634    }
 635
 636    // 3. Expect ';' separator before modifiers.
 637    if (pos >= buf.len or buf[pos] != ';') return null;
 638    pos += 1;
 639
 640    // 4. Parse modifier value. Kitty encodes as 1 + bitfield.
 641    const mod_encoded = parseDecimal(buf, &pos) orelse return null;
 642    if (mod_encoded < 1) return null;
 643    const mod_raw = mod_encoded - 1;
 644
 645    var event_type: u32 = 1;
 646    // 5. Parse optional event type after ':'.
 647    if (pos < buf.len and buf[pos] == ':') {
 648        pos += 1;
 649        event_type = parseDecimal(buf, &pos) orelse return null;
 650    }
 651
 652    // 6. Skip optional ';text-codepoints' section.
 653    if (pos < buf.len and buf[pos] == ';') {
 654        pos += 1;
 655        // Consume remaining digits and colons until 'u'.
 656        while (pos < buf.len and (std.ascii.isDigit(buf[pos]) or buf[pos] == ':')) {
 657            pos += 1;
 658        }
 659    }
 660
 661    // 7. Expect terminal 'u'.
 662    if (pos >= buf.len or buf[pos] != 'u') return null;
 663    pos += 1;
 664
 665    return .{
 666        .key_code = key_code,
 667        .modifiers = mod_raw,
 668        .event_type = event_type,
 669        .consumed = pos,
 670    };
 671}
 672
 673/// Parse a decimal integer from buf starting at pos, advancing pos past the
 674/// consumed digits. Returns null if no digits are present.
 675fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
 676    const start = pos.*;
 677    var value: u32 = 0;
 678    while (pos.* < buf.len and std.ascii.isDigit(buf[pos.*])) {
 679        value = value *% 10 +% (buf[pos.*] - '0');
 680        pos.* += 1;
 681    }
 682    if (pos.* == start) return null;
 683    return value;
 684}
 685
 686/// Detect if the payload contains user input that should be printed to the screen or
 687/// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
 688pub fn isUserInput(payload: []const u8) bool {
 689    var parser = ghostty_vt.Parser.init();
 690    var i: usize = 0;
 691    while (i < payload.len) {
 692        if (payload[i] == 0x1b and i + 2 < payload.len and payload[i + 1] == '[') {
 693            if (parseKittyCsiU(payload[i + 2 ..])) |kitty| {
 694                if (kitty.event_type != 3) return true;
 695                i += 2 + kitty.consumed;
 696                continue;
 697            }
 698        }
 699
 700        const actions = parser.next(payload[i]);
 701        for (actions) |action_opt| {
 702            const action = action_opt orelse continue;
 703            switch (action) {
 704                .print => return true, // printable characters
 705                .csi_dispatch => |csi| {
 706                    // kitty keyboard: CSI ... u or CSI ... ~
 707                    // legacy modified keys: CSI 27 ; ... ~
 708                    // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
 709                    if (csi.final == 'u' or csi.final == '~') return true;
 710                    // modified arrow keys (e.g., Ctrl+F sends CSI 1;5C in legacy mode)
 711                    if (csi.final >= 'A' and csi.final <= 'D' and csi.params.len > 1) return true;
 712                    // mouse events: CSI M (basic) or CSI < (SGR extended) - EXCLUDE these
 713                    // only intentional keyboard input should trigger leader switch
 714                    if (csi.final == 'M' or csi.final == '<') return false;
 715                    // focus events: CSI I (focus in) or CSI O (focus out) - EXCLUDE these
 716                    // these are automatic terminal events, not user typing
 717                    if (csi.final == 'I' or csi.final == 'O') return false;
 718                },
 719                .execute => |code| {
 720                    // looking for CR, LF, tab, and backspace
 721                    if (code == 0x0D or code == 0x0A or code == 0x09 or code == 0x08) return true;
 722                },
 723                else => {},
 724            }
 725        }
 726        i += 1;
 727    }
 728    return false;
 729}
 730
 731/// Emit the terminal's pwd as OSC 7.
 732///
 733/// This replaces the formatter's own `extra.pwd`, which writes
 734/// `terminal.pwd.items` verbatim. `Terminal.setPwd` appends a NUL sentinel to
 735/// that buffer, so the formatter's OSC 7 carries a stray `\x00` before the
 736/// terminator. `getPwd()` returns the same bytes without the sentinel.
 737///
 738/// The NUL is not cosmetic: a client that records what it receives (kitty
 739/// writing its session file, for one) persists the NUL and then fails to parse
 740/// its own state back. See https://github.com/neurosnap/zmx/issues/222.
 741fn writePwd(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
 742    const pwd = term.getPwd() orelse return;
 743    if (pwd.len == 0) return;
 744    writer.print("\x1b]7;{s}\x1b\\", .{pwd}) catch |err| {
 745        std.log.warn("failed to format pwd err={s}", .{@errorName(err)});
 746    };
 747}
 748
 749pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
 750    var builder: std.Io.Writer.Allocating = .init(alloc);
 751    defer builder.deinit();
 752
 753    // Synchronized output (DECSET 2026) is a transient rendering handshake
 754    // between a program and its current terminal client. Replaying it to a
 755    // newly attached client can leave that client deferring renders until its
 756    // local timeout fires, so temporarily exclude it from restored state and
 757    // restore the original mode before returning.
 758    const had_synchronized_output = term.modes.get(.synchronized_output);
 759    if (had_synchronized_output) {
 760        term.modes.set(.synchronized_output, false);
 761    }
 762
 763    const pages = &term.screens.active.pages;
 764    const screen_top = pages.getTopLeft(.screen);
 765    const active_top = pages.getTopLeft(.active);
 766    const has_scrollback = !screen_top.eql(active_top);
 767
 768    // Two-phase serialization to preserve scrollback without corrupting
 769    // cursor positions. This matters for nested zmx sessions (zmx→SSH→zmx)
 770    // where the outer daemon's ghostty-vt accumulates inner session scrollback.
 771    //
 772    // Phase 1: Emit scrollback content (plain text with styles, no terminal extras).
 773    // These lines scroll past the visible area into the terminal's scrollback buffer.
 774    // Phase 2: Clear visible screen, then emit visible content with full extras.
 775    // The clear ensures visible content starts from a clean slate regardless of
 776    // how much scrollback preceded it. CUP cursor positioning is then correct.
 777    //
 778    // See: https://github.com/neurosnap/zmx/issues/31
 779
 780    // Phase 1: scrollback only (if any exists)
 781    if (has_scrollback) {
 782        if (active_top.up(1)) |sb_bottom_row| {
 783            var sb_bottom = sb_bottom_row;
 784            sb_bottom.x = @intCast(pages.cols - 1);
 785
 786            var scroll_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
 787            scroll_fmt.content = .{
 788                .selection = ghostty_vt.Selection.init(
 789                    screen_top,
 790                    sb_bottom,
 791                    false,
 792                ),
 793            };
 794            scroll_fmt.extra = .none; // no modes, cursor, keyboard — just content
 795            scroll_fmt.format(&builder.writer) catch |err| {
 796                std.log.warn("failed to format scrollback err={s}", .{@errorName(err)});
 797            };
 798        }
 799
 800        // Clear visible screen after scrollback. \x1b[2J clears only the visible
 801        // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets
 802        // SGR style so phase 1 styles don't bleed into phase 2.
 803        builder.writer.writeAll("\x1b[2J\x1b[H\x1b[0m") catch {};
 804    }
 805
 806    // Phase 2: visible screen with full extras (modes, cursor, keyboard, etc.)
 807    var vis_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
 808
 809    // Restrict content to the active viewport only
 810    const active_tl = pages.pin(.{ .active = .{ .x = 0, .y = 0 } });
 811    const active_br = pages.pin(.{
 812        .active = .{
 813            .x = @intCast(pages.cols - 1),
 814            .y = @intCast(pages.rows - 1),
 815        },
 816    });
 817
 818    if (active_tl != null and active_br != null) {
 819        vis_fmt.content = .{
 820            .selection = ghostty_vt.Selection.init(
 821                active_tl.?,
 822                active_br.?,
 823                false,
 824            ),
 825        };
 826    }
 827    // Fallback: if pins are somehow invalid, use null selection (all content)
 828
 829    vis_fmt.extra = .{
 830        .palette = false,
 831        .modes = true,
 832        .scrolling_region = true,
 833        .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
 834        .pwd = false, // emitted below without the sentinel the formatter includes
 835        .keyboard = true,
 836        .screen = .all,
 837    };
 838
 839    vis_fmt.format(&builder.writer) catch |err| {
 840        std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
 841        return null;
 842    };
 843
 844    writePwd(&builder.writer, term);
 845
 846    // The formatter has no title extra and never emits OSC 0/1/2, so the title
 847    // has to be replayed separately or an attaching client shows whatever its
 848    // terminal defaults to, usually the client process name. OSC 2 does not
 849    // move the cursor, so this is safe to append after the content.
 850    if (term.getTitle()) |title| {
 851        builder.writer.print("\x1b]2;{s}\x07", .{title}) catch |err| {
 852            std.log.warn("failed to format title err={s}", .{@errorName(err)});
 853        };
 854    }
 855
 856    const output = builder.writer.buffered();
 857    if (output.len == 0) return null;
 858
 859    // Restore the original synchronized_output mode before returning
 860    if (had_synchronized_output) {
 861        term.modes.set(.synchronized_output, true);
 862    }
 863
 864    return alloc.dupe(u8, output) catch |err| {
 865        std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
 866        return null;
 867    };
 868}
 869
 870pub const HistoryFormat = enum(u8) {
 871    plain = 0,
 872    vt = 1,
 873    html = 2,
 874};
 875
 876pub fn serializeTerminal(
 877    alloc: std.mem.Allocator,
 878    term: *ghostty_vt.Terminal,
 879    format: HistoryFormat,
 880) ?[]const u8 {
 881    var builder: std.Io.Writer.Allocating = .init(alloc);
 882    defer builder.deinit();
 883
 884    const opts: ghostty_vt.formatter.Options = switch (format) {
 885        .plain => .plain,
 886        .vt => .vt,
 887        .html => .html,
 888    };
 889    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
 890    term_formatter.content = .{ .selection = null };
 891    term_formatter.extra = switch (format) {
 892        .plain => .none,
 893        .vt => .{
 894            .palette = false,
 895            .modes = true,
 896            .scrolling_region = true,
 897            .tabstops = false,
 898            .pwd = false, // emitted below without the sentinel the formatter includes
 899            .keyboard = true,
 900            .screen = .all,
 901        },
 902        .html => .styles,
 903    };
 904
 905    term_formatter.format(&builder.writer) catch |err| {
 906        std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
 907        return null;
 908    };
 909
 910    if (format == .vt) writePwd(&builder.writer, term);
 911
 912    const output = builder.writer.buffered();
 913    if (output.len == 0) return null;
 914
 915    return alloc.dupe(u8, output) catch |err| {
 916        std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
 917        return null;
 918    };
 919}
 920
 921/// Formats a session entry for list output (only the name when `short` is
 922/// true), adding a prefix to indicate the current session, if there is one.
 923pub fn writeSessionLine(
 924    writer: *std.Io.Writer,
 925    session: SessionEntry,
 926    short: bool,
 927    current_session: ?[]const u8,
 928) !void {
 929    const current_arrow = "→";
 930    const prefix = if (current_session) |current|
 931        if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else "  "
 932    else
 933        "";
 934
 935    if (short) {
 936        if (session.is_error) return;
 937        try writer.print("{s}\n", .{session.name});
 938        return;
 939    }
 940
 941    if (session.is_error) {
 942        // "cleaning up" is only truthful when the probe was definitively
 943        // refused (socket deleted this pass). On Timeout/Unexpected the
 944        // daemon may just be busy, so don't lie about what we did.
 945        const status = if (std.mem.eql(u8, session.error_name.?, "ConnectionRefused"))
 946            "cleaning up"
 947        else
 948            "unreachable";
 949        try writer.print("{s}name={s}\terr={s}\tstatus={s}\n", .{
 950            prefix,
 951            session.name,
 952            session.error_name.?,
 953            status,
 954        });
 955        return;
 956    }
 957
 958    try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
 959        prefix,
 960        session.name,
 961        session.pid.?,
 962        session.clients_len.?,
 963        session.created_at,
 964    });
 965    if (session.cwd) |cwd| {
 966        try writer.print("\tcwd={s}", .{cwd});
 967    }
 968    if (session.cmd) |cmd| {
 969        try writer.print("\tcmd={s}", .{cmd});
 970    }
 971    if (session.task_ended_at) |ended_at| {
 972        if (ended_at > 0) {
 973            try writer.print("\tended={d}", .{ended_at});
 974
 975            if (session.task_exit_code) |exit_code| {
 976                try writer.print("\texit_code={d}", .{exit_code});
 977            }
 978        }
 979    }
 980    if (session.labels) |labels| {
 981        var kvs = label.LabelIterator.init(labels);
 982        while (kvs.next()) |kv| {
 983            try writer.print("\t{s}={s}", .{ kv.key, kv.value });
 984        }
 985    }
 986    try writer.print("\n", .{});
 987}
 988
 989test "writeSessionLine formats output for current session and short output" {
 990    const Case = struct {
 991        session: SessionEntry,
 992        short: bool,
 993        current_session: ?[]const u8,
 994        expected: []const u8,
 995    };
 996
 997    const session = SessionEntry{
 998        .name = "dev",
 999        .pid = 123,
1000        .clients_len = 2,
1001        .is_error = false,
1002        .error_name = null,
1003        .cmd = null,
1004        .cwd = null,
1005        .created_at = 0,
1006        .task_ended_at = null,
1007        .task_exit_code = null,
1008    };
1009
1010    const cases = [_]Case{
1011        .{
1012            .session = session,
1013            .short = false,
1014            .current_session = "dev",
1015            .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
1016        },
1017        .{
1018            .session = session,
1019            .short = false,
1020            .current_session = "other",
1021            .expected = "  name=dev\tpid=123\tclients=2\tcreated=0\n",
1022        },
1023        .{
1024            .session = session,
1025            .short = false,
1026            .current_session = null,
1027            .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
1028        },
1029        .{
1030            .session = session,
1031            .short = true,
1032            .current_session = "dev",
1033            .expected = "dev\n",
1034        },
1035        .{
1036            .session = session,
1037            .short = true,
1038            .current_session = "other",
1039            .expected = "dev\n",
1040        },
1041        .{
1042            .session = session,
1043            .short = true,
1044            .current_session = null,
1045            .expected = "dev\n",
1046        },
1047    };
1048
1049    for (cases) |case| {
1050        var builder: std.Io.Writer.Allocating = .init(testing.allocator);
1051        defer builder.deinit();
1052
1053        try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
1054        try testing.expectEqualStrings(case.expected, builder.writer.buffered());
1055    }
1056}
1057
1058test "shellNeedsQuoting" {
1059    try testing.expect(shellNeedsQuoting(""));
1060    try testing.expect(shellNeedsQuoting("hello world"));
1061    try testing.expect(shellNeedsQuoting("hello!"));
1062    try testing.expect(shellNeedsQuoting("$PATH"));
1063    try testing.expect(shellNeedsQuoting("it's"));
1064    try testing.expect(shellNeedsQuoting("a|b"));
1065    try testing.expect(shellNeedsQuoting("a;b"));
1066    try testing.expect(!shellNeedsQuoting("hello"));
1067    try testing.expect(!shellNeedsQuoting("bash"));
1068    try testing.expect(!shellNeedsQuoting("-c"));
1069    try testing.expect(!shellNeedsQuoting("/usr/bin/env"));
1070}
1071
1072test "shellQuote" {
1073    const alloc = testing.allocator;
1074
1075    const empty = try shellQuote(alloc, "");
1076    defer alloc.free(empty);
1077    try testing.expectEqualStrings("''", empty);
1078
1079    const space = try shellQuote(alloc, "hello world");
1080    defer alloc.free(space);
1081    try testing.expectEqualStrings("'hello world'", space);
1082
1083    const bang = try shellQuote(alloc, "hello!");
1084    defer alloc.free(bang);
1085    try testing.expectEqualStrings("'hello!'", bang);
1086
1087    const dollar = try shellQuote(alloc, "$PATH");
1088    defer alloc.free(dollar);
1089    try testing.expectEqualStrings("'$PATH'", dollar);
1090
1091    const sq = try shellQuote(alloc, "it's");
1092    defer alloc.free(sq);
1093    try testing.expectEqualStrings("'it'\\''s'", sq);
1094
1095    const dq = try shellQuote(alloc, "say \"hi\"");
1096    defer alloc.free(dq);
1097    try testing.expectEqualStrings("'say \"hi\"'", dq);
1098
1099    const both = try shellQuote(alloc, "it's \"cool\"");
1100    defer alloc.free(both);
1101    try testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
1102
1103    // just a single quote
1104    const lone_sq = try shellQuote(alloc, "'");
1105    defer alloc.free(lone_sq);
1106    try testing.expectEqualStrings("''\\'''", lone_sq);
1107
1108    // multiple consecutive single quotes
1109    const triple_sq = try shellQuote(alloc, "'''");
1110    defer alloc.free(triple_sq);
1111    try testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
1112
1113    // backtick command substitution
1114    const backtick = try shellQuote(alloc, "`whoami`");
1115    defer alloc.free(backtick);
1116    try testing.expectEqualStrings("'`whoami`'", backtick);
1117
1118    // dollar command substitution
1119    const dollar_cmd = try shellQuote(alloc, "$(whoami)");
1120    defer alloc.free(dollar_cmd);
1121    try testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
1122
1123    // glob
1124    const glob = try shellQuote(alloc, "*.txt");
1125    defer alloc.free(glob);
1126    try testing.expectEqualStrings("'*.txt'", glob);
1127
1128    // tilde
1129    const tilde = try shellQuote(alloc, "~/file");
1130    defer alloc.free(tilde);
1131    try testing.expectEqualStrings("'~/file'", tilde);
1132
1133    // trailing backslash
1134    const trailing_bs = try shellQuote(alloc, "path\\");
1135    defer alloc.free(trailing_bs);
1136    try testing.expectEqualStrings("'path\\'", trailing_bs);
1137
1138    // semicolon (command injection)
1139    const semi = try shellQuote(alloc, "; rm -rf /");
1140    defer alloc.free(semi);
1141    try testing.expectEqualStrings("'; rm -rf /'", semi);
1142
1143    // embedded newline
1144    const newline = try shellQuote(alloc, "line1\nline2");
1145    defer alloc.free(newline);
1146    try testing.expectEqualStrings("'line1\nline2'", newline);
1147
1148    // parentheses (subshell)
1149    const parens = try shellQuote(alloc, "(echo hi)");
1150    defer alloc.free(parens);
1151    try testing.expectEqualStrings("'(echo hi)'", parens);
1152
1153    // heredoc marker
1154    const heredoc = try shellQuote(alloc, "<<EOF");
1155    defer alloc.free(heredoc);
1156    try testing.expectEqualStrings("'<<EOF'", heredoc);
1157
1158    // no quoting needed -- plain word should still be quoted
1159    // (shellQuote is only called when shellNeedsQuoting returns true,
1160    // but verify it produces valid output anyway)
1161    const plain = try shellQuote(alloc, "hello");
1162    defer alloc.free(plain);
1163    try testing.expectEqualStrings("'hello'", plain);
1164}
1165
1166test "isCtrlBackslash" {
1167    const expect = testing.expect;
1168
1169    // Basic: ctrl only (modifier 5 = 1 + 4)
1170    try expect(isCtrlBackslash("\x1b[92;5u"));
1171
1172    // Explicit press event type (:1)
1173    try expect(isCtrlBackslash("\x1b[92;5:1u"));
1174
1175    // Repeat event (:2) -- user holding Ctrl+\
1176    try expect(isCtrlBackslash("\x1b[92;5:2u"));
1177
1178    // Release event (:3) -- must NOT trigger detach
1179    try expect(!isCtrlBackslash("\x1b[92;5:3u"));
1180
1181    // Lock modifiers: caps_lock (bit 6) changes modifier value
1182    // ctrl + caps_lock = 1 + (4 + 64) = 69
1183    try expect(isCtrlBackslash("\x1b[92;69u"));
1184    try expect(isCtrlBackslash("\x1b[92;69:1u"));
1185    try expect(!isCtrlBackslash("\x1b[92;69:3u"));
1186
1187    // ctrl + num_lock = 1 + (4 + 128) = 133
1188    try expect(isCtrlBackslash("\x1b[92;133u"));
1189
1190    // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
1191    try expect(isCtrlBackslash("\x1b[92;197u"));
1192
1193    // Combined intentional modifiers -- must NOT match (ctrl+\ is the
1194    // detach key, not ctrl+shift+\ or ctrl+alt+\)
1195    // ctrl + shift = 1 + (4 + 1) = 6
1196    try expect(!isCtrlBackslash("\x1b[92;6u"));
1197
1198    // ctrl + alt = 1 + (4 + 2) = 7
1199    try expect(!isCtrlBackslash("\x1b[92;7u"));
1200
1201    // ctrl + super = 1 + (4 + 8) = 13
1202    try expect(!isCtrlBackslash("\x1b[92;13u"));
1203
1204    // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
1205    try expect(!isCtrlBackslash("\x1b[92;70u"));
1206
1207    // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
1208    try expect(!isCtrlBackslash("\x1b[92;134u"));
1209
1210    // Modifier without ctrl bit -- must NOT match
1211    // shift only = 1 + 1 = 2
1212    try expect(!isCtrlBackslash("\x1b[92;1u"));
1213    try expect(!isCtrlBackslash("\x1b[92;2u"));
1214
1215    // Alternate key sub-fields (report_alternates flag)
1216    // shifted key | (124): \x1b[92:124;5u
1217    try expect(isCtrlBackslash("\x1b[92:124;5u"));
1218
1219    // base layout key only (non-US keyboard): \x1b[92::92;5u
1220    try expect(isCtrlBackslash("\x1b[92::92;5u"));
1221
1222    // both shifted and base layout: \x1b[92:124:92;5u
1223    try expect(isCtrlBackslash("\x1b[92:124:92;5u"));
1224
1225    // Alternate keys + lock modifiers + event type
1226    try expect(isCtrlBackslash("\x1b[92:124;69:1u"));
1227    try expect(!isCtrlBackslash("\x1b[92:124;69:3u"));
1228
1229    // Text codepoints section (flag 0b10000) -- tolerated and skipped
1230    // Even though ctrl+\ text is typically empty, terminals may vary
1231    try expect(isCtrlBackslash("\x1b[92;5;28u"));
1232    try expect(isCtrlBackslash("\x1b[92;5;28:92u"));
1233
1234    // Wrong key code -- must NOT match
1235    try expect(!isCtrlBackslash("\x1b[91;5u"));
1236    try expect(!isCtrlBackslash("\x1b[93;5u"));
1237    try expect(!isCtrlBackslash("\x1b[9;5u"));
1238    try expect(!isCtrlBackslash("\x1b[920;5u"));
1239
1240    // Sequence embedded in larger buffer (e.g., preceded by other input)
1241    try expect(isCtrlBackslash("abc\x1b[92;5u"));
1242    try expect(isCtrlBackslash("\x1b[A\x1b[92;5u"));
1243
1244    // Garbage / malformed inputs
1245    try expect(!isCtrlBackslash("garbage"));
1246    try expect(!isCtrlBackslash(""));
1247    try expect(!isCtrlBackslash("\x1b["));
1248    try expect(!isCtrlBackslash("\x1b[92"));
1249    try expect(!isCtrlBackslash("\x1b[92;"));
1250    try expect(!isCtrlBackslash("\x1b[92;u"));
1251    try expect(!isCtrlBackslash("\x1b[;5u"));
1252
1253    // Other CSI u sequences that happen to contain '92' elsewhere
1254    try expect(!isCtrlBackslash("\x1b[65;92u"));
1255}
1256
1257test "isCtrlBackslash xterm modifyOtherKeys" {
1258    const expect = std.testing.expect;
1259
1260    // Basic: ctrl only (modifier 5 = 1 + 4), key 92 = '\'
1261    // Format: CSI 27 ; <mod> ; <key> ~
1262    try expect(isCtrlBackslash("\x1b[27;5;92~"));
1263
1264    // Lock modifiers tolerated
1265    // ctrl + caps_lock = 1 + (4 + 64) = 69
1266    try expect(isCtrlBackslash("\x1b[27;69;92~"));
1267    // ctrl + num_lock = 1 + (4 + 128) = 133
1268    try expect(isCtrlBackslash("\x1b[27;133;92~"));
1269    // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
1270    try expect(isCtrlBackslash("\x1b[27;197;92~"));
1271
1272    // Combined intentional modifiers must NOT match
1273    // ctrl + shift = 1 + (4 + 1) = 6
1274    try expect(!isCtrlBackslash("\x1b[27;6;92~"));
1275    // ctrl + alt = 1 + (4 + 2) = 7
1276    try expect(!isCtrlBackslash("\x1b[27;7;92~"));
1277    // ctrl + super = 1 + (4 + 8) = 13
1278    try expect(!isCtrlBackslash("\x1b[27;13;92~"));
1279    // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
1280    try expect(!isCtrlBackslash("\x1b[27;70;92~"));
1281    // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
1282    try expect(!isCtrlBackslash("\x1b[27;134;92~"));
1283
1284    // Modifier without ctrl bit -- must NOT match
1285    try expect(!isCtrlBackslash("\x1b[27;1;92~"));
1286    try expect(!isCtrlBackslash("\x1b[27;2;92~"));
1287
1288    // Wrong key code -- must NOT match
1289    try expect(!isCtrlBackslash("\x1b[27;5;91~"));
1290    try expect(!isCtrlBackslash("\x1b[27;5;93~"));
1291    try expect(!isCtrlBackslash("\x1b[27;5;65~"));
1292
1293    // Wrong sentinel -- must NOT match
1294    try expect(!isCtrlBackslash("\x1b[28;5;92~"));
1295    try expect(!isCtrlBackslash("\x1b[26;5;92~"));
1296
1297    // Wrong terminator -- must NOT match
1298    try expect(!isCtrlBackslash("\x1b[27;5;92u"));
1299    try expect(!isCtrlBackslash("\x1b[27;5;92m"));
1300
1301    // CSI sequences that look similar but are not modifyOtherKeys
1302    try expect(!isCtrlBackslash("\x1b[27m")); // SGR reset reverse
1303    try expect(!isCtrlBackslash("\x1b[27~")); // xterm F4
1304    try expect(!isCtrlBackslash("\x1b[27;5R")); // truncated cursor report
1305
1306    // Sequence embedded in larger buffer
1307    try expect(isCtrlBackslash("abc\x1b[27;5;92~"));
1308    try expect(isCtrlBackslash("\x1b[A\x1b[27;5;92~"));
1309
1310    // Garbage / malformed
1311    try expect(!isCtrlBackslash("\x1b[27"));
1312    try expect(!isCtrlBackslash("\x1b[27;"));
1313    try expect(!isCtrlBackslash("\x1b[27;5"));
1314    try expect(!isCtrlBackslash("\x1b[27;5;"));
1315    try expect(!isCtrlBackslash("\x1b[27;5;92"));
1316}
1317
1318test "isDetachKeyDisabled" {
1319    _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY");
1320    try testing.expect(!isDetachKeyDisabled());
1321
1322    _ = cross.c.setenv("ZMX_NO_DETACH_KEY", "1", 1);
1323    defer _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY");
1324    try testing.expect(isDetachKeyDisabled());
1325}
1326
1327test "parseOsc7Cwd" {
1328    const Case = struct {
1329        name: []const u8,
1330        value: []const u8,
1331        hostname: []const u8,
1332        expected: ?Cwd,
1333    };
1334
1335    const cases = [_]Case{
1336        .{
1337            .name = "local file uri",
1338            .value = "file://myhost/private/tmp",
1339            .hostname = "myhost",
1340            .expected = .{ .path = "/private/tmp", .is_local = true },
1341        },
1342        .{
1343            .name = "percent-encoded path is decoded",
1344            .value = "file://myhost/tmp/zmx%20spaced%20dir",
1345            .hostname = "myhost",
1346            .expected = .{ .path = "/tmp/zmx spaced dir", .is_local = true },
1347        },
1348        .{
1349            .name = "kitty scheme",
1350            .value = "kitty-shell-cwd://myhost/private/tmp",
1351            .hostname = "myhost",
1352            .expected = .{ .path = "/private/tmp", .is_local = true },
1353        },
1354        .{
1355            .name = "remote host keeps the path but is not local",
1356            .value = "file://otherhost/home/me",
1357            .hostname = "myhost",
1358            .expected = .{ .path = "/home/me", .is_local = false },
1359        },
1360        .{
1361            .name = "empty host means local",
1362            .value = "file:///private/tmp",
1363            .hostname = "myhost",
1364            .expected = .{ .path = "/private/tmp", .is_local = true },
1365        },
1366        .{
1367            .name = "localhost means local",
1368            .value = "file://localhost/private/tmp",
1369            .hostname = "myhost",
1370            .expected = .{ .path = "/private/tmp", .is_local = true },
1371        },
1372        .{
1373            .name = "fqdn matches a short hostname",
1374            .value = "file://myhost.local/private/tmp",
1375            .hostname = "myhost",
1376            .expected = .{ .path = "/private/tmp", .is_local = true },
1377        },
1378        .{
1379            .name = "short host matches an fqdn hostname",
1380            .value = "file://myhost/private/tmp",
1381            .hostname = "myhost.lan",
1382            .expected = .{ .path = "/private/tmp", .is_local = true },
1383        },
1384        .{
1385            .name = "host comparison ignores case",
1386            .value = "file://MyHost/private/tmp",
1387            .hostname = "myhost",
1388            .expected = .{ .path = "/private/tmp", .is_local = true },
1389        },
1390        .{
1391            .name = "plain absolute path passes through",
1392            .value = "/private/tmp",
1393            .hostname = "myhost",
1394            .expected = .{ .path = "/private/tmp", .is_local = true },
1395        },
1396        .{
1397            .name = "empty value",
1398            .value = "",
1399            .hostname = "myhost",
1400            .expected = null,
1401        },
1402        .{
1403            .name = "relative path",
1404            .value = "some/dir",
1405            .hostname = "myhost",
1406            .expected = null,
1407        },
1408        .{
1409            .name = "unsupported scheme",
1410            .value = "http://myhost/private/tmp",
1411            .hostname = "myhost",
1412            .expected = null,
1413        },
1414        .{
1415            .name = "uri without a path",
1416            .value = "file://myhost",
1417            .hostname = "myhost",
1418            .expected = null,
1419        },
1420    };
1421
1422    for (cases) |c| {
1423        var buf: [std.fs.max_path_bytes]u8 = undefined;
1424        const actual = parseOsc7Cwd(&buf, c.value, c.hostname);
1425        testing.expectEqualDeep(c.expected, actual) catch |err| {
1426            std.debug.print("case: {s}\n", .{c.name});
1427            return err;
1428        };
1429    }
1430}
1431
1432test "parseOsc7Cwd result survives the source value changing" {
1433    var buf: [std.fs.max_path_bytes]u8 = undefined;
1434    var value: [32]u8 = undefined;
1435    const src = "file://myhost/private/tmp";
1436    @memcpy(value[0..src.len], src);
1437
1438    const cwd = parseOsc7Cwd(&buf, value[0..src.len], "myhost") orelse
1439        return error.TestUnexpectedNull;
1440
1441    @memset(&value, 'x');
1442    try testing.expectEqualDeep(Cwd{ .path = "/private/tmp", .is_local = true }, cwd);
1443}
1444
1445test "parseOsc7Cwd rejects a path longer than the buffer" {
1446    var buf: [8]u8 = undefined;
1447    try testing.expectEqual(
1448        @as(?Cwd, null),
1449        parseOsc7Cwd(&buf, "file://myhost/a/very/long/path", "myhost"),
1450    );
1451    try testing.expectEqual(
1452        @as(?Cwd, null),
1453        parseOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1454    );
1455}
1456
1457test "toOsc7Cwd" {
1458    const Case = struct {
1459        name: []const u8,
1460        path: []const u8,
1461        expected: ?[]const u8,
1462    };
1463
1464    const cases = [_]Case{
1465        .{
1466            .name = "plain path",
1467            .path = "/private/tmp",
1468            .expected = "file://myhost/private/tmp",
1469        },
1470        .{
1471            .name = "space is encoded",
1472            .path = "/tmp/zmx spaced dir",
1473            .expected = "file://myhost/tmp/zmx%20spaced%20dir",
1474        },
1475        .{
1476            .name = "percent is encoded so it round-trips",
1477            .path = "/tmp/100%",
1478            .expected = "file://myhost/tmp/100%25",
1479        },
1480        .{
1481            .name = "unreserved characters are left alone",
1482            .path = "/tmp/a-b_c.d~e",
1483            .expected = "file://myhost/tmp/a-b_c.d~e",
1484        },
1485    };
1486
1487    for (cases) |c| {
1488        var buf: [std.fs.max_path_bytes]u8 = undefined;
1489        testing.expectEqualDeep(c.expected, toOsc7Cwd(&buf, c.path, "myhost")) catch |err| {
1490            std.debug.print("case: {s}\n", .{c.name});
1491            return err;
1492        };
1493    }
1494}
1495
1496test "toOsc7Cwd returns null when the result would not fit" {
1497    var buf: [8]u8 = undefined;
1498    try testing.expectEqual(
1499        @as(?[]const u8, null),
1500        toOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1501    );
1502}
1503
1504test "toOsc7Cwd round-trips through parseOsc7Cwd" {
1505    const paths = [_][]const u8{
1506        "/private/tmp",
1507        "/tmp/zmx spaced dir",
1508        "/tmp/100%",
1509        "/tmp/a-b_c.d~e",
1510        "/tmp/quote'and\"dquote",
1511    };
1512
1513    for (paths) |path| {
1514        var enc_buf: [std.fs.max_path_bytes]u8 = undefined;
1515        const uri = toOsc7Cwd(&enc_buf, path, "myhost") orelse
1516            return error.TestUnexpectedNull;
1517
1518        var dec_buf: [std.fs.max_path_bytes]u8 = undefined;
1519        testing.expectEqualDeep(
1520            Cwd{ .path = path, .is_local = true },
1521            parseOsc7Cwd(&dec_buf, uri, "myhost"),
1522        ) catch |err| {
1523            std.debug.print("path: {s} uri: {s}\n", .{ path, uri });
1524            return err;
1525        };
1526    }
1527}
1528
1529test "serializeTerminalState excludes synchronized output replay" {
1530    const alloc = testing.allocator;
1531    const io = testing.io;
1532
1533    var term = try ghostty_vt.Terminal.init(io, alloc, .{
1534        .cols = 80,
1535        .rows = 24,
1536    });
1537    defer term.deinit(alloc);
1538
1539    var stream = term.vtStream();
1540    defer stream.deinit();
1541
1542    stream.nextSlice("\x1b[?2004h"); // Bracketed paste
1543    stream.nextSlice("\x1b[?2026h"); // Synchronized output
1544    stream.nextSlice("hello");
1545
1546    try testing.expect(term.modes.get(.bracketed_paste));
1547    try testing.expect(term.modes.get(.synchronized_output));
1548
1549    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1550    defer alloc.free(output);
1551
1552    // The serialized output should contain bracketed paste (DECSET 2004)
1553    // but NOT synchronized output (DECSET 2026)
1554    try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
1555    try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
1556}
1557
1558test "serializeTerminalState replays the title" {
1559    const alloc = testing.allocator;
1560    const io = testing.io;
1561
1562    var term = try ghostty_vt.Terminal.init(io, alloc, .{
1563        .cols = 80,
1564        .rows = 24,
1565    });
1566    defer term.deinit(alloc);
1567
1568    var stream = term.vtStream();
1569    defer stream.deinit();
1570
1571    stream.nextSlice("\x1b]2;my title\x07");
1572    stream.nextSlice("hello");
1573
1574    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1575    defer alloc.free(output);
1576
1577    try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;my title\x07") != null);
1578}
1579
1580test "serializeTerminalState omits the title when none is set" {
1581    const alloc = testing.allocator;
1582    const io = testing.io;
1583
1584    var term = try ghostty_vt.Terminal.init(io, alloc, .{
1585        .cols = 80,
1586        .rows = 24,
1587    });
1588    defer term.deinit(alloc);
1589
1590    var stream = term.vtStream();
1591    defer stream.deinit();
1592
1593    stream.nextSlice("hello");
1594
1595    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1596    defer alloc.free(output);
1597
1598    try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
1599}
1600
1601test "serializeTerminalState replays the pwd without a NUL sentinel" {
1602    const alloc = testing.allocator;
1603    const io = testing.io;
1604
1605    var term = try ghostty_vt.Terminal.init(io, alloc, .{
1606        .cols = 80,
1607        .rows = 24,
1608    });
1609    defer term.deinit(alloc);
1610
1611    var stream = term.vtStream();
1612    defer stream.deinit();
1613
1614    stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
1615    stream.nextSlice("hello");
1616
1617    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1618    defer alloc.free(output);
1619
1620    try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1621    try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1622}
1623
1624test "serializeTerminalState omits the pwd when none is set" {
1625    const alloc = testing.allocator;
1626    const io = testing.io;
1627
1628    var term = try ghostty_vt.Terminal.init(io, alloc, .{
1629        .cols = 80,
1630        .rows = 24,
1631    });
1632    defer term.deinit(alloc);
1633
1634    var stream = term.vtStream();
1635    defer stream.deinit();
1636
1637    stream.nextSlice("hello");
1638
1639    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1640    defer alloc.free(output);
1641
1642    try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;") == null);
1643}
1644
1645test "serializeTerminal vt replays the pwd without a NUL sentinel" {
1646    const alloc = testing.allocator;
1647    const io = testing.io;
1648
1649    var term = try ghostty_vt.Terminal.init(io, alloc, .{
1650        .cols = 80,
1651        .rows = 24,
1652    });
1653    defer term.deinit(alloc);
1654
1655    var stream = term.vtStream();
1656    defer stream.deinit();
1657
1658    stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
1659    stream.nextSlice("hello");
1660
1661    const output = serializeTerminal(alloc, &term, .vt) orelse return error.TestUnexpectedNull;
1662    defer alloc.free(output);
1663
1664    try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1665    try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1666}
1667
1668fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
1669    var term = try ghostty_vt.Terminal.init(io, alloc, .{
1670        .cols = cols,
1671        .rows = rows,
1672        .max_scrollback_lines = 2_000,
1673    });
1674    if (vt_data.len > 0) {
1675        var stream = term.vtStream();
1676        defer stream.deinit();
1677        stream.nextSlice(vt_data);
1678    }
1679    return term;
1680}
1681
1682fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal, actual: *ghostty_vt.Terminal) !void {
1683    const exp_str = try expected.plainString(alloc);
1684    defer alloc.free(exp_str);
1685    const act_str = try actual.plainString(alloc);
1686    defer alloc.free(act_str);
1687    try testing.expectEqualStrings(exp_str, act_str);
1688}
1689
1690fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
1691    const cursor = &term.screens.active.cursor;
1692    try testing.expectEqual(col, cursor.x);
1693    try testing.expectEqual(row, cursor.y);
1694}
1695
1696fn serializeRoundtrip(alloc: std.mem.Allocator, io: std.Io, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
1697    const serialized = serializeTerminalState(alloc, source) orelse
1698        return error.SerializationFailed;
1699    defer alloc.free(serialized);
1700
1701    var dest = try ghostty_vt.Terminal.init(io, alloc, .{
1702        .cols = source.screens.active.pages.cols,
1703        .rows = source.screens.active.pages.rows,
1704        .max_scrollback_lines = 2_000,
1705    });
1706    var stream = dest.vtStream();
1707    defer stream.deinit();
1708    stream.nextSlice(serialized);
1709    return dest;
1710}
1711
1712fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marker: []const u8, expected_row: usize) !void {
1713    const plain = try term.plainString(alloc);
1714    defer alloc.free(plain);
1715    var row: usize = 0;
1716    var iter = std.mem.splitScalar(u8, plain, '\n');
1717    while (iter.next()) |line| {
1718        if (std.mem.indexOf(u8, line, marker) != null) {
1719            try testing.expectEqual(expected_row, row);
1720            return;
1721        }
1722        row += 1;
1723    }
1724    std.debug.print("marker '{s}' not found in terminal output\n", .{marker});
1725    return error.TestExpectedEqual;
1726}
1727
1728test "serializeTerminalState roundtrip preserves cursor position" {
1729    const alloc = testing.allocator;
1730    const io = testing.io;
1731
1732    var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++ // clear
1733        "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
1734    );
1735    defer term.deinit(alloc);
1736
1737    try expectCursorAt(&term, 9, 19); // 0-indexed
1738
1739    var client = try serializeRoundtrip(alloc, io, &term);
1740    defer client.deinit(alloc);
1741
1742    try expectCursorAt(&client, 9, 19);
1743}
1744
1745test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
1746    const alloc = testing.allocator;
1747    const io = testing.io;
1748
1749    var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++
1750        "\x1b[2;5HMARK_A" ++
1751        "\x1b[6;15HMARK_B" ++
1752        "\x1b[10;30HMARK_C" ++
1753        "\x1b[14;50HMARK_D" ++
1754        "\x1b[16;20H");
1755    defer term.deinit(alloc);
1756
1757    var client = try serializeRoundtrip(alloc, io, &term);
1758    defer client.deinit(alloc);
1759
1760    try expectScreensMatch(alloc, &term, &client);
1761    try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1762    try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1763    try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1764    try expectMarkerAtRow(alloc, &client, "MARK_D", 13);
1765    try expectCursorAt(&client, 15, 19);
1766}
1767
1768test "serializeTerminalState with scrollback preserves visible content" {
1769    const alloc = testing.allocator;
1770    const io = testing.io;
1771
1772    var term = try testCreateTerminal(alloc, io, 80, 24, "");
1773    defer term.deinit(alloc);
1774
1775    var stream = term.vtStream();
1776    defer stream.deinit();
1777
1778    // Generate 80 lines of scrollback (more than 24 visible rows)
1779    var buf: [32]u8 = undefined;
1780    for (0..80) |i| {
1781        const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1782        stream.nextSlice(line);
1783    }
1784
1785    // Clear screen and place markers at specific positions
1786    stream.nextSlice("\x1b[2J" ++
1787        "\x1b[2;5HMARK_A" ++
1788        "\x1b[6;15HMARK_B" ++
1789        "\x1b[10;30HMARK_C" ++
1790        "\x1b[16;20H");
1791
1792    // Verify source terminal has scrollback
1793    const pages = &term.screens.active.pages;
1794    const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
1795    try testing.expect(has_scrollback);
1796
1797    // Roundtrip: serialize → feed into fresh terminal
1798    var client = try serializeRoundtrip(alloc, io, &term);
1799    defer client.deinit(alloc);
1800
1801    // Visible content must match (this is the core cursor corruption test)
1802    try expectScreensMatch(alloc, &term, &client);
1803    try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1804    try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1805    try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1806    try expectCursorAt(&client, 15, 19);
1807}
1808
1809test "serializeTerminalState nested roundtrip preserves content" {
1810    // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
1811    // This is the exact nested session scenario (zmx → SSH → zmx).
1812    const alloc = testing.allocator;
1813    const io = testing.io;
1814
1815    // "Inner" terminal with scrollback + markers
1816    var inner = try testCreateTerminal(alloc, io, 80, 24, "");
1817    defer inner.deinit(alloc);
1818
1819    {
1820        var inner_stream = inner.vtStream();
1821        defer inner_stream.deinit();
1822        var buf: [32]u8 = undefined;
1823        for (0..60) |i| {
1824            const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1825            inner_stream.nextSlice(line);
1826        }
1827        inner_stream.nextSlice("\x1b[2J" ++
1828            "\x1b[3;10HINNER_A" ++
1829            "\x1b[12;25HINNER_B" ++
1830            "\x1b[20;5H");
1831    }
1832
1833    // Record inner's ground truth
1834    const inner_cursor_x = inner.screens.active.cursor.x;
1835    const inner_cursor_y = inner.screens.active.cursor.y;
1836
1837    // Serialize inner (simulates inner daemon re-attach to inner client)
1838    const inner_serialized = serializeTerminalState(alloc, &inner) orelse
1839        return error.SerializationFailed;
1840    defer alloc.free(inner_serialized);
1841
1842    // "Outer" terminal processes inner's serialized output
1843    var outer = try testCreateTerminal(alloc, io, 80, 24, "");
1844    defer outer.deinit(alloc);
1845
1846    {
1847        var outer_stream = outer.vtStream();
1848        defer outer_stream.deinit();
1849        outer_stream.nextSlice(inner_serialized);
1850    }
1851
1852    // Serialize outer (simulates outer daemon re-attach after detach)
1853    var client = try serializeRoundtrip(alloc, io, &outer);
1854    defer client.deinit(alloc);
1855
1856    // Client must see the same content as inner's visible screen
1857    try expectScreensMatch(alloc, &inner, &client);
1858    try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
1859    try expectMarkerAtRow(alloc, &client, "INNER_A", 2);
1860    try expectMarkerAtRow(alloc, &client, "INNER_B", 11);
1861}
1862
1863test "serializeTerminalState alternate screen not leaked" {
1864    const alloc = testing.allocator;
1865    const io = testing.io;
1866
1867    var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[?1049h" ++ // enter alt screen
1868        "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
1869        "\x1b[?1049l" ++ // exit alt screen
1870        "\x1b[2J\x1b[2;5HMAIN_MARK\x1b[8;20H" // write on main screen
1871    );
1872    defer term.deinit(alloc);
1873
1874    var client = try serializeRoundtrip(alloc, io, &term);
1875    defer client.deinit(alloc);
1876
1877    try expectScreensMatch(alloc, &term, &client);
1878
1879    const plain = try client.plainString(alloc);
1880    defer alloc.free(plain);
1881    try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
1882    try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
1883}
1884
1885test "serializeTerminalState size mismatch roundtrip" {
1886    const alloc = testing.allocator;
1887    const io = testing.io;
1888
1889    var term = try testCreateTerminal(alloc, io, 80, 30, "\x1b[2J" ++
1890        "\x1b[3;10HSIZE_A" ++
1891        "\x1b[12;20HSIZE_B" ++
1892        "\x1b[20;40HSIZE_C" ++
1893        "\x1b[15;15H");
1894    defer term.deinit(alloc);
1895
1896    // Resize to 24 rows (simulates outer terminal being smaller)
1897    try term.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
1898
1899    var client = try serializeRoundtrip(alloc, io, &term);
1900    defer client.deinit(alloc);
1901
1902    try expectScreensMatch(alloc, &term, &client);
1903    try expectCursorAt(&client, term.screens.active.cursor.y, term.screens.active.cursor.x);
1904}
1905
1906test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
1907    const alloc = testing.allocator;
1908    const io = testing.io;
1909
1910    var inner = try testCreateTerminal(alloc, io, 80, 30, "");
1911    defer inner.deinit(alloc);
1912
1913    {
1914        var inner_stream = inner.vtStream();
1915        defer inner_stream.deinit();
1916        var buf: [32]u8 = undefined;
1917        for (0..80) |i| {
1918            const line = std.fmt.bufPrint(&buf, "LINE_{d}\r\n", .{i}) catch unreachable;
1919            inner_stream.nextSlice(line);
1920        }
1921        inner_stream.nextSlice("\x1b[2J" ++
1922            "\x1b[3;10HSTRESS_A" ++
1923            "\x1b[12;25HSTRESS_B" ++
1924            "\x1b[16;20H");
1925    }
1926
1927    // Resize inner to 24 rows (outer terminal is smaller)
1928    try inner.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
1929
1930    const inner_cursor_x = inner.screens.active.cursor.x;
1931    const inner_cursor_y = inner.screens.active.cursor.y;
1932
1933    // Inner serialize → outer processes → outer serialize → client
1934    const inner_ser = serializeTerminalState(alloc, &inner) orelse
1935        return error.SerializationFailed;
1936    defer alloc.free(inner_ser);
1937
1938    var outer = try testCreateTerminal(alloc, io, 80, 24, "");
1939    defer outer.deinit(alloc);
1940    {
1941        var outer_stream = outer.vtStream();
1942        defer outer_stream.deinit();
1943        outer_stream.nextSlice(inner_ser);
1944    }
1945
1946    var client = try serializeRoundtrip(alloc, io, &outer);
1947    defer client.deinit(alloc);
1948
1949    try expectScreensMatch(alloc, &inner, &client);
1950    try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
1951}
1952
1953test "isUserInput: printable characters" {
1954    // Regular text should be detected as user input
1955    try testing.expect(isUserInput("hello"));
1956    try testing.expect(isUserInput("Hello World!"));
1957    try testing.expect(isUserInput("12345"));
1958    try testing.expect(isUserInput("!@#$%^&*()"));
1959}
1960
1961test "isUserInput: whitespace characters" {
1962    // Space character is printable
1963    try testing.expect(isUserInput(" "));
1964    try testing.expect(isUserInput("   "));
1965}
1966
1967test "isUserInput: line feed (LF)" {
1968    // LF triggers .execute action
1969    try testing.expect(isUserInput("\n"));
1970    try testing.expect(isUserInput("test\n"));
1971}
1972
1973test "isUserInput: carriage return (CR)" {
1974    // CR triggers .execute action
1975    try testing.expect(isUserInput("\r"));
1976    try testing.expect(isUserInput("test\r"));
1977}
1978
1979test "isUserInput: tab" {
1980    // Tab triggers .execute action
1981    try testing.expect(isUserInput("\t"));
1982    try testing.expect(isUserInput("col1\tcol2"));
1983}
1984
1985test "isUserInput: backspace" {
1986    // Backspace triggers .execute action
1987    try testing.expect(isUserInput("\x08"));
1988    try testing.expect(isUserInput("test\x08"));
1989}
1990
1991test "isUserInput: arrow keys (CSI ~)" {
1992    // Arrow keys use CSI with ~ - these have params
1993    try testing.expect(isUserInput("\x1b[3~")); // delete
1994    try testing.expect(isUserInput("\x1b[5~")); // page up
1995    try testing.expect(isUserInput("\x1b[6~")); // page down
1996}
1997
1998test "isUserInput: modified arrow keys with CSI u" {
1999    // Modified arrow keys with CSI ... u
2000    try testing.expect(isUserInput("\x1bOA")); // up with modifier
2001    try testing.expect(isUserInput("\x1bOB")); // down with modifier
2002    try testing.expect(isUserInput("\x1bOC")); // right with modifier
2003    try testing.expect(isUserInput("\x1bOD")); // left with modifier
2004}
2005
2006test "isUserInput: up arrow legacy" {
2007    // Legacy up arrow: CSI A (with params for kitty-style)
2008    try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
2009}
2010
2011test "isUserInput: up arrow kitty" {
2012    // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
2013    try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
2014}
2015
2016test "isUserInput: arrow keys with modifier params CSI A-D" {
2017    // Modified arrow keys like Ctrl+Up: CSI 1;5A
2018    try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
2019    try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
2020    try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
2021    try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
2022    try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
2023    try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
2024}
2025
2026test "isUserInput: function keys with modifiers CSI 27 ; ~" {
2027    // Legacy modified keys: CSI 27 ; ... ~
2028    try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
2029    try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
2030    try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
2031}
2032
2033test "isUserInput: enter key" {
2034    // Enter is LF (0x0A)
2035    try testing.expect(isUserInput("\x0A"));
2036}
2037
2038test "isUserInput: mixed content" {
2039    // Mix of printable and control sequences
2040    try testing.expect(isUserInput("hello\nworld"));
2041    try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
2042    try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
2043}
2044
2045test "isUserInput: non-user input (escape sequences only)" {
2046    // Cursor movement without user input
2047    try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
2048    // SGR color set (no printing)
2049    try testing.expect(!isUserInput("\x1b[0m"));
2050    // Cursor position report query
2051    try testing.expect(!isUserInput("\x1b[6n"));
2052}
2053
2054test "isUserInput: empty string" {
2055    try testing.expect(!isUserInput(""));
2056}
2057
2058test "isUserInput: only whitespace controls" {
2059    // Multiple control chars should return true
2060    try testing.expect(isUserInput("\n\r\t"));
2061}
2062
2063test "isUserInput: kitty keyboard sequences" {
2064    // Kitty keyboard protocol uses CSI u
2065    try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
2066    try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
2067    try testing.expect(isUserInput("\x1b[102;1:1u")); // literal "f" press
2068    try testing.expect(isUserInput("\x1b[57444;1:1u")); // Kitty functional key press
2069    try testing.expect(!isUserInput("\x1b[102;1:3u")); // literal "f" release only
2070    try testing.expect(isUserInput("\x1b[102;1:1u\x1b[67;65;31M")); // key press with mouse noise
2071}
2072
2073test "isUserInput: mouse events (CSI M) excluded" {
2074    // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
2075    // Mouse events should NOT trigger leader switch
2076    try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
2077    try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
2078}
2079
2080test "isUserInput: mouse events SGR mode CSI < excluded" {
2081    // SGR extended mouse tracking: CSI < Cb;Cx;Y M
2082    // Mouse events should NOT trigger leader switch
2083    try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
2084    try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
2085}
2086
2087test "isUserInput: focus events excluded" {
2088    // Focus in/out are automatic terminal events, not user typing
2089    try testing.expect(!isUserInput("\x1b[I")); // focus in
2090    try testing.expect(!isUserInput("\x1b[O")); // focus out
2091}
2092
2093test "isUserInput: bracketed paste included" {
2094    // Bracketed paste start/end are user-initiated paste operations
2095    try testing.expect(isUserInput("\x1b[200~")); // paste start
2096    try testing.expect(isUserInput("\x1b[201~")); // paste end
2097    // Content between start/end is also user input
2098    try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
2099}
2100
2101test "stripAnsi: plain text passes through" {
2102    const alloc = testing.allocator;
2103    const result = try stripAnsi(alloc, "hello world\n");
2104    defer alloc.free(result);
2105    try testing.expectEqualStrings("hello world\n", result);
2106}
2107
2108test "stripAnsi: removes SGR color codes" {
2109    const alloc = testing.allocator;
2110    // \e[31m = red, \e[0m = reset
2111    const result = try stripAnsi(alloc, "\x1b[31mred\x1b[0m");
2112    defer alloc.free(result);
2113    try testing.expectEqualStrings("red", result);
2114}
2115
2116test "stripAnsi: removes cursor movement" {
2117    const alloc = testing.allocator;
2118    // \e[2J = clear screen, \e[H = home cursor
2119    const result = try stripAnsi(alloc, "\x1b[2J\x1b[Hhello");
2120    defer alloc.free(result);
2121    try testing.expectEqualStrings("hello", result);
2122}
2123
2124test "stripAnsi: preserves newlines and tabs" {
2125    const alloc = testing.allocator;
2126    const result = try stripAnsi(alloc, "line1\nline2\ttab\r");
2127    defer alloc.free(result);
2128    try testing.expectEqualStrings("line1\nline2\ttab\r", result);
2129}
2130
2131test "stripAnsi: removes OSC sequences" {
2132    const alloc = testing.allocator;
2133    // OSC 0;title BEL = set window title
2134    const result = try stripAnsi(alloc, "\x1b]0;My Title\x07hello");
2135    defer alloc.free(result);
2136    try testing.expectEqualStrings("hello", result);
2137}
2138
2139test "stripAnsi: removes DA query and response" {
2140    const alloc = testing.allocator;
2141    // DA1 query: \e[c, DA1 response: \e[?62;22c
2142    const result = try stripAnsi(alloc, "\x1b[c\x1b[?62;22chello");
2143    defer alloc.free(result);
2144    try testing.expectEqualStrings("hello", result);
2145}
2146
2147test "stripAnsi: complex mixed content" {
2148    const alloc = testing.allocator;
2149    // Shell prompt with colors + command echo + output
2150    const input = "\x1b[0;32m[user@host ~]$\x1b[0m git log\n" ++
2151        "abc1234 commit message\n" ++
2152        "\x1b[0;32m[user@host ~]$\x1b[0m";
2153    const result = try stripAnsi(alloc, input);
2154    defer alloc.free(result);
2155    try testing.expectEqualStrings("[user@host ~]$ git log\nabc1234 commit message\n[user@host ~]$", result);
2156}
2157
2158test "stripAnsi: empty input" {
2159    const alloc = testing.allocator;
2160    const result = try stripAnsi(alloc, "");
2161    defer alloc.free(result);
2162    try testing.expectEqualStrings("", result);
2163}
2164
2165test "stripAnsi: only escape sequences" {
2166    const alloc = testing.allocator;
2167    const result = try stripAnsi(alloc, "\x1b[31m\x1b[1m\x1b[0m");
2168    defer alloc.free(result);
2169    try testing.expectEqualStrings("", result);
2170}