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}