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