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