main zmx / src / terminal.zig
Eric Bower  ·  2026-10-23
   1const std = @import("std");
   2const build_options = @import("build_options");
   3const util = @import("util.zig");
   4const testing = std.testing;
   5const has_daemon = if (@hasDecl(build_options, "has_daemon")) build_options.has_daemon else true;
   6pub const ghostty_vt = if (has_daemon) @import("ghostty-vt") else struct {
   7    pub const Terminal = struct {};
   8    pub const Parser = struct {};
   9    pub const Selection = struct {};
  10    pub const formatter = struct {};
  11    pub const color = struct {};
  12};
  13
  14pub const HistoryFormat = util.HistoryFormat;
  15
  16/// True when a CSI carries a private marker (`?`, `>`, `=`, `<`). Keyboard
  17/// encodings never use one, but the replies a terminal sends on its own do:
  18/// kitty keyboard flags (`CSI ? flags u`), DA1 (`CSI ? 62;22 c`), DA2
  19/// (`CSI > 1;10;0 c`), DECRPM (`CSI ? 2026;2 $ y`), color scheme (`CSI ? 997;1 n`).
  20fn hasPrivateMarker(csi: ghostty_vt.Parser.Action.CSI) bool {
  21    for (csi.intermediates) |b| {
  22        if (b == '?' or b == '>' or b == '=' or b == '<') return true;
  23    }
  24    return false;
  25}
  26
  27/// Detect if the payload contains user input that should be printed to the screen or
  28/// is a key combination like up-arrow, backspace, enter, ctrl+f, etc.
  29///
  30/// Terminal-generated replies must not count: every attached client's terminal
  31/// answers the queries the app broadcasts, so a reply from a passive viewer
  32/// would otherwise make it leader and resize the session to its window.
  33pub fn isUserInput(payload: []const u8) bool {
  34    var parser = ghostty_vt.Parser.init();
  35    var i: usize = 0;
  36    while (i < payload.len) {
  37        if (payload[i] == 0x1b and i + 2 < payload.len and payload[i + 1] == '[') {
  38            if (util.parseKittyCsiU(payload[i + 2 ..])) |kitty| {
  39                if (kitty.event_type != 3) return true;
  40                i += 2 + kitty.consumed;
  41                continue;
  42            }
  43        }
  44
  45        const actions = parser.next(payload[i]);
  46        for (actions) |action_opt| {
  47            const action = action_opt orelse continue;
  48            switch (action) {
  49                .print => return true, // printable characters
  50                .csi_dispatch => |csi| {
  51                    // A reply, not a key: skip it and keep scanning, since a
  52                    // real keystroke can arrive in the same read.
  53                    if (hasPrivateMarker(csi)) continue;
  54                    // kitty keyboard: CSI ... u or CSI ... ~
  55                    // legacy modified keys: CSI 27 ; ... ~
  56                    // arrow/function keys with modifiers: CSI 1 ; <mod> A-D
  57                    // CSI ? flags u is the terminal's reply to a kitty keyboard status
  58                    // query, not a key press; skip it and keep scanning the payload
  59                    const status_reply = csi.final == 'u' and csi.intermediates.len > 0 and csi.intermediates[0] == '?';
  60                    if (!status_reply and (csi.final == 'u' or csi.final == '~')) return true;
  61                    // modified arrow keys (e.g., Ctrl+F sends CSI 1;5C in legacy mode)
  62                    if (csi.final >= 'A' and csi.final <= 'D' and csi.params.len > 1) return true;
  63                    // mouse events: CSI M (basic) or CSI < (SGR extended) - EXCLUDE these
  64                    // only intentional keyboard input should trigger leader switch
  65                    if (csi.final == 'M' or csi.final == '<') return false;
  66                    // focus events: CSI I (focus in) or CSI O (focus out) - EXCLUDE these
  67                    // these are automatic terminal events, not user typing
  68                    if (csi.final == 'I' or csi.final == 'O') return false;
  69                },
  70                .execute => |code| {
  71                    // looking for CR, LF, tab, and backspace
  72                    if (code == 0x0D or code == 0x0A or code == 0x09 or code == 0x08) return true;
  73                },
  74                else => {},
  75            }
  76        }
  77        i += 1;
  78    }
  79    return false;
  80}
  81
  82/// Emit the terminal's pwd as OSC 7.
  83///
  84/// This replaces the formatter's own `extra.pwd`, which writes
  85/// `terminal.pwd.items` verbatim. `Terminal.setPwd` appends a NUL sentinel to
  86/// that buffer, so the formatter's OSC 7 carries a stray `\x00` before the
  87/// terminator. `getPwd()` returns the same bytes without the sentinel.
  88///
  89/// The NUL is not cosmetic: a client that records what it receives (kitty
  90/// writing its session file, for one) persists the NUL and then fails to parse
  91/// its own state back. See https://github.com/neurosnap/zmx/issues/222.
  92fn writePwd(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
  93    const pwd = term.getPwd() orelse return;
  94    if (pwd.len == 0) return;
  95    writer.print("\x1b]7;{s}\x1b\\", .{pwd}) catch |err| {
  96        std.log.warn("failed to format pwd err={s}", .{@errorName(err)});
  97    };
  98}
  99
 100fn writeDynamicColor(
 101    writer: *std.Io.Writer,
 102    kind: ghostty_vt.color.Dynamic,
 103    dynamic: ghostty_vt.color.DynamicRGB,
 104) void {
 105    const c = dynamic.override orelse return;
 106    writer.print("\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", .{
 107        @backingInt(kind), c.r, c.g, c.b,
 108    }) catch |err| {
 109        std.log.warn("failed to format dynamic color err={s}", .{@errorName(err)});
 110    };
 111}
 112
 113fn writeColorOverrides(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
 114    const colors = &term.colors;
 115    writeDynamicColor(writer, .foreground, colors.foreground);
 116    writeDynamicColor(writer, .background, colors.background);
 117    writeDynamicColor(writer, .cursor, colors.cursor);
 118}
 119
 120/// The VT formatter does not emit the cursor shape (DECSCUSR).
 121fn writeCursorStyle(writer: *std.Io.Writer, term: *ghostty_vt.Terminal) !void {
 122    if (term.cursor.is_default) return writer.writeAll("\x1b[0 q");
 123    const blink = term.modes.get(.cursor_blinking);
 124    const style: u8 = switch (term.screens.active.cursor.cursor_style) {
 125        .block, .block_hollow => if (blink) 1 else 2,
 126        .underline => if (blink) 3 else 4,
 127        .bar => if (blink) 5 else 6,
 128    };
 129    try writer.print("\x1b[{d} q", .{style});
 130}
 131
 132pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
 133    var builder: std.Io.Writer.Allocating = .init(alloc);
 134    defer builder.deinit();
 135
 136    // Synchronized output (DECSET 2026) is a transient rendering handshake
 137    // between a program and its current terminal client. Replaying it to a
 138    // newly attached client can leave that client deferring renders until its
 139    // local timeout fires, so temporarily exclude it from restored state and
 140    // restore the original mode before returning.
 141    const had_synchronized_output = term.modes.get(.synchronized_output);
 142    if (had_synchronized_output) {
 143        term.modes.set(.synchronized_output, false);
 144    }
 145    defer if (had_synchronized_output) term.modes.set(.synchronized_output, true);
 146
 147    // If state contains color override, restore it.
 148    writeColorOverrides(&builder.writer, term);
 149
 150    const pages = &term.screens.active.pages;
 151    const screen_top = pages.getTopLeft(.screen);
 152    const active_top = pages.getTopLeft(.active);
 153    const has_scrollback = !screen_top.eql(active_top);
 154
 155    // Two-phase serialization to preserve scrollback without corrupting
 156    // cursor positions. This matters for nested zmx sessions (zmx→SSH→zmx)
 157    // where the outer daemon's ghostty-vt accumulates inner session scrollback.
 158    //
 159    // Phase 1: Emit scrollback content (plain text with styles, no terminal extras).
 160    // These lines scroll past the visible area into the terminal's scrollback buffer.
 161    // Phase 2: Clear visible screen, then emit visible content with full extras.
 162    // The clear ensures visible content starts from a clean slate regardless of
 163    // how much scrollback preceded it. CUP cursor positioning is then correct.
 164    //
 165    // See: https://github.com/neurosnap/zmx/issues/31
 166
 167    // Phase 1: scrollback only (if any exists)
 168    if (has_scrollback) {
 169        if (active_top.up(1)) |sb_bottom_row| {
 170            var sb_bottom = sb_bottom_row;
 171            sb_bottom.x = @intCast(pages.cols - 1);
 172
 173            var scroll_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
 174            scroll_fmt.content = .{
 175                .selection = ghostty_vt.Selection.init(
 176                    screen_top,
 177                    sb_bottom,
 178                    false,
 179                ),
 180            };
 181            scroll_fmt.extra = .none; // no modes, cursor, keyboard — just content
 182            scroll_fmt.format(&builder.writer) catch |err| {
 183                std.log.warn("failed to format scrollback err={s}", .{@errorName(err)});
 184            };
 185        }
 186
 187        // The formatter leaves its final history rows in the viewport. Move
 188        // every one into scrollback before clearing that viewport, including
 189        // when the entire history is shorter than the terminal height.
 190        for (0..pages.rows) |_| builder.writer.writeAll("\r\n") catch |err| {
 191            std.log.warn("failed to preserve scrollback err={s}", .{@errorName(err)});
 192            return null;
 193        };
 194
 195        // Clear visible screen after scrollback. \x1b[2J clears only the visible
 196        // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets
 197        // SGR style so phase 1 styles don't bleed into phase 2.
 198        builder.writer.writeAll("\x1b[2J\x1b[H\x1b[0m") catch {};
 199    }
 200
 201    // Phase 2: visible screen with full extras (modes, cursor, keyboard, etc.)
 202    var vis_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
 203
 204    // Restrict content to the active viewport only
 205    const active_tl = pages.pin(.{ .active = .{ .x = 0, .y = 0 } });
 206    const active_br = pages.pin(.{
 207        .active = .{
 208            .x = @intCast(pages.cols - 1),
 209            .y = @intCast(pages.rows - 1),
 210        },
 211    });
 212
 213    if (active_tl != null and active_br != null) {
 214        vis_fmt.content = .{
 215            .selection = ghostty_vt.Selection.init(
 216                active_tl.?,
 217                active_br.?,
 218                false,
 219            ),
 220        };
 221    }
 222    // Fallback: if pins are somehow invalid, use null selection (all content)
 223
 224    vis_fmt.extra = .{
 225        .palette = false,
 226        .modes = true,
 227        .scrolling_region = true,
 228        .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
 229        .pwd = false, // emitted below without the sentinel the formatter includes
 230        .keyboard = true,
 231        .screen = .all,
 232    };
 233
 234    vis_fmt.format(&builder.writer) catch |err| {
 235        std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
 236        return null;
 237    };
 238
 239    writePwd(&builder.writer, term);
 240
 241    // The formatter has no title extra and never emits OSC 0/1/2, so the title
 242    // has to be replayed separately or an attaching client shows whatever its
 243    // terminal defaults to, usually the client process name. OSC 2 does not
 244    // move the cursor, so this is safe to append after the content.
 245    if (term.getTitle()) |title| {
 246        builder.writer.print("\x1b]2;{s}\x07", .{title}) catch |err| {
 247            std.log.warn("failed to format title err={s}", .{@errorName(err)});
 248        };
 249    }
 250
 251    writeCursorStyle(&builder.writer, term) catch |err| {
 252        std.log.warn("failed to restore cursor style err={s}", .{@errorName(err)});
 253    };
 254
 255    const output = builder.writer.buffered();
 256    if (output.len == 0) return null;
 257
 258    return alloc.dupe(u8, output) catch |err| {
 259        std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
 260        return null;
 261    };
 262}
 263
 264pub fn serializeTerminal(
 265    alloc: std.mem.Allocator,
 266    term: *ghostty_vt.Terminal,
 267    format: HistoryFormat,
 268) ?[]const u8 {
 269    return serializeTerminalRange(alloc, term, format, null);
 270}
 271
 272/// Formats the active screen and at most `scrollback_rows` preceding physical
 273/// rows. Null preserves whole-history formatting. Alternate screens have no
 274/// scrollback, so their selection always stays inside the current grid.
 275pub fn serializeTerminalRange(
 276    alloc: std.mem.Allocator,
 277    term: *ghostty_vt.Terminal,
 278    format: HistoryFormat,
 279    scrollback_rows: ?u32,
 280) ?[]const u8 {
 281    var builder: std.Io.Writer.Allocating = .init(alloc);
 282    defer builder.deinit();
 283
 284    const opts: ghostty_vt.formatter.Options = switch (format) {
 285        .plain => .plain,
 286        .vt => .vt,
 287        .html => .html,
 288    };
 289    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
 290    term_formatter.content = .{ .selection = null };
 291    if (scrollback_rows) |rows| {
 292        const pages = &term.screens.active.pages;
 293        const active_top = pages.getTopLeft(.active);
 294        const top = active_top.up(rows) orelse pages.getTopLeft(.screen);
 295        const bottom = pages.pin(.{ .active = .{
 296            .x = @intCast(pages.cols - 1),
 297            .y = @intCast(pages.rows - 1),
 298        } }) orelse return null;
 299        term_formatter.content = .{
 300            .selection = ghostty_vt.Selection.init(top, bottom, false),
 301        };
 302    }
 303    term_formatter.extra = switch (format) {
 304        .plain => .none,
 305        .vt => .{
 306            .palette = false,
 307            .modes = true,
 308            .scrolling_region = true,
 309            .tabstops = false,
 310            .pwd = false, // emitted below without the sentinel the formatter includes
 311            .keyboard = true,
 312            .screen = .all,
 313        },
 314        .html => .styles,
 315    };
 316
 317    term_formatter.format(&builder.writer) catch |err| {
 318        std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
 319        return null;
 320    };
 321
 322    if (format == .vt) writePwd(&builder.writer, term);
 323
 324    const output = builder.writer.buffered();
 325    if (output.len == 0) return null;
 326
 327    return alloc.dupe(u8, output) catch |err| {
 328        std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
 329        return null;
 330    };
 331}
 332
 333test "serializeTerminalState excludes synchronized output replay" {
 334    const alloc = testing.allocator;
 335    const io = testing.io;
 336
 337    var term = try ghostty_vt.Terminal.init(io, alloc, .{
 338        .cols = 80,
 339        .rows = 24,
 340    });
 341    defer term.deinit(alloc);
 342
 343    var stream = term.vtStream();
 344    defer stream.deinit();
 345
 346    stream.nextSlice("\x1b[?2004h"); // Bracketed paste
 347    stream.nextSlice("\x1b[?2026h"); // Synchronized output
 348    stream.nextSlice("hello");
 349
 350    try testing.expect(term.modes.get(.bracketed_paste));
 351    try testing.expect(term.modes.get(.synchronized_output));
 352
 353    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
 354    defer alloc.free(output);
 355
 356    // The serialized output should contain bracketed paste (DECSET 2004)
 357    // but NOT synchronized output (DECSET 2026)
 358    try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
 359    try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
 360}
 361
 362test "serializeTerminalState replays the title" {
 363    const alloc = testing.allocator;
 364    const io = testing.io;
 365
 366    var term = try ghostty_vt.Terminal.init(io, alloc, .{
 367        .cols = 80,
 368        .rows = 24,
 369    });
 370    defer term.deinit(alloc);
 371
 372    var stream = term.vtStream();
 373    defer stream.deinit();
 374
 375    stream.nextSlice("\x1b]2;my title\x07");
 376    stream.nextSlice("hello");
 377
 378    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
 379    defer alloc.free(output);
 380
 381    try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;my title\x07") != null);
 382}
 383
 384test "serializeTerminalState omits the title when none is set" {
 385    const alloc = testing.allocator;
 386    const io = testing.io;
 387
 388    var term = try ghostty_vt.Terminal.init(io, alloc, .{
 389        .cols = 80,
 390        .rows = 24,
 391    });
 392    defer term.deinit(alloc);
 393
 394    var stream = term.vtStream();
 395    defer stream.deinit();
 396
 397    stream.nextSlice("hello");
 398
 399    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
 400    defer alloc.free(output);
 401
 402    try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
 403}
 404
 405test "serializeTerminalState replays the pwd without a NUL sentinel" {
 406    const alloc = testing.allocator;
 407    const io = testing.io;
 408
 409    var term = try ghostty_vt.Terminal.init(io, alloc, .{
 410        .cols = 80,
 411        .rows = 24,
 412    });
 413    defer term.deinit(alloc);
 414
 415    var stream = term.vtStream();
 416    defer stream.deinit();
 417
 418    stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
 419    stream.nextSlice("hello");
 420
 421    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
 422    defer alloc.free(output);
 423
 424    try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
 425    try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
 426}
 427
 428test "serializeTerminalState omits the pwd when none is set" {
 429    const alloc = testing.allocator;
 430    const io = testing.io;
 431
 432    var term = try ghostty_vt.Terminal.init(io, alloc, .{
 433        .cols = 80,
 434        .rows = 24,
 435    });
 436    defer term.deinit(alloc);
 437
 438    var stream = term.vtStream();
 439    defer stream.deinit();
 440
 441    stream.nextSlice("hello");
 442
 443    const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
 444    defer alloc.free(output);
 445
 446    try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;") == null);
 447}
 448
 449test "serializeTerminal vt replays the pwd without a NUL sentinel" {
 450    const alloc = testing.allocator;
 451    const io = testing.io;
 452
 453    var term = try ghostty_vt.Terminal.init(io, alloc, .{
 454        .cols = 80,
 455        .rows = 24,
 456    });
 457    defer term.deinit(alloc);
 458
 459    var stream = term.vtStream();
 460    defer stream.deinit();
 461
 462    stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
 463    stream.nextSlice("hello");
 464
 465    const output = serializeTerminal(alloc, &term, .vt) orelse return error.TestUnexpectedNull;
 466    defer alloc.free(output);
 467
 468    try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
 469    try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
 470}
 471
 472test "serializeTerminalRange screen only returns the active screen" {
 473    const alloc = testing.allocator;
 474    var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
 475    defer term.deinit(alloc);
 476
 477    const output = serializeTerminalRange(alloc, &term, .plain, 0) orelse return error.TestUnexpectedNull;
 478    defer alloc.free(output);
 479    try testing.expectEqualStrings("3\n4\n5", output);
 480}
 481
 482test "serializeTerminalRange adds the requested scrollback rows" {
 483    const alloc = testing.allocator;
 484    var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5");
 485    defer term.deinit(alloc);
 486
 487    const output = serializeTerminalRange(alloc, &term, .plain, 1) orelse return error.TestUnexpectedNull;
 488    defer alloc.free(output);
 489    try testing.expectEqualStrings("2\n3\n4\n5", output);
 490
 491    const all = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
 492    defer alloc.free(all);
 493    const full = serializeTerminal(alloc, &term, .plain) orelse return error.TestUnexpectedNull;
 494    defer alloc.free(full);
 495    try testing.expectEqualStrings(full, all);
 496}
 497
 498test "serializeTerminalRange alternate screen returns only its grid" {
 499    const alloc = testing.allocator;
 500    var term = try testCreateTerminal(alloc, testing.io, 80, 3, "1\r\n2\r\n3\r\n4\r\n5\x1b[?1049h\x1b[Halt");
 501    defer term.deinit(alloc);
 502
 503    const output = serializeTerminalRange(alloc, &term, .plain, 100) orelse return error.TestUnexpectedNull;
 504    defer alloc.free(output);
 505    try testing.expectEqualStrings("alt", output);
 506}
 507
 508fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
 509    var term = try ghostty_vt.Terminal.init(io, alloc, .{
 510        .cols = cols,
 511        .rows = rows,
 512        .max_scrollback_lines = 2_000,
 513    });
 514    if (vt_data.len > 0) {
 515        var stream = term.vtStream();
 516        defer stream.deinit();
 517        stream.nextSlice(vt_data);
 518    }
 519    return term;
 520}
 521
 522fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal, actual: *ghostty_vt.Terminal) !void {
 523    const exp_str = try expected.plainString(alloc);
 524    defer alloc.free(exp_str);
 525    const act_str = try actual.plainString(alloc);
 526    defer alloc.free(act_str);
 527    try testing.expectEqualStrings(exp_str, act_str);
 528}
 529
 530fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
 531    const cursor = &term.screens.active.cursor;
 532    try testing.expectEqual(col, cursor.x);
 533    try testing.expectEqual(row, cursor.y);
 534}
 535
 536fn serializeRoundtrip(alloc: std.mem.Allocator, io: std.Io, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
 537    const serialized = serializeTerminalState(alloc, source) orelse
 538        return error.SerializationFailed;
 539    defer alloc.free(serialized);
 540
 541    var dest = try ghostty_vt.Terminal.init(io, alloc, .{
 542        .cols = source.screens.active.pages.cols,
 543        .rows = source.screens.active.pages.rows,
 544        .max_scrollback_lines = 2_000,
 545    });
 546    var stream = dest.vtStream();
 547    defer stream.deinit();
 548    stream.nextSlice(serialized);
 549    return dest;
 550}
 551
 552fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marker: []const u8, expected_row: usize) !void {
 553    const plain = try term.plainString(alloc);
 554    defer alloc.free(plain);
 555    var row: usize = 0;
 556    var iter = std.mem.splitScalar(u8, plain, '\n');
 557    while (iter.next()) |line| {
 558        if (std.mem.indexOf(u8, line, marker) != null) {
 559            try testing.expectEqual(expected_row, row);
 560            return;
 561        }
 562        row += 1;
 563    }
 564    std.debug.print("marker '{s}' not found in terminal output\n", .{marker});
 565    return error.TestExpectedEqual;
 566}
 567
 568test "serializeTerminalState roundtrip preserves cursor position" {
 569    const alloc = testing.allocator;
 570    const io = testing.io;
 571
 572    var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++ // clear
 573        "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
 574    );
 575    defer term.deinit(alloc);
 576
 577    try expectCursorAt(&term, 9, 19); // 0-indexed
 578
 579    var client = try serializeRoundtrip(alloc, io, &term);
 580    defer client.deinit(alloc);
 581
 582    try expectCursorAt(&client, 9, 19);
 583}
 584
 585test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
 586    const alloc = testing.allocator;
 587    const io = testing.io;
 588
 589    var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++
 590        "\x1b[2;5HMARK_A" ++
 591        "\x1b[6;15HMARK_B" ++
 592        "\x1b[10;30HMARK_C" ++
 593        "\x1b[14;50HMARK_D" ++
 594        "\x1b[16;20H");
 595    defer term.deinit(alloc);
 596
 597    var client = try serializeRoundtrip(alloc, io, &term);
 598    defer client.deinit(alloc);
 599
 600    try expectScreensMatch(alloc, &term, &client);
 601    try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
 602    try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
 603    try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
 604    try expectMarkerAtRow(alloc, &client, "MARK_D", 13);
 605    try expectCursorAt(&client, 15, 19);
 606}
 607
 608test "serializeTerminalState preserves all scrollback rows before clearing viewport" {
 609    const alloc = testing.allocator;
 610    const io = testing.io;
 611    for ([_]u16{ 1, 3, 24 }) |rows| {
 612        for ([_]usize{ 1, 2, 23, 24, 25, 80 }) |lines| {
 613            var term = try testCreateTerminal(alloc, io, 40, rows, "");
 614            defer term.deinit(alloc);
 615            {
 616                var stream = term.vtStream();
 617                defer stream.deinit();
 618                var buf: [32]u8 = undefined;
 619                for (0..lines) |index| {
 620                    stream.nextSlice(try std.fmt.bufPrint(&buf, "ROW_{d}\r\n", .{index}));
 621                }
 622                stream.nextSlice("\x1b[2J\x1b[Hvisible");
 623            }
 624            var restored = try serializeRoundtrip(alloc, io, &term);
 625            defer restored.deinit(alloc);
 626            const expected = try term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{ .x = 0, .y = 0 } });
 627            defer alloc.free(expected);
 628            const actual = try restored.screens.active.dumpStringAlloc(alloc, .{ .screen = .{ .x = 0, .y = 0 } });
 629            defer alloc.free(actual);
 630            try testing.expectEqualStrings(expected, actual);
 631            try expectCursorAt(&restored, term.screens.active.cursor.y, term.screens.active.cursor.x);
 632        }
 633    }
 634}
 635
 636test "serializeTerminalState allocation failure preserves synchronized output mode" {
 637    const alloc = testing.allocator;
 638    var term = try testCreateTerminal(alloc, testing.io, 40, 3, "one\r\ntwo\r\nthree\r\nfour\r\n");
 639    defer term.deinit(alloc);
 640    term.modes.set(.synchronized_output, true);
 641    var failing = testing.FailingAllocator.init(alloc, .{ .fail_index = 0 });
 642    try testing.expect(serializeTerminalState(failing.allocator(), &term) == null);
 643    try testing.expect(term.modes.get(.synchronized_output));
 644}
 645
 646test "serializeTerminalState with scrollback preserves visible content" {
 647    const alloc = testing.allocator;
 648    const io = testing.io;
 649
 650    var term = try testCreateTerminal(alloc, io, 80, 24, "");
 651    defer term.deinit(alloc);
 652
 653    var stream = term.vtStream();
 654    defer stream.deinit();
 655
 656    // Generate 80 lines of scrollback (more than 24 visible rows)
 657    var buf: [32]u8 = undefined;
 658    for (0..80) |i| {
 659        const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
 660        stream.nextSlice(line);
 661    }
 662
 663    // Clear screen and place markers at specific positions
 664    stream.nextSlice("\x1b[2J" ++
 665        "\x1b[2;5HMARK_A" ++
 666        "\x1b[6;15HMARK_B" ++
 667        "\x1b[10;30HMARK_C" ++
 668        "\x1b[16;20H");
 669
 670    // Verify source terminal has scrollback
 671    const pages = &term.screens.active.pages;
 672    const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
 673    try testing.expect(has_scrollback);
 674
 675    // Roundtrip: serialize → feed into fresh terminal
 676    var client = try serializeRoundtrip(alloc, io, &term);
 677    defer client.deinit(alloc);
 678
 679    // Visible content must match (this is the core cursor corruption test)
 680    try expectScreensMatch(alloc, &term, &client);
 681    try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
 682    try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
 683    try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
 684    try expectCursorAt(&client, 15, 19);
 685}
 686
 687test "serializeTerminalState nested roundtrip preserves content" {
 688    // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
 689    // This is the exact nested session scenario (zmx → SSH → zmx).
 690    const alloc = testing.allocator;
 691    const io = testing.io;
 692
 693    // "Inner" terminal with scrollback + markers
 694    var inner = try testCreateTerminal(alloc, io, 80, 24, "");
 695    defer inner.deinit(alloc);
 696
 697    {
 698        var inner_stream = inner.vtStream();
 699        defer inner_stream.deinit();
 700        var buf: [32]u8 = undefined;
 701        for (0..60) |i| {
 702            const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
 703            inner_stream.nextSlice(line);
 704        }
 705        inner_stream.nextSlice("\x1b[2J" ++
 706            "\x1b[3;10HINNER_A" ++
 707            "\x1b[12;25HINNER_B" ++
 708            "\x1b[20;5H");
 709    }
 710
 711    // Record inner's ground truth
 712    const inner_cursor_x = inner.screens.active.cursor.x;
 713    const inner_cursor_y = inner.screens.active.cursor.y;
 714
 715    // Serialize inner (simulates inner daemon re-attach to inner client)
 716    const inner_serialized = serializeTerminalState(alloc, &inner) orelse
 717        return error.SerializationFailed;
 718    defer alloc.free(inner_serialized);
 719
 720    // "Outer" terminal processes inner's serialized output
 721    var outer = try testCreateTerminal(alloc, io, 80, 24, "");
 722    defer outer.deinit(alloc);
 723
 724    {
 725        var outer_stream = outer.vtStream();
 726        defer outer_stream.deinit();
 727        outer_stream.nextSlice(inner_serialized);
 728    }
 729
 730    // Serialize outer (simulates outer daemon re-attach after detach)
 731    var client = try serializeRoundtrip(alloc, io, &outer);
 732    defer client.deinit(alloc);
 733
 734    // Client must see the same content as inner's visible screen
 735    try expectScreensMatch(alloc, &inner, &client);
 736    try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
 737    try expectMarkerAtRow(alloc, &client, "INNER_A", 2);
 738    try expectMarkerAtRow(alloc, &client, "INNER_B", 11);
 739}
 740
 741test "serializeTerminalState alternate screen not leaked" {
 742    const alloc = testing.allocator;
 743    const io = testing.io;
 744
 745    var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[?1049h" ++ // enter alt screen
 746        "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
 747        "\x1b[?1049l" ++ // exit alt screen
 748        "\x1b[2J\x1b[2;5HMAIN_MARK\x1b[8;20H" // write on main screen
 749    );
 750    defer term.deinit(alloc);
 751
 752    var client = try serializeRoundtrip(alloc, io, &term);
 753    defer client.deinit(alloc);
 754
 755    try expectScreensMatch(alloc, &term, &client);
 756
 757    const plain = try client.plainString(alloc);
 758    defer alloc.free(plain);
 759    try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
 760    try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
 761}
 762
 763test "serializeTerminalState size mismatch roundtrip" {
 764    const alloc = testing.allocator;
 765    const io = testing.io;
 766
 767    var term = try testCreateTerminal(alloc, io, 80, 30, "\x1b[2J" ++
 768        "\x1b[3;10HSIZE_A" ++
 769        "\x1b[12;20HSIZE_B" ++
 770        "\x1b[20;40HSIZE_C" ++
 771        "\x1b[15;15H");
 772    defer term.deinit(alloc);
 773
 774    // Resize to 24 rows (simulates outer terminal being smaller)
 775    try term.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
 776
 777    var client = try serializeRoundtrip(alloc, io, &term);
 778    defer client.deinit(alloc);
 779
 780    try expectScreensMatch(alloc, &term, &client);
 781    try expectCursorAt(&client, term.screens.active.cursor.y, term.screens.active.cursor.x);
 782}
 783
 784test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
 785    const alloc = testing.allocator;
 786    const io = testing.io;
 787
 788    var inner = try testCreateTerminal(alloc, io, 80, 30, "");
 789    defer inner.deinit(alloc);
 790
 791    {
 792        var inner_stream = inner.vtStream();
 793        defer inner_stream.deinit();
 794        var buf: [32]u8 = undefined;
 795        for (0..80) |i| {
 796            const line = std.fmt.bufPrint(&buf, "LINE_{d}\r\n", .{i}) catch unreachable;
 797            inner_stream.nextSlice(line);
 798        }
 799        inner_stream.nextSlice("\x1b[2J" ++
 800            "\x1b[3;10HSTRESS_A" ++
 801            "\x1b[12;25HSTRESS_B" ++
 802            "\x1b[16;20H");
 803    }
 804
 805    // Resize inner to 24 rows (outer terminal is smaller)
 806    try inner.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
 807
 808    const inner_cursor_x = inner.screens.active.cursor.x;
 809    const inner_cursor_y = inner.screens.active.cursor.y;
 810
 811    // Inner serialize → outer processes → outer serialize → client
 812    const inner_ser = serializeTerminalState(alloc, &inner) orelse
 813        return error.SerializationFailed;
 814    defer alloc.free(inner_ser);
 815
 816    var outer = try testCreateTerminal(alloc, io, 80, 24, "");
 817    defer outer.deinit(alloc);
 818    {
 819        var outer_stream = outer.vtStream();
 820        defer outer_stream.deinit();
 821        outer_stream.nextSlice(inner_ser);
 822    }
 823
 824    var client = try serializeRoundtrip(alloc, io, &outer);
 825    defer client.deinit(alloc);
 826
 827    try expectScreensMatch(alloc, &inner, &client);
 828    try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
 829}
 830
 831test "isUserInput: printable characters" {
 832    // Regular text should be detected as user input
 833    try testing.expect(isUserInput("hello"));
 834    try testing.expect(isUserInput("Hello World!"));
 835    try testing.expect(isUserInput("12345"));
 836    try testing.expect(isUserInput("!@#$%^&*()"));
 837}
 838
 839test "isUserInput: whitespace characters" {
 840    // Space character is printable
 841    try testing.expect(isUserInput(" "));
 842    try testing.expect(isUserInput("   "));
 843}
 844
 845test "isUserInput: line feed (LF)" {
 846    // LF triggers .execute action
 847    try testing.expect(isUserInput("\n"));
 848    try testing.expect(isUserInput("test\n"));
 849}
 850
 851test "isUserInput: carriage return (CR)" {
 852    // CR triggers .execute action
 853    try testing.expect(isUserInput("\r"));
 854    try testing.expect(isUserInput("test\r"));
 855}
 856
 857test "isUserInput: tab" {
 858    // Tab triggers .execute action
 859    try testing.expect(isUserInput("\t"));
 860    try testing.expect(isUserInput("col1\tcol2"));
 861}
 862
 863test "isUserInput: backspace" {
 864    // Backspace triggers .execute action
 865    try testing.expect(isUserInput("\x08"));
 866    try testing.expect(isUserInput("test\x08"));
 867}
 868
 869test "isUserInput: arrow keys (CSI ~)" {
 870    // Arrow keys use CSI with ~ - these have params
 871    try testing.expect(isUserInput("\x1b[3~")); // delete
 872    try testing.expect(isUserInput("\x1b[5~")); // page up
 873    try testing.expect(isUserInput("\x1b[6~")); // page down
 874}
 875
 876test "isUserInput: modified arrow keys with CSI u" {
 877    // Modified arrow keys with CSI ... u
 878    try testing.expect(isUserInput("\x1bOA")); // up with modifier
 879    try testing.expect(isUserInput("\x1bOB")); // down with modifier
 880    try testing.expect(isUserInput("\x1bOC")); // right with modifier
 881    try testing.expect(isUserInput("\x1bOD")); // left with modifier
 882}
 883
 884test "isUserInput: up arrow legacy" {
 885    // Legacy up arrow: CSI A (with params for kitty-style)
 886    try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
 887}
 888
 889test "isUserInput: up arrow kitty" {
 890    // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
 891    try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
 892}
 893
 894test "isUserInput: arrow keys with modifier params CSI A-D" {
 895    // Modified arrow keys like Ctrl+Up: CSI 1;5A
 896    try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
 897    try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
 898    try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
 899    try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
 900    try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
 901    try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
 902}
 903
 904test "isUserInput: function keys with modifiers CSI 27 ; ~" {
 905    // Legacy modified keys: CSI 27 ; ... ~
 906    try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
 907    try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
 908    try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
 909}
 910
 911test "isUserInput: enter key" {
 912    // Enter is LF (0x0A)
 913    try testing.expect(isUserInput("\x0A"));
 914}
 915
 916test "isUserInput: mixed content" {
 917    // Mix of printable and control sequences
 918    try testing.expect(isUserInput("hello\nworld"));
 919    try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
 920    try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
 921}
 922
 923test "isUserInput: non-user input (escape sequences only)" {
 924    // Cursor movement without user input
 925    try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
 926    // SGR color set (no printing)
 927    try testing.expect(!isUserInput("\x1b[0m"));
 928    // Cursor position report query
 929    try testing.expect(!isUserInput("\x1b[6n"));
 930}
 931
 932test "isUserInput: empty string" {
 933    try testing.expect(!isUserInput(""));
 934}
 935
 936test "isUserInput: only whitespace controls" {
 937    // Multiple control chars should return true
 938    try testing.expect(isUserInput("\n\r\t"));
 939}
 940
 941test "isUserInput: kitty keyboard sequences" {
 942    // Kitty keyboard protocol uses CSI u
 943    try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
 944    try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
 945    try testing.expect(isUserInput("\x1b[102;1:1u")); // literal "f" press
 946    try testing.expect(isUserInput("\x1b[57444;1:1u")); // Kitty functional key press
 947    try testing.expect(!isUserInput("\x1b[102;1:3u")); // literal "f" release only
 948    try testing.expect(isUserInput("\x1b[102;1:1u\x1b[67;65;31M")); // key press with mouse noise
 949}
 950
 951test "isUserInput: mouse events (CSI M) excluded" {
 952    // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
 953    // Mouse events should NOT trigger leader switch
 954    try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
 955    try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
 956}
 957
 958test "isUserInput: mouse events SGR mode CSI < excluded" {
 959    // SGR extended mouse tracking: CSI < Cb;Cx;Y M
 960    // Mouse events should NOT trigger leader switch
 961    try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
 962    try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
 963}
 964
 965test "isUserInput: focus events excluded" {
 966    // Focus in/out are automatic terminal events, not user typing
 967    try testing.expect(!isUserInput("\x1b[I")); // focus in
 968    try testing.expect(!isUserInput("\x1b[O")); // focus out
 969}
 970
 971test "isUserInput: terminal query replies excluded" {
 972    // Replies every attached terminal sends to queries the app broadcasts.
 973    try testing.expect(!isUserInput("\x1b[?5u")); // kitty keyboard flags
 974    try testing.expect(!isUserInput("\x1b[?0u"));
 975    try testing.expect(!isUserInput("\x1b[?62;22;52c")); // DA1
 976    try testing.expect(!isUserInput("\x1b[>1;10;0c")); // DA2
 977    try testing.expect(!isUserInput("\x1b[?2026;2$y")); // DECRPM
 978    try testing.expect(!isUserInput("\x1b[?997;1n")); // color scheme
 979    try testing.expect(!isUserInput("\x1b[12;40R")); // cursor position
 980    try testing.expect(!isUserInput("\x1b[6;32;14t")); // cell size
 981    // XTVERSION + kitty flags + DA1 in one read, as a passive Ghostty
 982    // client sent it in the wild (it made that viewer the leader).
 983    try testing.expect(!isUserInput("\x1bP>|ghostty 1.3.2-main+64f5cc175\x1b\\\x1b[?5u\x1b[?62;22;52c"));
 984}
 985
 986test "isUserInput: key after a reply in the same read still counts" {
 987    try testing.expect(isUserInput("\x1b[?5ux"));
 988    try testing.expect(isUserInput("\x1b[?62;22c\x1b[99;5u")); // reply then kitty ctrl+c
 989    try testing.expect(isUserInput("\x1b[?5u\x1b[3~")); // reply then delete
 990}
 991
 992test "isUserInput: bracketed paste included" {
 993    // Bracketed paste start/end are user-initiated paste operations
 994    try testing.expect(isUserInput("\x1b[200~")); // paste start
 995    try testing.expect(isUserInput("\x1b[201~")); // paste end
 996    // Content between start/end is also user input
 997    try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
 998}
 999
1000test "isUserInput: kitty keyboard status reply is a terminal response" {
1001    // CSI ? flags u answers the program's CSI ? u query; no key was pressed
1002    try testing.expect(!isUserInput("\x1b[?1u"));
1003    try testing.expect(!isUserInput("\x1b[?0u"));
1004    try testing.expect(!isUserInput("\x1b[?31u"));
1005    // a real key coalesced with the reply still counts
1006    try testing.expect(isUserInput("\x1b[?1ua"));
1007    try testing.expect(isUserInput("\x1b[?1u\r"));
1008    try testing.expect(isUserInput("\x1b[?1u\x1b[97;1u"));
1009    try testing.expect(!isUserInput("\x1b[?1u\x1b[97;1:3u"));
1010}