David van Munster
·
2026-08-13
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
749fn writeDynamicColor(
750 writer: *std.Io.Writer,
751 kind: ghostty_vt.color.Dynamic,
752 dynamic: ghostty_vt.color.DynamicRGB,
753) void {
754 const c = dynamic.override orelse return;
755 writer.print("\x1b]{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", .{
756 @intFromEnum(kind), c.r, c.g, c.b,
757 }) catch |err| {
758 std.log.warn("failed to format dynamic color err={s}", .{@errorName(err)});
759 };
760}
761
762fn writeColorOverrides(writer: *std.Io.Writer, term: *const ghostty_vt.Terminal) void {
763 const colors = &term.colors;
764 writeDynamicColor(writer, .foreground, colors.foreground);
765 writeDynamicColor(writer, .background, colors.background);
766 writeDynamicColor(writer, .cursor, colors.cursor);
767}
768
769pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
770 var builder: std.Io.Writer.Allocating = .init(alloc);
771 defer builder.deinit();
772
773 // Synchronized output (DECSET 2026) is a transient rendering handshake
774 // between a program and its current terminal client. Replaying it to a
775 // newly attached client can leave that client deferring renders until its
776 // local timeout fires, so temporarily exclude it from restored state and
777 // restore the original mode before returning.
778 const had_synchronized_output = term.modes.get(.synchronized_output);
779 if (had_synchronized_output) {
780 term.modes.set(.synchronized_output, false);
781 }
782
783 // If state contains color override, restore it.
784 writeColorOverrides(&builder.writer, term);
785
786 const pages = &term.screens.active.pages;
787 const screen_top = pages.getTopLeft(.screen);
788 const active_top = pages.getTopLeft(.active);
789 const has_scrollback = !screen_top.eql(active_top);
790
791 // Two-phase serialization to preserve scrollback without corrupting
792 // cursor positions. This matters for nested zmx sessions (zmx→SSH→zmx)
793 // where the outer daemon's ghostty-vt accumulates inner session scrollback.
794 //
795 // Phase 1: Emit scrollback content (plain text with styles, no terminal extras).
796 // These lines scroll past the visible area into the terminal's scrollback buffer.
797 // Phase 2: Clear visible screen, then emit visible content with full extras.
798 // The clear ensures visible content starts from a clean slate regardless of
799 // how much scrollback preceded it. CUP cursor positioning is then correct.
800 //
801 // See: https://github.com/neurosnap/zmx/issues/31
802
803 // Phase 1: scrollback only (if any exists)
804 if (has_scrollback) {
805 if (active_top.up(1)) |sb_bottom_row| {
806 var sb_bottom = sb_bottom_row;
807 sb_bottom.x = @intCast(pages.cols - 1);
808
809 var scroll_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
810 scroll_fmt.content = .{
811 .selection = ghostty_vt.Selection.init(
812 screen_top,
813 sb_bottom,
814 false,
815 ),
816 };
817 scroll_fmt.extra = .none; // no modes, cursor, keyboard — just content
818 scroll_fmt.format(&builder.writer) catch |err| {
819 std.log.warn("failed to format scrollback err={s}", .{@errorName(err)});
820 };
821 }
822
823 // Clear visible screen after scrollback. \x1b[2J clears only the visible
824 // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets
825 // SGR style so phase 1 styles don't bleed into phase 2.
826 builder.writer.writeAll("\x1b[2J\x1b[H\x1b[0m") catch {};
827 }
828
829 // Phase 2: visible screen with full extras (modes, cursor, keyboard, etc.)
830 var vis_fmt = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
831
832 // Restrict content to the active viewport only
833 const active_tl = pages.pin(.{ .active = .{ .x = 0, .y = 0 } });
834 const active_br = pages.pin(.{
835 .active = .{
836 .x = @intCast(pages.cols - 1),
837 .y = @intCast(pages.rows - 1),
838 },
839 });
840
841 if (active_tl != null and active_br != null) {
842 vis_fmt.content = .{
843 .selection = ghostty_vt.Selection.init(
844 active_tl.?,
845 active_br.?,
846 false,
847 ),
848 };
849 }
850 // Fallback: if pins are somehow invalid, use null selection (all content)
851
852 vis_fmt.extra = .{
853 .palette = false,
854 .modes = true,
855 .scrolling_region = true,
856 .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
857 .pwd = false, // emitted below without the sentinel the formatter includes
858 .keyboard = true,
859 .screen = .all,
860 };
861
862 vis_fmt.format(&builder.writer) catch |err| {
863 std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
864 return null;
865 };
866
867 writePwd(&builder.writer, term);
868
869 // The formatter has no title extra and never emits OSC 0/1/2, so the title
870 // has to be replayed separately or an attaching client shows whatever its
871 // terminal defaults to, usually the client process name. OSC 2 does not
872 // move the cursor, so this is safe to append after the content.
873 if (term.getTitle()) |title| {
874 builder.writer.print("\x1b]2;{s}\x07", .{title}) catch |err| {
875 std.log.warn("failed to format title err={s}", .{@errorName(err)});
876 };
877 }
878
879 const output = builder.writer.buffered();
880 if (output.len == 0) return null;
881
882 // Restore the original synchronized_output mode before returning
883 if (had_synchronized_output) {
884 term.modes.set(.synchronized_output, true);
885 }
886
887 return alloc.dupe(u8, output) catch |err| {
888 std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
889 return null;
890 };
891}
892
893pub const HistoryFormat = enum(u8) {
894 plain = 0,
895 vt = 1,
896 html = 2,
897};
898
899pub fn serializeTerminal(
900 alloc: std.mem.Allocator,
901 term: *ghostty_vt.Terminal,
902 format: HistoryFormat,
903) ?[]const u8 {
904 var builder: std.Io.Writer.Allocating = .init(alloc);
905 defer builder.deinit();
906
907 const opts: ghostty_vt.formatter.Options = switch (format) {
908 .plain => .plain,
909 .vt => .vt,
910 .html => .html,
911 };
912 var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
913 term_formatter.content = .{ .selection = null };
914 term_formatter.extra = switch (format) {
915 .plain => .none,
916 .vt => .{
917 .palette = false,
918 .modes = true,
919 .scrolling_region = true,
920 .tabstops = false,
921 .pwd = false, // emitted below without the sentinel the formatter includes
922 .keyboard = true,
923 .screen = .all,
924 },
925 .html => .styles,
926 };
927
928 term_formatter.format(&builder.writer) catch |err| {
929 std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
930 return null;
931 };
932
933 if (format == .vt) writePwd(&builder.writer, term);
934
935 const output = builder.writer.buffered();
936 if (output.len == 0) return null;
937
938 return alloc.dupe(u8, output) catch |err| {
939 std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
940 return null;
941 };
942}
943
944/// Formats a session entry for list output (only the name when `short` is
945/// true), adding a prefix to indicate the current session, if there is one.
946pub fn writeSessionLine(
947 writer: *std.Io.Writer,
948 session: SessionEntry,
949 short: bool,
950 current_session: ?[]const u8,
951) !void {
952 const current_arrow = "→";
953 const prefix = if (current_session) |current|
954 if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else " "
955 else
956 "";
957
958 if (short) {
959 if (session.is_error) return;
960 try writer.print("{s}\n", .{session.name});
961 return;
962 }
963
964 if (session.is_error) {
965 // "cleaning up" is only truthful when the probe was definitively
966 // refused (socket deleted this pass). On Timeout/Unexpected the
967 // daemon may just be busy, so don't lie about what we did.
968 const status = if (std.mem.eql(u8, session.error_name.?, "ConnectionRefused"))
969 "cleaning up"
970 else
971 "unreachable";
972 try writer.print("{s}name={s}\terr={s}\tstatus={s}\n", .{
973 prefix,
974 session.name,
975 session.error_name.?,
976 status,
977 });
978 return;
979 }
980
981 try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
982 prefix,
983 session.name,
984 session.pid.?,
985 session.clients_len.?,
986 session.created_at,
987 });
988 if (session.cwd) |cwd| {
989 try writer.print("\tcwd={s}", .{cwd});
990 }
991 if (session.cmd) |cmd| {
992 try writer.print("\tcmd={s}", .{cmd});
993 }
994 if (session.task_ended_at) |ended_at| {
995 if (ended_at > 0) {
996 try writer.print("\tended={d}", .{ended_at});
997
998 if (session.task_exit_code) |exit_code| {
999 try writer.print("\texit_code={d}", .{exit_code});
1000 }
1001 }
1002 }
1003 if (session.labels) |labels| {
1004 var kvs = label.LabelIterator.init(labels);
1005 while (kvs.next()) |kv| {
1006 try writer.print("\t{s}={s}", .{ kv.key, kv.value });
1007 }
1008 }
1009 try writer.print("\n", .{});
1010}
1011
1012test "writeSessionLine formats output for current session and short output" {
1013 const Case = struct {
1014 session: SessionEntry,
1015 short: bool,
1016 current_session: ?[]const u8,
1017 expected: []const u8,
1018 };
1019
1020 const session = SessionEntry{
1021 .name = "dev",
1022 .pid = 123,
1023 .clients_len = 2,
1024 .is_error = false,
1025 .error_name = null,
1026 .cmd = null,
1027 .cwd = null,
1028 .created_at = 0,
1029 .task_ended_at = null,
1030 .task_exit_code = null,
1031 };
1032
1033 const cases = [_]Case{
1034 .{
1035 .session = session,
1036 .short = false,
1037 .current_session = "dev",
1038 .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
1039 },
1040 .{
1041 .session = session,
1042 .short = false,
1043 .current_session = "other",
1044 .expected = " name=dev\tpid=123\tclients=2\tcreated=0\n",
1045 },
1046 .{
1047 .session = session,
1048 .short = false,
1049 .current_session = null,
1050 .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
1051 },
1052 .{
1053 .session = session,
1054 .short = true,
1055 .current_session = "dev",
1056 .expected = "dev\n",
1057 },
1058 .{
1059 .session = session,
1060 .short = true,
1061 .current_session = "other",
1062 .expected = "dev\n",
1063 },
1064 .{
1065 .session = session,
1066 .short = true,
1067 .current_session = null,
1068 .expected = "dev\n",
1069 },
1070 };
1071
1072 for (cases) |case| {
1073 var builder: std.Io.Writer.Allocating = .init(testing.allocator);
1074 defer builder.deinit();
1075
1076 try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
1077 try testing.expectEqualStrings(case.expected, builder.writer.buffered());
1078 }
1079}
1080
1081test "shellNeedsQuoting" {
1082 try testing.expect(shellNeedsQuoting(""));
1083 try testing.expect(shellNeedsQuoting("hello world"));
1084 try testing.expect(shellNeedsQuoting("hello!"));
1085 try testing.expect(shellNeedsQuoting("$PATH"));
1086 try testing.expect(shellNeedsQuoting("it's"));
1087 try testing.expect(shellNeedsQuoting("a|b"));
1088 try testing.expect(shellNeedsQuoting("a;b"));
1089 try testing.expect(!shellNeedsQuoting("hello"));
1090 try testing.expect(!shellNeedsQuoting("bash"));
1091 try testing.expect(!shellNeedsQuoting("-c"));
1092 try testing.expect(!shellNeedsQuoting("/usr/bin/env"));
1093}
1094
1095test "shellQuote" {
1096 const alloc = testing.allocator;
1097
1098 const empty = try shellQuote(alloc, "");
1099 defer alloc.free(empty);
1100 try testing.expectEqualStrings("''", empty);
1101
1102 const space = try shellQuote(alloc, "hello world");
1103 defer alloc.free(space);
1104 try testing.expectEqualStrings("'hello world'", space);
1105
1106 const bang = try shellQuote(alloc, "hello!");
1107 defer alloc.free(bang);
1108 try testing.expectEqualStrings("'hello!'", bang);
1109
1110 const dollar = try shellQuote(alloc, "$PATH");
1111 defer alloc.free(dollar);
1112 try testing.expectEqualStrings("'$PATH'", dollar);
1113
1114 const sq = try shellQuote(alloc, "it's");
1115 defer alloc.free(sq);
1116 try testing.expectEqualStrings("'it'\\''s'", sq);
1117
1118 const dq = try shellQuote(alloc, "say \"hi\"");
1119 defer alloc.free(dq);
1120 try testing.expectEqualStrings("'say \"hi\"'", dq);
1121
1122 const both = try shellQuote(alloc, "it's \"cool\"");
1123 defer alloc.free(both);
1124 try testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
1125
1126 // just a single quote
1127 const lone_sq = try shellQuote(alloc, "'");
1128 defer alloc.free(lone_sq);
1129 try testing.expectEqualStrings("''\\'''", lone_sq);
1130
1131 // multiple consecutive single quotes
1132 const triple_sq = try shellQuote(alloc, "'''");
1133 defer alloc.free(triple_sq);
1134 try testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
1135
1136 // backtick command substitution
1137 const backtick = try shellQuote(alloc, "`whoami`");
1138 defer alloc.free(backtick);
1139 try testing.expectEqualStrings("'`whoami`'", backtick);
1140
1141 // dollar command substitution
1142 const dollar_cmd = try shellQuote(alloc, "$(whoami)");
1143 defer alloc.free(dollar_cmd);
1144 try testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
1145
1146 // glob
1147 const glob = try shellQuote(alloc, "*.txt");
1148 defer alloc.free(glob);
1149 try testing.expectEqualStrings("'*.txt'", glob);
1150
1151 // tilde
1152 const tilde = try shellQuote(alloc, "~/file");
1153 defer alloc.free(tilde);
1154 try testing.expectEqualStrings("'~/file'", tilde);
1155
1156 // trailing backslash
1157 const trailing_bs = try shellQuote(alloc, "path\\");
1158 defer alloc.free(trailing_bs);
1159 try testing.expectEqualStrings("'path\\'", trailing_bs);
1160
1161 // semicolon (command injection)
1162 const semi = try shellQuote(alloc, "; rm -rf /");
1163 defer alloc.free(semi);
1164 try testing.expectEqualStrings("'; rm -rf /'", semi);
1165
1166 // embedded newline
1167 const newline = try shellQuote(alloc, "line1\nline2");
1168 defer alloc.free(newline);
1169 try testing.expectEqualStrings("'line1\nline2'", newline);
1170
1171 // parentheses (subshell)
1172 const parens = try shellQuote(alloc, "(echo hi)");
1173 defer alloc.free(parens);
1174 try testing.expectEqualStrings("'(echo hi)'", parens);
1175
1176 // heredoc marker
1177 const heredoc = try shellQuote(alloc, "<<EOF");
1178 defer alloc.free(heredoc);
1179 try testing.expectEqualStrings("'<<EOF'", heredoc);
1180
1181 // no quoting needed -- plain word should still be quoted
1182 // (shellQuote is only called when shellNeedsQuoting returns true,
1183 // but verify it produces valid output anyway)
1184 const plain = try shellQuote(alloc, "hello");
1185 defer alloc.free(plain);
1186 try testing.expectEqualStrings("'hello'", plain);
1187}
1188
1189test "isCtrlBackslash" {
1190 const expect = testing.expect;
1191
1192 // Basic: ctrl only (modifier 5 = 1 + 4)
1193 try expect(isCtrlBackslash("\x1b[92;5u"));
1194
1195 // Explicit press event type (:1)
1196 try expect(isCtrlBackslash("\x1b[92;5:1u"));
1197
1198 // Repeat event (:2) -- user holding Ctrl+\
1199 try expect(isCtrlBackslash("\x1b[92;5:2u"));
1200
1201 // Release event (:3) -- must NOT trigger detach
1202 try expect(!isCtrlBackslash("\x1b[92;5:3u"));
1203
1204 // Lock modifiers: caps_lock (bit 6) changes modifier value
1205 // ctrl + caps_lock = 1 + (4 + 64) = 69
1206 try expect(isCtrlBackslash("\x1b[92;69u"));
1207 try expect(isCtrlBackslash("\x1b[92;69:1u"));
1208 try expect(!isCtrlBackslash("\x1b[92;69:3u"));
1209
1210 // ctrl + num_lock = 1 + (4 + 128) = 133
1211 try expect(isCtrlBackslash("\x1b[92;133u"));
1212
1213 // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
1214 try expect(isCtrlBackslash("\x1b[92;197u"));
1215
1216 // Combined intentional modifiers -- must NOT match (ctrl+\ is the
1217 // detach key, not ctrl+shift+\ or ctrl+alt+\)
1218 // ctrl + shift = 1 + (4 + 1) = 6
1219 try expect(!isCtrlBackslash("\x1b[92;6u"));
1220
1221 // ctrl + alt = 1 + (4 + 2) = 7
1222 try expect(!isCtrlBackslash("\x1b[92;7u"));
1223
1224 // ctrl + super = 1 + (4 + 8) = 13
1225 try expect(!isCtrlBackslash("\x1b[92;13u"));
1226
1227 // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
1228 try expect(!isCtrlBackslash("\x1b[92;70u"));
1229
1230 // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
1231 try expect(!isCtrlBackslash("\x1b[92;134u"));
1232
1233 // Modifier without ctrl bit -- must NOT match
1234 // shift only = 1 + 1 = 2
1235 try expect(!isCtrlBackslash("\x1b[92;1u"));
1236 try expect(!isCtrlBackslash("\x1b[92;2u"));
1237
1238 // Alternate key sub-fields (report_alternates flag)
1239 // shifted key | (124): \x1b[92:124;5u
1240 try expect(isCtrlBackslash("\x1b[92:124;5u"));
1241
1242 // base layout key only (non-US keyboard): \x1b[92::92;5u
1243 try expect(isCtrlBackslash("\x1b[92::92;5u"));
1244
1245 // both shifted and base layout: \x1b[92:124:92;5u
1246 try expect(isCtrlBackslash("\x1b[92:124:92;5u"));
1247
1248 // Alternate keys + lock modifiers + event type
1249 try expect(isCtrlBackslash("\x1b[92:124;69:1u"));
1250 try expect(!isCtrlBackslash("\x1b[92:124;69:3u"));
1251
1252 // Text codepoints section (flag 0b10000) -- tolerated and skipped
1253 // Even though ctrl+\ text is typically empty, terminals may vary
1254 try expect(isCtrlBackslash("\x1b[92;5;28u"));
1255 try expect(isCtrlBackslash("\x1b[92;5;28:92u"));
1256
1257 // Wrong key code -- must NOT match
1258 try expect(!isCtrlBackslash("\x1b[91;5u"));
1259 try expect(!isCtrlBackslash("\x1b[93;5u"));
1260 try expect(!isCtrlBackslash("\x1b[9;5u"));
1261 try expect(!isCtrlBackslash("\x1b[920;5u"));
1262
1263 // Sequence embedded in larger buffer (e.g., preceded by other input)
1264 try expect(isCtrlBackslash("abc\x1b[92;5u"));
1265 try expect(isCtrlBackslash("\x1b[A\x1b[92;5u"));
1266
1267 // Garbage / malformed inputs
1268 try expect(!isCtrlBackslash("garbage"));
1269 try expect(!isCtrlBackslash(""));
1270 try expect(!isCtrlBackslash("\x1b["));
1271 try expect(!isCtrlBackslash("\x1b[92"));
1272 try expect(!isCtrlBackslash("\x1b[92;"));
1273 try expect(!isCtrlBackslash("\x1b[92;u"));
1274 try expect(!isCtrlBackslash("\x1b[;5u"));
1275
1276 // Other CSI u sequences that happen to contain '92' elsewhere
1277 try expect(!isCtrlBackslash("\x1b[65;92u"));
1278}
1279
1280test "isCtrlBackslash xterm modifyOtherKeys" {
1281 const expect = std.testing.expect;
1282
1283 // Basic: ctrl only (modifier 5 = 1 + 4), key 92 = '\'
1284 // Format: CSI 27 ; <mod> ; <key> ~
1285 try expect(isCtrlBackslash("\x1b[27;5;92~"));
1286
1287 // Lock modifiers tolerated
1288 // ctrl + caps_lock = 1 + (4 + 64) = 69
1289 try expect(isCtrlBackslash("\x1b[27;69;92~"));
1290 // ctrl + num_lock = 1 + (4 + 128) = 133
1291 try expect(isCtrlBackslash("\x1b[27;133;92~"));
1292 // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
1293 try expect(isCtrlBackslash("\x1b[27;197;92~"));
1294
1295 // Combined intentional modifiers must NOT match
1296 // ctrl + shift = 1 + (4 + 1) = 6
1297 try expect(!isCtrlBackslash("\x1b[27;6;92~"));
1298 // ctrl + alt = 1 + (4 + 2) = 7
1299 try expect(!isCtrlBackslash("\x1b[27;7;92~"));
1300 // ctrl + super = 1 + (4 + 8) = 13
1301 try expect(!isCtrlBackslash("\x1b[27;13;92~"));
1302 // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
1303 try expect(!isCtrlBackslash("\x1b[27;70;92~"));
1304 // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
1305 try expect(!isCtrlBackslash("\x1b[27;134;92~"));
1306
1307 // Modifier without ctrl bit -- must NOT match
1308 try expect(!isCtrlBackslash("\x1b[27;1;92~"));
1309 try expect(!isCtrlBackslash("\x1b[27;2;92~"));
1310
1311 // Wrong key code -- must NOT match
1312 try expect(!isCtrlBackslash("\x1b[27;5;91~"));
1313 try expect(!isCtrlBackslash("\x1b[27;5;93~"));
1314 try expect(!isCtrlBackslash("\x1b[27;5;65~"));
1315
1316 // Wrong sentinel -- must NOT match
1317 try expect(!isCtrlBackslash("\x1b[28;5;92~"));
1318 try expect(!isCtrlBackslash("\x1b[26;5;92~"));
1319
1320 // Wrong terminator -- must NOT match
1321 try expect(!isCtrlBackslash("\x1b[27;5;92u"));
1322 try expect(!isCtrlBackslash("\x1b[27;5;92m"));
1323
1324 // CSI sequences that look similar but are not modifyOtherKeys
1325 try expect(!isCtrlBackslash("\x1b[27m")); // SGR reset reverse
1326 try expect(!isCtrlBackslash("\x1b[27~")); // xterm F4
1327 try expect(!isCtrlBackslash("\x1b[27;5R")); // truncated cursor report
1328
1329 // Sequence embedded in larger buffer
1330 try expect(isCtrlBackslash("abc\x1b[27;5;92~"));
1331 try expect(isCtrlBackslash("\x1b[A\x1b[27;5;92~"));
1332
1333 // Garbage / malformed
1334 try expect(!isCtrlBackslash("\x1b[27"));
1335 try expect(!isCtrlBackslash("\x1b[27;"));
1336 try expect(!isCtrlBackslash("\x1b[27;5"));
1337 try expect(!isCtrlBackslash("\x1b[27;5;"));
1338 try expect(!isCtrlBackslash("\x1b[27;5;92"));
1339}
1340
1341test "isDetachKeyDisabled" {
1342 _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY");
1343 try testing.expect(!isDetachKeyDisabled());
1344
1345 _ = cross.c.setenv("ZMX_NO_DETACH_KEY", "1", 1);
1346 defer _ = cross.c.unsetenv("ZMX_NO_DETACH_KEY");
1347 try testing.expect(isDetachKeyDisabled());
1348}
1349
1350test "parseOsc7Cwd" {
1351 const Case = struct {
1352 name: []const u8,
1353 value: []const u8,
1354 hostname: []const u8,
1355 expected: ?Cwd,
1356 };
1357
1358 const cases = [_]Case{
1359 .{
1360 .name = "local file uri",
1361 .value = "file://myhost/private/tmp",
1362 .hostname = "myhost",
1363 .expected = .{ .path = "/private/tmp", .is_local = true },
1364 },
1365 .{
1366 .name = "percent-encoded path is decoded",
1367 .value = "file://myhost/tmp/zmx%20spaced%20dir",
1368 .hostname = "myhost",
1369 .expected = .{ .path = "/tmp/zmx spaced dir", .is_local = true },
1370 },
1371 .{
1372 .name = "kitty scheme",
1373 .value = "kitty-shell-cwd://myhost/private/tmp",
1374 .hostname = "myhost",
1375 .expected = .{ .path = "/private/tmp", .is_local = true },
1376 },
1377 .{
1378 .name = "remote host keeps the path but is not local",
1379 .value = "file://otherhost/home/me",
1380 .hostname = "myhost",
1381 .expected = .{ .path = "/home/me", .is_local = false },
1382 },
1383 .{
1384 .name = "empty host means local",
1385 .value = "file:///private/tmp",
1386 .hostname = "myhost",
1387 .expected = .{ .path = "/private/tmp", .is_local = true },
1388 },
1389 .{
1390 .name = "localhost means local",
1391 .value = "file://localhost/private/tmp",
1392 .hostname = "myhost",
1393 .expected = .{ .path = "/private/tmp", .is_local = true },
1394 },
1395 .{
1396 .name = "fqdn matches a short hostname",
1397 .value = "file://myhost.local/private/tmp",
1398 .hostname = "myhost",
1399 .expected = .{ .path = "/private/tmp", .is_local = true },
1400 },
1401 .{
1402 .name = "short host matches an fqdn hostname",
1403 .value = "file://myhost/private/tmp",
1404 .hostname = "myhost.lan",
1405 .expected = .{ .path = "/private/tmp", .is_local = true },
1406 },
1407 .{
1408 .name = "host comparison ignores case",
1409 .value = "file://MyHost/private/tmp",
1410 .hostname = "myhost",
1411 .expected = .{ .path = "/private/tmp", .is_local = true },
1412 },
1413 .{
1414 .name = "plain absolute path passes through",
1415 .value = "/private/tmp",
1416 .hostname = "myhost",
1417 .expected = .{ .path = "/private/tmp", .is_local = true },
1418 },
1419 .{
1420 .name = "empty value",
1421 .value = "",
1422 .hostname = "myhost",
1423 .expected = null,
1424 },
1425 .{
1426 .name = "relative path",
1427 .value = "some/dir",
1428 .hostname = "myhost",
1429 .expected = null,
1430 },
1431 .{
1432 .name = "unsupported scheme",
1433 .value = "http://myhost/private/tmp",
1434 .hostname = "myhost",
1435 .expected = null,
1436 },
1437 .{
1438 .name = "uri without a path",
1439 .value = "file://myhost",
1440 .hostname = "myhost",
1441 .expected = null,
1442 },
1443 };
1444
1445 for (cases) |c| {
1446 var buf: [std.fs.max_path_bytes]u8 = undefined;
1447 const actual = parseOsc7Cwd(&buf, c.value, c.hostname);
1448 testing.expectEqualDeep(c.expected, actual) catch |err| {
1449 std.debug.print("case: {s}\n", .{c.name});
1450 return err;
1451 };
1452 }
1453}
1454
1455test "parseOsc7Cwd result survives the source value changing" {
1456 var buf: [std.fs.max_path_bytes]u8 = undefined;
1457 var value: [32]u8 = undefined;
1458 const src = "file://myhost/private/tmp";
1459 @memcpy(value[0..src.len], src);
1460
1461 const cwd = parseOsc7Cwd(&buf, value[0..src.len], "myhost") orelse
1462 return error.TestUnexpectedNull;
1463
1464 @memset(&value, 'x');
1465 try testing.expectEqualDeep(Cwd{ .path = "/private/tmp", .is_local = true }, cwd);
1466}
1467
1468test "parseOsc7Cwd rejects a path longer than the buffer" {
1469 var buf: [8]u8 = undefined;
1470 try testing.expectEqual(
1471 @as(?Cwd, null),
1472 parseOsc7Cwd(&buf, "file://myhost/a/very/long/path", "myhost"),
1473 );
1474 try testing.expectEqual(
1475 @as(?Cwd, null),
1476 parseOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1477 );
1478}
1479
1480test "toOsc7Cwd" {
1481 const Case = struct {
1482 name: []const u8,
1483 path: []const u8,
1484 expected: ?[]const u8,
1485 };
1486
1487 const cases = [_]Case{
1488 .{
1489 .name = "plain path",
1490 .path = "/private/tmp",
1491 .expected = "file://myhost/private/tmp",
1492 },
1493 .{
1494 .name = "space is encoded",
1495 .path = "/tmp/zmx spaced dir",
1496 .expected = "file://myhost/tmp/zmx%20spaced%20dir",
1497 },
1498 .{
1499 .name = "percent is encoded so it round-trips",
1500 .path = "/tmp/100%",
1501 .expected = "file://myhost/tmp/100%25",
1502 },
1503 .{
1504 .name = "unreserved characters are left alone",
1505 .path = "/tmp/a-b_c.d~e",
1506 .expected = "file://myhost/tmp/a-b_c.d~e",
1507 },
1508 };
1509
1510 for (cases) |c| {
1511 var buf: [std.fs.max_path_bytes]u8 = undefined;
1512 testing.expectEqualDeep(c.expected, toOsc7Cwd(&buf, c.path, "myhost")) catch |err| {
1513 std.debug.print("case: {s}\n", .{c.name});
1514 return err;
1515 };
1516 }
1517}
1518
1519test "toOsc7Cwd returns null when the result would not fit" {
1520 var buf: [8]u8 = undefined;
1521 try testing.expectEqual(
1522 @as(?[]const u8, null),
1523 toOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1524 );
1525}
1526
1527test "toOsc7Cwd round-trips through parseOsc7Cwd" {
1528 const paths = [_][]const u8{
1529 "/private/tmp",
1530 "/tmp/zmx spaced dir",
1531 "/tmp/100%",
1532 "/tmp/a-b_c.d~e",
1533 "/tmp/quote'and\"dquote",
1534 };
1535
1536 for (paths) |path| {
1537 var enc_buf: [std.fs.max_path_bytes]u8 = undefined;
1538 const uri = toOsc7Cwd(&enc_buf, path, "myhost") orelse
1539 return error.TestUnexpectedNull;
1540
1541 var dec_buf: [std.fs.max_path_bytes]u8 = undefined;
1542 testing.expectEqualDeep(
1543 Cwd{ .path = path, .is_local = true },
1544 parseOsc7Cwd(&dec_buf, uri, "myhost"),
1545 ) catch |err| {
1546 std.debug.print("path: {s} uri: {s}\n", .{ path, uri });
1547 return err;
1548 };
1549 }
1550}
1551
1552test "serializeTerminalState excludes synchronized output replay" {
1553 const alloc = testing.allocator;
1554 const io = testing.io;
1555
1556 var term = try ghostty_vt.Terminal.init(io, alloc, .{
1557 .cols = 80,
1558 .rows = 24,
1559 });
1560 defer term.deinit(alloc);
1561
1562 var stream = term.vtStream();
1563 defer stream.deinit();
1564
1565 stream.nextSlice("\x1b[?2004h"); // Bracketed paste
1566 stream.nextSlice("\x1b[?2026h"); // Synchronized output
1567 stream.nextSlice("hello");
1568
1569 try testing.expect(term.modes.get(.bracketed_paste));
1570 try testing.expect(term.modes.get(.synchronized_output));
1571
1572 const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1573 defer alloc.free(output);
1574
1575 // The serialized output should contain bracketed paste (DECSET 2004)
1576 // but NOT synchronized output (DECSET 2026)
1577 try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2004h") != null);
1578 try testing.expect(std.mem.indexOf(u8, output, "\x1b[?2026h") == null);
1579}
1580
1581test "serializeTerminalState replays the title" {
1582 const alloc = testing.allocator;
1583 const io = testing.io;
1584
1585 var term = try ghostty_vt.Terminal.init(io, alloc, .{
1586 .cols = 80,
1587 .rows = 24,
1588 });
1589 defer term.deinit(alloc);
1590
1591 var stream = term.vtStream();
1592 defer stream.deinit();
1593
1594 stream.nextSlice("\x1b]2;my title\x07");
1595 stream.nextSlice("hello");
1596
1597 const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1598 defer alloc.free(output);
1599
1600 try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;my title\x07") != null);
1601}
1602
1603test "serializeTerminalState omits the title when none is set" {
1604 const alloc = testing.allocator;
1605 const io = testing.io;
1606
1607 var term = try ghostty_vt.Terminal.init(io, alloc, .{
1608 .cols = 80,
1609 .rows = 24,
1610 });
1611 defer term.deinit(alloc);
1612
1613 var stream = term.vtStream();
1614 defer stream.deinit();
1615
1616 stream.nextSlice("hello");
1617
1618 const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1619 defer alloc.free(output);
1620
1621 try testing.expect(std.mem.indexOf(u8, output, "\x1b]2;") == null);
1622}
1623
1624test "serializeTerminalState replays the pwd without a NUL sentinel" {
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("\x1b]7;file://myhost/private/tmp\x1b\\");
1638 stream.nextSlice("hello");
1639
1640 const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1641 defer alloc.free(output);
1642
1643 try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1644 try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1645}
1646
1647test "serializeTerminalState omits the pwd when none is set" {
1648 const alloc = testing.allocator;
1649 const io = testing.io;
1650
1651 var term = try ghostty_vt.Terminal.init(io, alloc, .{
1652 .cols = 80,
1653 .rows = 24,
1654 });
1655 defer term.deinit(alloc);
1656
1657 var stream = term.vtStream();
1658 defer stream.deinit();
1659
1660 stream.nextSlice("hello");
1661
1662 const output = serializeTerminalState(alloc, &term) orelse return error.TestUnexpectedNull;
1663 defer alloc.free(output);
1664
1665 try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;") == null);
1666}
1667
1668test "serializeTerminal vt replays the pwd without a NUL sentinel" {
1669 const alloc = testing.allocator;
1670 const io = testing.io;
1671
1672 var term = try ghostty_vt.Terminal.init(io, alloc, .{
1673 .cols = 80,
1674 .rows = 24,
1675 });
1676 defer term.deinit(alloc);
1677
1678 var stream = term.vtStream();
1679 defer stream.deinit();
1680
1681 stream.nextSlice("\x1b]7;file://myhost/private/tmp\x1b\\");
1682 stream.nextSlice("hello");
1683
1684 const output = serializeTerminal(alloc, &term, .vt) orelse return error.TestUnexpectedNull;
1685 defer alloc.free(output);
1686
1687 try testing.expect(std.mem.indexOf(u8, output, "\x1b]7;file://myhost/private/tmp\x1b\\") != null);
1688 try testing.expectEqual(@as(?usize, null), std.mem.indexOfScalar(u8, output, 0));
1689}
1690
1691fn testCreateTerminal(alloc: std.mem.Allocator, io: std.Io, cols: u16, rows: u16, vt_data: []const u8) !ghostty_vt.Terminal {
1692 var term = try ghostty_vt.Terminal.init(io, alloc, .{
1693 .cols = cols,
1694 .rows = rows,
1695 .max_scrollback_lines = 2_000,
1696 });
1697 if (vt_data.len > 0) {
1698 var stream = term.vtStream();
1699 defer stream.deinit();
1700 stream.nextSlice(vt_data);
1701 }
1702 return term;
1703}
1704
1705fn expectScreensMatch(alloc: std.mem.Allocator, expected: *ghostty_vt.Terminal, actual: *ghostty_vt.Terminal) !void {
1706 const exp_str = try expected.plainString(alloc);
1707 defer alloc.free(exp_str);
1708 const act_str = try actual.plainString(alloc);
1709 defer alloc.free(act_str);
1710 try testing.expectEqualStrings(exp_str, act_str);
1711}
1712
1713fn expectCursorAt(term: *ghostty_vt.Terminal, row: usize, col: usize) !void {
1714 const cursor = &term.screens.active.cursor;
1715 try testing.expectEqual(col, cursor.x);
1716 try testing.expectEqual(row, cursor.y);
1717}
1718
1719fn serializeRoundtrip(alloc: std.mem.Allocator, io: std.Io, source: *ghostty_vt.Terminal) !ghostty_vt.Terminal {
1720 const serialized = serializeTerminalState(alloc, source) orelse
1721 return error.SerializationFailed;
1722 defer alloc.free(serialized);
1723
1724 var dest = try ghostty_vt.Terminal.init(io, alloc, .{
1725 .cols = source.screens.active.pages.cols,
1726 .rows = source.screens.active.pages.rows,
1727 .max_scrollback_lines = 2_000,
1728 });
1729 var stream = dest.vtStream();
1730 defer stream.deinit();
1731 stream.nextSlice(serialized);
1732 return dest;
1733}
1734
1735fn expectMarkerAtRow(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, marker: []const u8, expected_row: usize) !void {
1736 const plain = try term.plainString(alloc);
1737 defer alloc.free(plain);
1738 var row: usize = 0;
1739 var iter = std.mem.splitScalar(u8, plain, '\n');
1740 while (iter.next()) |line| {
1741 if (std.mem.indexOf(u8, line, marker) != null) {
1742 try testing.expectEqual(expected_row, row);
1743 return;
1744 }
1745 row += 1;
1746 }
1747 std.debug.print("marker '{s}' not found in terminal output\n", .{marker});
1748 return error.TestExpectedEqual;
1749}
1750
1751test "serializeTerminalState roundtrip preserves cursor position" {
1752 const alloc = testing.allocator;
1753 const io = testing.io;
1754
1755 var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++ // clear
1756 "\x1b[10;20H" // cursor at row 10, col 20 (1-indexed)
1757 );
1758 defer term.deinit(alloc);
1759
1760 try expectCursorAt(&term, 9, 19); // 0-indexed
1761
1762 var client = try serializeRoundtrip(alloc, io, &term);
1763 defer client.deinit(alloc);
1764
1765 try expectCursorAt(&client, 9, 19);
1766}
1767
1768test "serializeTerminalState roundtrip preserves CUP-positioned markers" {
1769 const alloc = testing.allocator;
1770 const io = testing.io;
1771
1772 var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[2J" ++
1773 "\x1b[2;5HMARK_A" ++
1774 "\x1b[6;15HMARK_B" ++
1775 "\x1b[10;30HMARK_C" ++
1776 "\x1b[14;50HMARK_D" ++
1777 "\x1b[16;20H");
1778 defer term.deinit(alloc);
1779
1780 var client = try serializeRoundtrip(alloc, io, &term);
1781 defer client.deinit(alloc);
1782
1783 try expectScreensMatch(alloc, &term, &client);
1784 try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1785 try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1786 try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1787 try expectMarkerAtRow(alloc, &client, "MARK_D", 13);
1788 try expectCursorAt(&client, 15, 19);
1789}
1790
1791test "serializeTerminalState with scrollback preserves visible content" {
1792 const alloc = testing.allocator;
1793 const io = testing.io;
1794
1795 var term = try testCreateTerminal(alloc, io, 80, 24, "");
1796 defer term.deinit(alloc);
1797
1798 var stream = term.vtStream();
1799 defer stream.deinit();
1800
1801 // Generate 80 lines of scrollback (more than 24 visible rows)
1802 var buf: [32]u8 = undefined;
1803 for (0..80) |i| {
1804 const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1805 stream.nextSlice(line);
1806 }
1807
1808 // Clear screen and place markers at specific positions
1809 stream.nextSlice("\x1b[2J" ++
1810 "\x1b[2;5HMARK_A" ++
1811 "\x1b[6;15HMARK_B" ++
1812 "\x1b[10;30HMARK_C" ++
1813 "\x1b[16;20H");
1814
1815 // Verify source terminal has scrollback
1816 const pages = &term.screens.active.pages;
1817 const has_scrollback = !pages.getTopLeft(.screen).eql(pages.getTopLeft(.active));
1818 try testing.expect(has_scrollback);
1819
1820 // Roundtrip: serialize → feed into fresh terminal
1821 var client = try serializeRoundtrip(alloc, io, &term);
1822 defer client.deinit(alloc);
1823
1824 // Visible content must match (this is the core cursor corruption test)
1825 try expectScreensMatch(alloc, &term, &client);
1826 try expectMarkerAtRow(alloc, &client, "MARK_A", 1);
1827 try expectMarkerAtRow(alloc, &client, "MARK_B", 5);
1828 try expectMarkerAtRow(alloc, &client, "MARK_C", 9);
1829 try expectCursorAt(&client, 15, 19);
1830}
1831
1832test "serializeTerminalState nested roundtrip preserves content" {
1833 // Simulates: inner zmx → serialized state → outer ghostty-vt → serialized again → client
1834 // This is the exact nested session scenario (zmx → SSH → zmx).
1835 const alloc = testing.allocator;
1836 const io = testing.io;
1837
1838 // "Inner" terminal with scrollback + markers
1839 var inner = try testCreateTerminal(alloc, io, 80, 24, "");
1840 defer inner.deinit(alloc);
1841
1842 {
1843 var inner_stream = inner.vtStream();
1844 defer inner_stream.deinit();
1845 var buf: [32]u8 = undefined;
1846 for (0..60) |i| {
1847 const line = std.fmt.bufPrint(&buf, "SCROLL_{d}\r\n", .{i}) catch unreachable;
1848 inner_stream.nextSlice(line);
1849 }
1850 inner_stream.nextSlice("\x1b[2J" ++
1851 "\x1b[3;10HINNER_A" ++
1852 "\x1b[12;25HINNER_B" ++
1853 "\x1b[20;5H");
1854 }
1855
1856 // Record inner's ground truth
1857 const inner_cursor_x = inner.screens.active.cursor.x;
1858 const inner_cursor_y = inner.screens.active.cursor.y;
1859
1860 // Serialize inner (simulates inner daemon re-attach to inner client)
1861 const inner_serialized = serializeTerminalState(alloc, &inner) orelse
1862 return error.SerializationFailed;
1863 defer alloc.free(inner_serialized);
1864
1865 // "Outer" terminal processes inner's serialized output
1866 var outer = try testCreateTerminal(alloc, io, 80, 24, "");
1867 defer outer.deinit(alloc);
1868
1869 {
1870 var outer_stream = outer.vtStream();
1871 defer outer_stream.deinit();
1872 outer_stream.nextSlice(inner_serialized);
1873 }
1874
1875 // Serialize outer (simulates outer daemon re-attach after detach)
1876 var client = try serializeRoundtrip(alloc, io, &outer);
1877 defer client.deinit(alloc);
1878
1879 // Client must see the same content as inner's visible screen
1880 try expectScreensMatch(alloc, &inner, &client);
1881 try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
1882 try expectMarkerAtRow(alloc, &client, "INNER_A", 2);
1883 try expectMarkerAtRow(alloc, &client, "INNER_B", 11);
1884}
1885
1886test "serializeTerminalState alternate screen not leaked" {
1887 const alloc = testing.allocator;
1888 const io = testing.io;
1889
1890 var term = try testCreateTerminal(alloc, io, 80, 24, "\x1b[?1049h" ++ // enter alt screen
1891 "\x1b[2J\x1b[3;10HALT_MARK" ++ // write on alt screen
1892 "\x1b[?1049l" ++ // exit alt screen
1893 "\x1b[2J\x1b[2;5HMAIN_MARK\x1b[8;20H" // write on main screen
1894 );
1895 defer term.deinit(alloc);
1896
1897 var client = try serializeRoundtrip(alloc, io, &term);
1898 defer client.deinit(alloc);
1899
1900 try expectScreensMatch(alloc, &term, &client);
1901
1902 const plain = try client.plainString(alloc);
1903 defer alloc.free(plain);
1904 try testing.expect(std.mem.indexOf(u8, plain, "ALT_MARK") == null);
1905 try testing.expect(std.mem.indexOf(u8, plain, "MAIN_MARK") != null);
1906}
1907
1908test "serializeTerminalState size mismatch roundtrip" {
1909 const alloc = testing.allocator;
1910 const io = testing.io;
1911
1912 var term = try testCreateTerminal(alloc, io, 80, 30, "\x1b[2J" ++
1913 "\x1b[3;10HSIZE_A" ++
1914 "\x1b[12;20HSIZE_B" ++
1915 "\x1b[20;40HSIZE_C" ++
1916 "\x1b[15;15H");
1917 defer term.deinit(alloc);
1918
1919 // Resize to 24 rows (simulates outer terminal being smaller)
1920 try term.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
1921
1922 var client = try serializeRoundtrip(alloc, io, &term);
1923 defer client.deinit(alloc);
1924
1925 try expectScreensMatch(alloc, &term, &client);
1926 try expectCursorAt(&client, term.screens.active.cursor.y, term.screens.active.cursor.x);
1927}
1928
1929test "serializeTerminalState scrollback + size mismatch nested roundtrip" {
1930 const alloc = testing.allocator;
1931 const io = testing.io;
1932
1933 var inner = try testCreateTerminal(alloc, io, 80, 30, "");
1934 defer inner.deinit(alloc);
1935
1936 {
1937 var inner_stream = inner.vtStream();
1938 defer inner_stream.deinit();
1939 var buf: [32]u8 = undefined;
1940 for (0..80) |i| {
1941 const line = std.fmt.bufPrint(&buf, "LINE_{d}\r\n", .{i}) catch unreachable;
1942 inner_stream.nextSlice(line);
1943 }
1944 inner_stream.nextSlice("\x1b[2J" ++
1945 "\x1b[3;10HSTRESS_A" ++
1946 "\x1b[12;25HSTRESS_B" ++
1947 "\x1b[16;20H");
1948 }
1949
1950 // Resize inner to 24 rows (outer terminal is smaller)
1951 try inner.resize(alloc, ghostty_vt.Terminal.Resize{ .cols = 80, .rows = 24 });
1952
1953 const inner_cursor_x = inner.screens.active.cursor.x;
1954 const inner_cursor_y = inner.screens.active.cursor.y;
1955
1956 // Inner serialize → outer processes → outer serialize → client
1957 const inner_ser = serializeTerminalState(alloc, &inner) orelse
1958 return error.SerializationFailed;
1959 defer alloc.free(inner_ser);
1960
1961 var outer = try testCreateTerminal(alloc, io, 80, 24, "");
1962 defer outer.deinit(alloc);
1963 {
1964 var outer_stream = outer.vtStream();
1965 defer outer_stream.deinit();
1966 outer_stream.nextSlice(inner_ser);
1967 }
1968
1969 var client = try serializeRoundtrip(alloc, io, &outer);
1970 defer client.deinit(alloc);
1971
1972 try expectScreensMatch(alloc, &inner, &client);
1973 try expectCursorAt(&client, inner_cursor_y, inner_cursor_x);
1974}
1975
1976test "isUserInput: printable characters" {
1977 // Regular text should be detected as user input
1978 try testing.expect(isUserInput("hello"));
1979 try testing.expect(isUserInput("Hello World!"));
1980 try testing.expect(isUserInput("12345"));
1981 try testing.expect(isUserInput("!@#$%^&*()"));
1982}
1983
1984test "isUserInput: whitespace characters" {
1985 // Space character is printable
1986 try testing.expect(isUserInput(" "));
1987 try testing.expect(isUserInput(" "));
1988}
1989
1990test "isUserInput: line feed (LF)" {
1991 // LF triggers .execute action
1992 try testing.expect(isUserInput("\n"));
1993 try testing.expect(isUserInput("test\n"));
1994}
1995
1996test "isUserInput: carriage return (CR)" {
1997 // CR triggers .execute action
1998 try testing.expect(isUserInput("\r"));
1999 try testing.expect(isUserInput("test\r"));
2000}
2001
2002test "isUserInput: tab" {
2003 // Tab triggers .execute action
2004 try testing.expect(isUserInput("\t"));
2005 try testing.expect(isUserInput("col1\tcol2"));
2006}
2007
2008test "isUserInput: backspace" {
2009 // Backspace triggers .execute action
2010 try testing.expect(isUserInput("\x08"));
2011 try testing.expect(isUserInput("test\x08"));
2012}
2013
2014test "isUserInput: arrow keys (CSI ~)" {
2015 // Arrow keys use CSI with ~ - these have params
2016 try testing.expect(isUserInput("\x1b[3~")); // delete
2017 try testing.expect(isUserInput("\x1b[5~")); // page up
2018 try testing.expect(isUserInput("\x1b[6~")); // page down
2019}
2020
2021test "isUserInput: modified arrow keys with CSI u" {
2022 // Modified arrow keys with CSI ... u
2023 try testing.expect(isUserInput("\x1bOA")); // up with modifier
2024 try testing.expect(isUserInput("\x1bOB")); // down with modifier
2025 try testing.expect(isUserInput("\x1bOC")); // right with modifier
2026 try testing.expect(isUserInput("\x1bOD")); // left with modifier
2027}
2028
2029test "isUserInput: up arrow legacy" {
2030 // Legacy up arrow: CSI A (with params for kitty-style)
2031 try testing.expect(isUserInput("\x1b[1;1A")); // kitty-style legacy
2032}
2033
2034test "isUserInput: up arrow kitty" {
2035 // Kitty keyboard up arrow: CSI 1;1;1A (no colon format supported by parser)
2036 try testing.expect(isUserInput("\x1b[1;1;1A")); // kitty up arrow
2037}
2038
2039test "isUserInput: arrow keys with modifier params CSI A-D" {
2040 // Modified arrow keys like Ctrl+Up: CSI 1;5A
2041 try testing.expect(isUserInput("\x1b[1;5A")); // Ctrl+Up
2042 try testing.expect(isUserInput("\x1b[1;5B")); // Ctrl+Down
2043 try testing.expect(isUserInput("\x1b[1;5C")); // Ctrl+Right
2044 try testing.expect(isUserInput("\x1b[1;5D")); // Ctrl+Left
2045 try testing.expect(isUserInput("\x1b[1;3A")); // Alt+Up
2046 try testing.expect(isUserInput("\x1b[1;3B")); // Alt+Down
2047}
2048
2049test "isUserInput: function keys with modifiers CSI 27 ; ~" {
2050 // Legacy modified keys: CSI 27 ; ... ~
2051 try testing.expect(isUserInput("\x1b[15;2~")); // F4 with modifier
2052 try testing.expect(isUserInput("\x1b[17;2~")); // F5 with modifier
2053 try testing.expect(isUserInput("\x1b[18;2~")); // F6 with modifier
2054}
2055
2056test "isUserInput: enter key" {
2057 // Enter is LF (0x0A)
2058 try testing.expect(isUserInput("\x0A"));
2059}
2060
2061test "isUserInput: mixed content" {
2062 // Mix of printable and control sequences
2063 try testing.expect(isUserInput("hello\nworld"));
2064 try testing.expect(isUserInput("\x1b[3~\x1b[6~")); // multiple CSI ~ sequences
2065 try testing.expect(isUserInput("abc\x1b[3~def")); // text with CSI ~
2066}
2067
2068test "isUserInput: non-user input (escape sequences only)" {
2069 // Cursor movement without user input
2070 try testing.expect(!isUserInput("\x1b[2;1H")); // CSI H cursor home
2071 // SGR color set (no printing)
2072 try testing.expect(!isUserInput("\x1b[0m"));
2073 // Cursor position report query
2074 try testing.expect(!isUserInput("\x1b[6n"));
2075}
2076
2077test "isUserInput: empty string" {
2078 try testing.expect(!isUserInput(""));
2079}
2080
2081test "isUserInput: only whitespace controls" {
2082 // Multiple control chars should return true
2083 try testing.expect(isUserInput("\n\r\t"));
2084}
2085
2086test "isUserInput: kitty keyboard sequences" {
2087 // Kitty keyboard protocol uses CSI u
2088 try testing.expect(isUserInput("\x1b[11;2u")); // F1 with modifier
2089 try testing.expect(isUserInput("\x1b[12;2u")); // F2 with modifier
2090 try testing.expect(isUserInput("\x1b[102;1:1u")); // literal "f" press
2091 try testing.expect(isUserInput("\x1b[57444;1:1u")); // Kitty functional key press
2092 try testing.expect(!isUserInput("\x1b[102;1:3u")); // literal "f" release only
2093 try testing.expect(isUserInput("\x1b[102;1:1u\x1b[67;65;31M")); // key press with mouse noise
2094}
2095
2096test "isUserInput: mouse events (CSI M) excluded" {
2097 // Basic mouse tracking (SGR disabled): CSI M Cb Cx Cy
2098 // Mouse events should NOT trigger leader switch
2099 try testing.expect(!isUserInput("\x1b[M@ 0 0")); // button 0, pos 0,0
2100 try testing.expect(!isUserInput("\x1b[M@ 1 1")); // button 1, pos 1,1
2101}
2102
2103test "isUserInput: mouse events SGR mode CSI < excluded" {
2104 // SGR extended mouse tracking: CSI < Cb;Cx;Y M
2105 // Mouse events should NOT trigger leader switch
2106 try testing.expect(!isUserInput("\x1b[<0;1;1M")); // button release
2107 try testing.expect(!isUserInput("\x1b[<64;1;1M")); // button press
2108}
2109
2110test "isUserInput: focus events excluded" {
2111 // Focus in/out are automatic terminal events, not user typing
2112 try testing.expect(!isUserInput("\x1b[I")); // focus in
2113 try testing.expect(!isUserInput("\x1b[O")); // focus out
2114}
2115
2116test "isUserInput: bracketed paste included" {
2117 // Bracketed paste start/end are user-initiated paste operations
2118 try testing.expect(isUserInput("\x1b[200~")); // paste start
2119 try testing.expect(isUserInput("\x1b[201~")); // paste end
2120 // Content between start/end is also user input
2121 try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
2122}
2123
2124test "stripAnsi: plain text passes through" {
2125 const alloc = testing.allocator;
2126 const result = try stripAnsi(alloc, "hello world\n");
2127 defer alloc.free(result);
2128 try testing.expectEqualStrings("hello world\n", result);
2129}
2130
2131test "stripAnsi: removes SGR color codes" {
2132 const alloc = testing.allocator;
2133 // \e[31m = red, \e[0m = reset
2134 const result = try stripAnsi(alloc, "\x1b[31mred\x1b[0m");
2135 defer alloc.free(result);
2136 try testing.expectEqualStrings("red", result);
2137}
2138
2139test "stripAnsi: removes cursor movement" {
2140 const alloc = testing.allocator;
2141 // \e[2J = clear screen, \e[H = home cursor
2142 const result = try stripAnsi(alloc, "\x1b[2J\x1b[Hhello");
2143 defer alloc.free(result);
2144 try testing.expectEqualStrings("hello", result);
2145}
2146
2147test "stripAnsi: preserves newlines and tabs" {
2148 const alloc = testing.allocator;
2149 const result = try stripAnsi(alloc, "line1\nline2\ttab\r");
2150 defer alloc.free(result);
2151 try testing.expectEqualStrings("line1\nline2\ttab\r", result);
2152}
2153
2154test "stripAnsi: removes OSC sequences" {
2155 const alloc = testing.allocator;
2156 // OSC 0;title BEL = set window title
2157 const result = try stripAnsi(alloc, "\x1b]0;My Title\x07hello");
2158 defer alloc.free(result);
2159 try testing.expectEqualStrings("hello", result);
2160}
2161
2162test "stripAnsi: removes DA query and response" {
2163 const alloc = testing.allocator;
2164 // DA1 query: \e[c, DA1 response: \e[?62;22c
2165 const result = try stripAnsi(alloc, "\x1b[c\x1b[?62;22chello");
2166 defer alloc.free(result);
2167 try testing.expectEqualStrings("hello", result);
2168}
2169
2170test "stripAnsi: complex mixed content" {
2171 const alloc = testing.allocator;
2172 // Shell prompt with colors + command echo + output
2173 const input = "\x1b[0;32m[user@host ~]$\x1b[0m git log\n" ++
2174 "abc1234 commit message\n" ++
2175 "\x1b[0;32m[user@host ~]$\x1b[0m";
2176 const result = try stripAnsi(alloc, input);
2177 defer alloc.free(result);
2178 try testing.expectEqualStrings("[user@host ~]$ git log\nabc1234 commit message\n[user@host ~]$", result);
2179}
2180
2181test "stripAnsi: empty input" {
2182 const alloc = testing.allocator;
2183 const result = try stripAnsi(alloc, "");
2184 defer alloc.free(result);
2185 try testing.expectEqualStrings("", result);
2186}
2187
2188test "stripAnsi: only escape sequences" {
2189 const alloc = testing.allocator;
2190 const result = try stripAnsi(alloc, "\x1b[31m\x1b[1m\x1b[0m");
2191 defer alloc.free(result);
2192 try testing.expectEqualStrings("", result);
2193}