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