Eric Bower
·
2026-10-23
1const std = @import("std");
2const build_options = @import("build_options");
3const has_daemon = if (@hasDecl(build_options, "has_daemon")) build_options.has_daemon else true;
4pub const terminal = if (has_daemon) @import("terminal.zig") else struct {};
5pub const isUserInput = if (has_daemon) terminal.isUserInput else struct {};
6pub const serializeTerminalState = if (has_daemon) terminal.serializeTerminalState else struct {};
7pub const serializeTerminal = if (has_daemon) terminal.serializeTerminal else struct {};
8pub const serializeTerminalRange = if (has_daemon) terminal.serializeTerminalRange else struct {};
9const ipc = @import("ipc.zig");
10const socket = @import("socket.zig");
11const cross = @import("cross");
12const label = @import("label.zig");
13const lib_posix = @import("posix.zig");
14const testing = std.testing;
15
16pub const SessionEntry = struct {
17 name: []const u8,
18 pid: ?i32,
19 clients_len: ?usize,
20 is_error: bool,
21 error_name: ?[]const u8,
22 cmd: ?[]const u8 = null,
23 cwd: ?[]const u8 = null,
24 labels: ?[]const u8 = null,
25 created_at: u64,
26 task_ended_at: ?u64,
27 task_exit_code: ?u8,
28
29 pub fn deinit(self: SessionEntry, alloc: std.mem.Allocator) void {
30 alloc.free(self.name);
31 if (self.cmd) |cmd| alloc.free(cmd);
32 if (self.cwd) |cwd| alloc.free(cwd);
33 if (self.labels) |l| alloc.free(l);
34 }
35
36 pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
37 return std.mem.order(u8, a.name, b.name) == .lt;
38 }
39};
40
41pub fn get_session_entries(
42 alloc: std.mem.Allocator,
43 io: std.Io,
44 socket_dir: []const u8,
45) !std.ArrayList(SessionEntry) {
46 var dir = try std.Io.Dir.openDirAbsolute(io, socket_dir, .{ .iterate = true });
47 defer dir.close(io);
48 var iter = dir.iterate();
49
50 var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
51
52 while (try iter.next(io)) |entry| {
53 const exists = socket.sessionExists(io, dir, entry.name) catch continue;
54 if (exists) {
55 const name = try alloc.dupe(u8, entry.name);
56 errdefer alloc.free(name);
57
58 const socket_path = socket.getSocketPath(alloc, socket_dir, entry.name) catch |err| switch (err) {
59 error.NameTooLong => continue,
60 error.OutOfMemory => return err,
61 };
62 defer alloc.free(socket_path);
63
64 const result = ipc.probeSession(alloc, socket_path) catch |err| {
65 try sessions.append(alloc, .{
66 .name = name,
67 .pid = null,
68 .clients_len = null,
69 .is_error = true,
70 .error_name = @errorName(err),
71 .created_at = 0,
72 .task_exit_code = 1,
73 .task_ended_at = 0,
74 .labels = "",
75 });
76 // Only clean up when the daemon is definitively gone. A busy
77 // daemon can miss the probe timeout; deleting its socket
78 // orphans it permanently.
79 if (err == error.ConnectionRefused) {
80 socket.cleanupStaleSocket(io, dir, entry.name);
81 }
82 continue;
83 };
84 defer result.deinit();
85
86 // Extract cmd and cwd from the fixed-size arrays. Lengths come
87 // off the wire (u16 range), so clamp to the actual array size.
88 const cmd_len = @min(result.info.cmd_len, ipc.MAX_CMD_LEN);
89 const cwd_len = @min(result.info.cwd_len, ipc.MAX_CWD_LEN);
90 const cmd: ?[]const u8 = if (cmd_len > 0)
91 alloc.dupe(u8, result.info.cmd[0..cmd_len]) catch null
92 else
93 null;
94
95 const cwd: ?[]const u8 = if (cwd_len > 0)
96 alloc.dupe(u8, result.info.cwd[0..cwd_len]) catch null
97 else
98 null;
99
100 const labels = if (result.labels) |lbl|
101 alloc.dupe(u8, lbl) catch null
102 else
103 null;
104
105 try sessions.append(alloc, .{
106 .name = name,
107 .pid = result.info.pid,
108 .clients_len = result.info.clients_len,
109 .is_error = false,
110 .error_name = null,
111 .cmd = cmd,
112 .cwd = cwd,
113 .labels = labels,
114 .created_at = result.info.created_at,
115 .task_ended_at = result.info.task_ended_at,
116 .task_exit_code = result.info.task_exit_code,
117 });
118 }
119 }
120
121 return sessions;
122}
123
124pub const Cwd = struct {
125 /// A filesystem path, percent-decoded, with no scheme or host.
126 path: []const u8,
127 /// True when the OSC 7 host is this machine, so `path` names a directory we
128 /// can actually chdir into. OSC 7 crosses SSH boundaries, so a session that
129 /// ssh'd elsewhere reports a path that does not exist locally.
130 is_local: bool,
131};
132
133/// parseOsc7Cwd turns an OSC 7 value into a path that can be opened.
134///
135/// The value looks like `file://<host><path>` with the path percent-encoded, so
136/// it cannot be handed to `openDirAbsolute` as-is: the escaping is never
137/// decoded and any directory whose name needed it fails to open.
138///
139/// A plain absolute path is accepted and passed through, since a caller may
140/// hand us one directly before the session has reported an OSC 7.
141///
142/// `buf` holds the decoded path, so the result stays valid after the source
143/// value changes. Returns null when the value is not a path we can use.
144pub fn parseOsc7Cwd(buf: []u8, value: []const u8, hostname: []const u8) ?Cwd {
145 if (value.len == 0) return null;
146
147 if (std.fs.path.isAbsolute(value)) {
148 if (value.len > buf.len) return null;
149 @memcpy(buf[0..value.len], value);
150 return .{ .path = buf[0..value.len], .is_local = true };
151 }
152
153 const uri = std.Uri.parse(value) catch return null;
154 // kitty emits kitty-shell-cwd:// from its own shell integration, and
155 // accepts it alongside file:// on the way back in.
156 if (!std.mem.eql(u8, uri.scheme, "file") and
157 !std.mem.eql(u8, uri.scheme, "kitty-shell-cwd")) return null;
158
159 const decoded = uri.path.toRaw(buf) catch return null;
160 if (!std.fs.path.isAbsolute(decoded)) return null;
161 // toRaw returns the input slice when there was nothing to decode, and that
162 // slice is owned by the caller of this fn, so copy it into buf either way.
163 const path = if (decoded.ptr == buf.ptr) decoded else blk: {
164 if (decoded.len > buf.len) return null;
165 std.mem.copyForwards(u8, buf[0..decoded.len], decoded);
166 break :blk buf[0..decoded.len];
167 };
168
169 return .{ .path = path, .is_local = isLocalHost(uri.host, hostname) };
170}
171
172fn isLocalHost(host: ?std.Uri.Component, hostname: []const u8) bool {
173 // file:///path omits the host, which conventionally means the local machine.
174 const component = host orelse return true;
175 var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
176 const value = component.toRaw(&host_buf) catch return false;
177 if (value.len == 0) return true;
178 if (std.ascii.eqlIgnoreCase(value, "localhost")) return true;
179 if (std.ascii.eqlIgnoreCase(value, hostname)) return true;
180 // gethostname often reports a short name while OSC 7 carries the FQDN
181 // (or the reverse), so fall back to comparing the first label.
182 const value_label = value[0 .. std.mem.indexOfScalar(u8, value, '.') orelse value.len];
183 const host_label = hostname[0 .. std.mem.indexOfScalar(u8, hostname, '.') orelse hostname.len];
184 return host_label.len > 0 and std.ascii.eqlIgnoreCase(value_label, host_label);
185}
186
187/// toOsc7Cwd renders a plain path as the OSC 7 form, `file://<host><path>`.
188///
189/// The daemon stores its cwd in this form so `zmx list` shows the host, which
190/// is how you can tell at a glance that a session is inside SSH. Callers that
191/// only have a local path (`zmx run`, `zmx attach`) go through this so the
192/// stored value has one shape regardless of where it came from.
193///
194/// Returns null when the result would not fit in `buf`.
195pub fn toOsc7Cwd(buf: []u8, path: []const u8, hostname: []const u8) ?[]const u8 {
196 var w: std.Io.Writer = .fixed(buf);
197 w.print("file://{s}", .{hostname}) catch return null;
198 // Percent-encode so a path with a space or a `%` in it round-trips back
199 // through parseOsc7Cwd unchanged.
200 std.Uri.Component.percentEncode(&w, path, isPathChar) catch return null;
201 return w.buffered();
202}
203
204/// Characters that need no escaping in a URI path. RFC 3986 pchar, minus the
205/// sub-delims that a shell would find surprising to see left raw in `zmx list`.
206fn isPathChar(c: u8) bool {
207 return switch (c) {
208 'A'...'Z', 'a'...'z', '0'...'9' => true,
209 '-', '.', '_', '~', '/', ':', '@' => true,
210 else => false,
211 };
212}
213
214/// getCwd get the current working directory in a std.Uri format.
215/// Caller is responsible for releasing memory.
216pub fn getCwd(gpa: std.mem.Allocator, io: std.Io) ![]u8 {
217 const cur_path = try std.process.currentPathAlloc(io, gpa);
218 defer gpa.free(cur_path);
219
220 var buf: [std.posix.HOST_NAME_MAX]u8 = undefined;
221 const hostname = try std.posix.gethostname(&buf);
222
223 return std.fmt.allocPrint(gpa, "file://{s}{s}", .{ hostname, cur_path });
224}
225
226pub fn shellNeedsQuoting(arg: []const u8) bool {
227 if (arg.len == 0) return true;
228 for (arg) |ch| {
229 switch (ch) {
230 ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']' => return true,
231 '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
232 else => {},
233 }
234 }
235 return false;
236}
237
238pub fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
239 // Always use single quotes (like Python's shlex.quote). Inside single
240 // quotes nothing is special except ' itself, which we handle with the
241 // '\'' trick (end quote, escaped literal quote, reopen quote).
242 var len: usize = 2;
243 for (arg) |ch| {
244 len += if (ch == '\'') 4 else 1;
245 }
246 const buf = try alloc.alloc(u8, len);
247 var i: usize = 0;
248 buf[i] = '\'';
249 i += 1;
250 for (arg) |ch| {
251 if (ch == '\'') {
252 @memcpy(buf[i..][0..4], "'\\''");
253 i += 4;
254 } else {
255 buf[i] = ch;
256 i += 1;
257 }
258 }
259 buf[i] = '\'';
260 return buf;
261}
262
263const DA1_QUERY = "\x1b[c";
264const DA1_QUERY_EXPLICIT = "\x1b[0c";
265const DA2_QUERY = "\x1b[>c";
266const DA2_QUERY_EXPLICIT = "\x1b[>0c";
267const DA1_RESPONSE = "\x1b[?62;22c";
268const DA2_RESPONSE = "\x1b[>1;10;0c";
269
270pub fn respondToDeviceAttributes(alloc: std.mem.Allocator, buf: *std.ArrayList(u8), data: []const u8) void {
271 // Scan for DA queries in PTY output and respond on behalf of the terminal.
272 // This handles the case where no client is attached (e.g. zmx run)
273 // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered.
274 //
275 // Responses are queued into the daemon's pty_write_buf (not written
276 // directly) so they don't interleave with any already-buffered input —
277 // e.g. a large `zmx run` payload still draining after the client
278 // disconnected.
279 //
280 // DA1 query: ESC [ c or ESC [ 0 c
281 // DA2 query: ESC [ > c or ESC [ > 0 c
282 // DA1 response (from terminal): ESC [ ? ... c (has '?' after '[')
283 //
284 // We must NOT match DA responses (which contain '?') as queries.
285 var i: usize = 0;
286 while (i < data.len) {
287 if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') {
288 // Skip DA responses which have '?' after CSI
289 if (i + 2 < data.len and data[i + 2] == '?') {
290 i += 3;
291 continue;
292 }
293 if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) {
294 buf.appendSlice(alloc, DA2_RESPONSE) catch {};
295 } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) {
296 buf.appendSlice(alloc, DA1_RESPONSE) catch {};
297 }
298 }
299 i += 1;
300 }
301}
302
303fn matchSeq(data: []const u8, seq: []const u8) bool {
304 if (data.len < seq.len) return false;
305 return std.mem.eql(u8, data[0..seq.len], seq);
306}
307
308/// OSC 133;A (prompt start) marker.
309const OSC_133_A = "\x1b]133;A";
310
311/// Rewrite OSC 133;A sequences to include `redraw=0`, which tells the outer
312/// terminal not to clear prompt lines on resize. This is necessary because
313/// zmx sits between the shell and the outer terminal: from the outer terminal's
314/// perspective, the foreground process (zmx client) cannot redraw prompts.
315/// Without this, the outer terminal clears the prompt on resize expecting the
316/// shell to redraw it, but the shell's redraw goes through zmx's IPC path with
317/// cursor coordinates relative to the inner PTY, causing a cursor desync that
318/// makes the prompt invisible.
319/// See: https://github.com/neurosnap/zmx/issues/111
320pub fn rewritePromptRedraw(alloc: std.mem.Allocator, data: []const u8) ?[]const u8 {
321 // Fast-path: most PTY output has no escape sequences at all. A scalar
322 // byte scan for ESC is cheaper than the full string indexOf below.
323 if (std.mem.indexOfScalar(u8, data, '\x1b') == null) return null;
324 if (std.mem.indexOf(u8, data, OSC_133_A) == null) return null;
325
326 var result = std.ArrayList(u8).initCapacity(alloc, data.len + 200) catch return null;
327 errdefer result.deinit(alloc);
328 result.appendSlice(alloc, data) catch return null;
329
330 // Work backwards so index shifts don't invalidate later positions.
331 var search_from: usize = result.items.len;
332 while (search_from > 0) {
333 const haystack = result.items[0..search_from];
334 const pos = std.mem.lastIndexOf(u8, haystack, OSC_133_A) orelse break;
335 search_from = pos;
336
337 const after = pos + OSC_133_A.len;
338 if (after >= result.items.len) continue;
339
340 // Find the string terminator (BEL \x07 or ST \x1b\\).
341 var term_pos: ?usize = null;
342 var j = after;
343 while (j < result.items.len) : (j += 1) {
344 if (result.items[j] == '\x07') {
345 term_pos = j;
346 break;
347 }
348 if (result.items[j] == '\x1b' and j + 1 < result.items.len and result.items[j + 1] == '\\') {
349 term_pos = j;
350 break;
351 }
352 }
353 const end = term_pos orelse continue;
354
355 // Check the parameter region between OSC_133_A and the terminator.
356 const params = result.items[after..end];
357
358 // If redraw=0 already present, skip.
359 if (std.mem.indexOf(u8, params, "redraw=0") != null) continue;
360
361 // If redraw= exists with a different value, replace it.
362 if (std.mem.indexOf(u8, params, "redraw=")) |rdw_offset| {
363 const abs_rdw = after + rdw_offset;
364 const value_start = abs_rdw + "redraw=".len;
365 var value_end = value_start;
366 while (value_end < end and result.items[value_end] != ';') : (value_end += 1) {}
367 result.replaceRange(alloc, value_start, value_end - value_start, "0") catch return null;
368 continue;
369 }
370
371 // No redraw= present. Insert ;redraw=0 before the terminator.
372 result.replaceRange(alloc, end, 0, ";redraw=0") catch return null;
373 }
374
375 // If nothing changed, free and return null.
376 if (std.mem.eql(u8, result.items, data)) {
377 result.deinit(alloc);
378 return null;
379 }
380
381 return result.toOwnedSlice(alloc) catch null;
382}
383
384test "rewritePromptRedraw: no OSC 133;A returns null" {
385 const result = rewritePromptRedraw(std.testing.allocator, "hello world");
386 try std.testing.expect(result == null);
387}
388
389test "rewritePromptRedraw: injects redraw=0 with BEL terminator" {
390 const input = "\x1b]133;A\x07";
391 const result = rewritePromptRedraw(std.testing.allocator, input).?;
392 defer std.testing.allocator.free(result);
393 try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
394}
395
396test "rewritePromptRedraw: injects redraw=0 with ST terminator" {
397 const input = "\x1b]133;A\x1b\\";
398 const result = rewritePromptRedraw(std.testing.allocator, input).?;
399 defer std.testing.allocator.free(result);
400 try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x1b\\", result);
401}
402
403test "rewritePromptRedraw: replaces existing redraw=1" {
404 const input = "\x1b]133;A;redraw=1\x07";
405 const result = rewritePromptRedraw(std.testing.allocator, input).?;
406 defer std.testing.allocator.free(result);
407 try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
408}
409
410test "rewritePromptRedraw: replaces existing redraw=last" {
411 const input = "\x1b]133;A;redraw=last\x07";
412 const result = rewritePromptRedraw(std.testing.allocator, input).?;
413 defer std.testing.allocator.free(result);
414 try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
415}
416
417test "rewritePromptRedraw: preserves redraw=0 (no-op)" {
418 const result = rewritePromptRedraw(std.testing.allocator, "\x1b]133;A;redraw=0\x07");
419 try std.testing.expect(result == null);
420}
421
422test "rewritePromptRedraw: preserves other parameters" {
423 const input = "\x1b]133;A;aid=14;cl=line\x07";
424 const result = rewritePromptRedraw(std.testing.allocator, input).?;
425 defer std.testing.allocator.free(result);
426 try std.testing.expectEqualStrings("\x1b]133;A;aid=14;cl=line;redraw=0\x07", result);
427}
428
429test "rewritePromptRedraw: handles multiple markers" {
430 const input = "before\x1b]133;A\x07middle\x1b]133;A;redraw=1\x07after";
431 const result = rewritePromptRedraw(std.testing.allocator, input).?;
432 defer std.testing.allocator.free(result);
433 try std.testing.expectEqualStrings("before\x1b]133;A;redraw=0\x07middle\x1b]133;A;redraw=0\x07after", result);
434}
435
436test "rewritePromptRedraw: does not touch OSC 133;B or 133;C" {
437 const input = "\x1b]133;B\x07\x1b]133;C\x07";
438 const result = rewritePromptRedraw(std.testing.allocator, input);
439 try std.testing.expect(result == null);
440}
441
442test "rewritePromptRedraw: embedded in larger output" {
443 const input = "some output\r\n\x1b]133;A\x07prompt$ \x1b]133;B\x07";
444 const result = rewritePromptRedraw(std.testing.allocator, input).?;
445 defer std.testing.allocator.free(result);
446 try std.testing.expectEqualStrings("some output\r\n\x1b]133;A;redraw=0\x07prompt$ \x1b]133;B\x07", result);
447}
448
449pub fn generateTaskId(io: std.Io) [4]u8 {
450 var bytes: [2]u8 = undefined;
451 io.random(&bytes);
452 return std.fmt.bytesToHex(bytes, .lower);
453}
454
455pub fn getTaskExitMarker(buf: []u8, id_marker: [4]u8) ![]u8 {
456 return std.fmt.bufPrint(buf, "ZMX_TASK_COMPLETED:{s}:", .{id_marker});
457}
458
459pub fn findTaskExitMarker(output: []const u8, id_marker: [4]u8) !?u8 {
460 var buf: [1024]u8 = undefined;
461 const marker = try getTaskExitMarker(&buf, id_marker);
462
463 // The command line is echoed back by the PTY (canonical mode) before the
464 // shell evaluates it, so the *first* occurrence of the marker in the
465 // output is often the literal, unexpanded "ZMX_TASK_COMPLETED:{id}:$?" from
466 // the echo, not the real "ZMX_TASK_COMPLETED:{id}:<code>" written once the
467 // shell actually runs it. Keep scanning past unparseable occurrences
468 // instead of giving up on the first one.
469 var search_start: usize = 0;
470 while (std.mem.indexOfPos(u8, output, search_start, marker)) |idx| {
471 const after_marker = output[idx + marker.len ..];
472
473 // Find the exit code number and newline
474 var end_idx: usize = 0;
475 while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
476 end_idx += 1;
477 }
478
479 const exit_code_str = after_marker[0..end_idx];
480
481 // Parse exit code
482 if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
483 return exit_code;
484 } else |_| {
485 search_start = idx + marker.len;
486 }
487 }
488
489 return null;
490}
491
492/// Strip ANSI escape sequences from data, returning only printable characters
493/// and essential whitespace (CR, LF, tab, backspace).
494/// The returned slice is owned by the caller and must be freed.
495pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
496 var result = std.ArrayList(u8).initCapacity(alloc, data.len) catch unreachable;
497 defer result.deinit(alloc);
498
499 var i: usize = 0;
500 while (i < data.len) {
501 const c = data[i];
502 if (c == 0x1b) {
503 i += 1;
504 if (i >= data.len) break;
505 const next = data[i];
506 if (next == '[') { // CSI
507 i += 1;
508 while (i < data.len) : (i += 1) {
509 const b = data[i];
510 if (b >= 0x40 and b <= 0x7e) {
511 i += 1;
512 break;
513 }
514 }
515 } else if (next == ']') { // OSC
516 i += 1;
517 while (i < data.len) : (i += 1) {
518 const b = data[i];
519 if (b == 0x07) {
520 i += 1;
521 break;
522 }
523 if (b == 0x1b and i + 1 < data.len and data[i + 1] == '\\') {
524 i += 2;
525 break;
526 }
527 }
528 } else if (next == 'P' or next == 'X' or next == '^' or next == '_') { // DCS, SOS, PM, APC
529 i += 1;
530 while (i < data.len) : (i += 1) {
531 if (data[i] == 0x07) {
532 i += 1;
533 break;
534 }
535 if (data[i] == 0x1b and i + 1 < data.len and data[i + 1] == '\\') {
536 i += 2;
537 break;
538 }
539 }
540 } else {
541 // 2-byte escape sequence (e.g. ESC c)
542 i += 1;
543 }
544 } else {
545 switch (c) {
546 '\r', '\n', '\t', 0x08 => try result.append(alloc, c),
547 0x7f => {},
548 else => if (c >= 0x20) try result.append(alloc, c),
549 }
550 i += 1;
551 }
552 }
553 return result.toOwnedSlice(alloc);
554}
555
556/// Dcts Ctrl+\ across raw, Kitty CSI u, and xterm modifyOtherKeys encodings.
557pub fn isCtrlBackslash(buf: []const u8) bool {
558 if (buf.len == 0) return false;
559 return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100) or isModifyOtherKey(buf, 0x5c, 0b100);
560}
561
562/// Scans the buffer for an xterm modifyOtherKeys-encoded keypress.
563/// Format: CSI 27 ; <modifier> ; <keycode> ~
564/// Reference: invisible-island.net/xterm/ctlseqs/ctlseqs.html (modifyOtherKeys).
565fn isModifyOtherKey(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
566 var i: usize = 0;
567 while (i + 1 < buf.len) : (i += 1) {
568 if (buf[i] == 0x1b and buf[i + 1] == '[') {
569 if (modifyOtherMatches(buf[i + 2 ..], expected_key, expected_mods)) return true;
570 }
571 }
572 return false;
573}
574
575/// Parses the body of an xterm modifyOtherKeys CSI sequence (after the leading
576/// `\x1b[`). Mirrors keypressWithMod's tolerance for lock modifiers.
577fn modifyOtherMatches(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
578 var pos: usize = 0;
579
580 // 1. Sentinel: literal "27" identifies xterm modifyOtherKeys.
581 const sentinel = parseDecimal(buf, &pos) orelse return false;
582 if (sentinel != 27) return false;
583
584 // 2. Expect ';' before modifier.
585 if (pos >= buf.len or buf[pos] != ';') return false;
586 pos += 1;
587
588 // 3. Parse modifier (xterm encodes as 1 + bitfield, same as kitty).
589 const mod_encoded = parseDecimal(buf, &pos) orelse return false;
590 if (mod_encoded < 1) return false;
591 const mod_raw = mod_encoded - 1;
592 // Tolerate ambient lock modifiers (caps_lock=64, num_lock=128).
593 const intentional_mods = mod_raw & 0b00111111;
594 if (expected_mods > 0 and expected_mods != intentional_mods) return false;
595
596 // 4. Expect ';' before keycode.
597 if (pos >= buf.len or buf[pos] != ';') return false;
598 pos += 1;
599
600 // 5. Parse keycode.
601 const key_code = parseDecimal(buf, &pos) orelse return false;
602 if (key_code != expected_key) return false;
603
604 // 6. Expect '~' terminator.
605 return pos < buf.len and buf[pos] == '~';
606}
607
608/// Returns true when the user has opted out of the ctrl+\ detach shortcut
609/// via ZMX_NO_DETACH_KEY, e.g. to free up ctrl+\ for an inner program
610/// like vim, which uses ctrl+\ ctrl+n to escape its own terminal mode.
611pub fn isDetachKeyDisabled() bool {
612 return lib_posix.getenv("ZMX_NO_DETACH_KEY") != null;
613}
614
615/// Detects vt100 or kitty keyboard protocol escape sequence for up arrow.
616pub fn isUpArrow(buf: []const u8) bool {
617 return std.mem.eql(u8, buf, "\x1b[A") or std.mem.eql(u8, buf, "\x1b[1;1:1A");
618}
619
620fn isKeyPressed(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
621 // Scan for any CSI u sequence encoding in the buffer.
622 var i: usize = 0;
623 while (i + 2 < buf.len) : (i += 1) {
624 if (buf[i] == 0x1b and buf[i + 1] == '[') {
625 if (keypressWithMod(buf[i + 2 ..], expected_key, expected_mods)) return true;
626 }
627 }
628 return false;
629}
630
631/// Parses the general CSI u form:
632/// CSI key-code[:alternates] ; modifiers[:event-type] [; text-codepoints] u
633///
634/// Event type is press (1 or absent) or repeat (2). Rejects release (3).
635/// Tolerates additional modifiers (caps_lock, num_lock)
636/// and alternate key sub-fields from the kitty protocol's progressive
637/// enhancement flags.
638fn keypressWithMod(buf: []const u8, expected_key: u32, expected_mods: u32) bool {
639 const parsed = parseKittyCsiU(buf) orelse return false;
640 if (parsed.key_code != expected_key) return false;
641
642 // Only accept intentional modifiers. Lock modifiers
643 // (caps_lock=0b1000000, num_lock=0b10000000) are tolerated because
644 // they are ambient state, not deliberate key combinations.
645 const intentional_mods = parsed.modifiers & 0b00111111;
646 if (expected_mods > 0 and expected_mods != intentional_mods) return false;
647
648 // 3 = release -- reject. Accept press (1) and repeat (2).
649 return parsed.event_type != 3;
650}
651
652pub const KittyCsiU = struct {
653 key_code: u32,
654 modifiers: u32,
655 event_type: u32,
656 consumed: usize,
657};
658
659pub fn parseKittyCsiU(buf: []const u8) ?KittyCsiU {
660 var pos: usize = 0;
661
662 // 1. Parse key code.
663 const key_code = parseDecimal(buf, &pos) orelse return null;
664
665 // 2. Skip any ':alternate-key' sub-fields (shifted key, base layout key).
666 while (pos < buf.len and buf[pos] == ':') {
667 pos += 1; // consume ':'
668 _ = parseDecimal(buf, &pos); // consume digits (may be empty for ::base)
669 }
670
671 // 3. Expect ';' separator before modifiers.
672 if (pos >= buf.len or buf[pos] != ';') return null;
673 pos += 1;
674
675 // 4. Parse modifier value. Kitty encodes as 1 + bitfield.
676 const mod_encoded = parseDecimal(buf, &pos) orelse return null;
677 if (mod_encoded < 1) return null;
678 const mod_raw = mod_encoded - 1;
679
680 var event_type: u32 = 1;
681 // 5. Parse optional event type after ':'.
682 if (pos < buf.len and buf[pos] == ':') {
683 pos += 1;
684 event_type = parseDecimal(buf, &pos) orelse return null;
685 }
686
687 // 6. Skip optional ';text-codepoints' section.
688 if (pos < buf.len and buf[pos] == ';') {
689 pos += 1;
690 // Consume remaining digits and colons until 'u'.
691 while (pos < buf.len and (std.ascii.isDigit(buf[pos]) or buf[pos] == ':')) {
692 pos += 1;
693 }
694 }
695
696 // 7. Expect terminal 'u'.
697 if (pos >= buf.len or buf[pos] != 'u') return null;
698 pos += 1;
699
700 return .{
701 .key_code = key_code,
702 .modifiers = mod_raw,
703 .event_type = event_type,
704 .consumed = pos,
705 };
706}
707
708/// Parse a decimal integer from buf starting at pos, advancing pos past the
709/// consumed digits. Returns null if no digits are present.
710fn parseDecimal(buf: []const u8, pos: *usize) ?u32 {
711 const start = pos.*;
712 var value: u32 = 0;
713 while (pos.* < buf.len and std.ascii.isDigit(buf[pos.*])) {
714 value = value *% 10 +% (buf[pos.*] - '0');
715 pos.* += 1;
716 }
717 if (pos.* == start) return null;
718 return value;
719}
720
721pub const HistoryFormat = enum(u8) {
722 plain = 0,
723 vt = 1,
724 html = 2,
725};
726
727/// Formats a session entry for list output (only the name when `short` is
728/// true), adding a prefix to indicate the current session, if there is one.
729pub fn writeSessionLine(
730 writer: *std.Io.Writer,
731 session: SessionEntry,
732 short: bool,
733 current_session: ?[]const u8,
734) !void {
735 const current_arrow = "→";
736 const prefix = if (current_session) |current|
737 if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else " "
738 else
739 "";
740
741 if (short) {
742 if (session.is_error) return;
743 try writer.print("{s}\n", .{session.name});
744 return;
745 }
746
747 if (session.is_error) {
748 // "cleaning up" is only truthful when the probe was definitively
749 // refused (socket deleted this pass). On Timeout/Unexpected the
750 // daemon may just be busy, so don't lie about what we did.
751 const status = if (std.mem.eql(u8, session.error_name.?, "ConnectionRefused"))
752 "cleaning up"
753 else
754 "unreachable";
755 try writer.print("{s}name={s}\terr={s}\tstatus={s}\n", .{
756 prefix,
757 session.name,
758 session.error_name.?,
759 status,
760 });
761 return;
762 }
763
764 try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
765 prefix,
766 session.name,
767 session.pid.?,
768 session.clients_len.?,
769 session.created_at,
770 });
771 if (session.cwd) |cwd| {
772 try writer.print("\tcwd={s}", .{cwd});
773 }
774 if (session.cmd) |cmd| {
775 try writer.print("\tcmd={s}", .{cmd});
776 }
777 if (session.task_ended_at) |ended_at| {
778 if (ended_at > 0) {
779 try writer.print("\tended={d}", .{ended_at});
780
781 if (session.task_exit_code) |exit_code| {
782 try writer.print("\texit_code={d}", .{exit_code});
783 }
784 }
785 }
786 if (session.labels) |labels| {
787 var kvs = label.LabelIterator.init(labels);
788 while (kvs.next()) |kv| {
789 try writer.print("\t{s}={s}", .{ kv.key, kv.value });
790 }
791 }
792 try writer.print("\n", .{});
793}
794
795test "writeSessionLine formats output for current session and short output" {
796 const Case = struct {
797 session: SessionEntry,
798 short: bool,
799 current_session: ?[]const u8,
800 expected: []const u8,
801 };
802
803 const session = SessionEntry{
804 .name = "dev",
805 .pid = 123,
806 .clients_len = 2,
807 .is_error = false,
808 .error_name = null,
809 .cmd = null,
810 .cwd = null,
811 .created_at = 0,
812 .task_ended_at = null,
813 .task_exit_code = null,
814 };
815
816 const cases = [_]Case{
817 .{
818 .session = session,
819 .short = false,
820 .current_session = "dev",
821 .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
822 },
823 .{
824 .session = session,
825 .short = false,
826 .current_session = "other",
827 .expected = " name=dev\tpid=123\tclients=2\tcreated=0\n",
828 },
829 .{
830 .session = session,
831 .short = false,
832 .current_session = null,
833 .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
834 },
835 .{
836 .session = session,
837 .short = true,
838 .current_session = "dev",
839 .expected = "dev\n",
840 },
841 .{
842 .session = session,
843 .short = true,
844 .current_session = "other",
845 .expected = "dev\n",
846 },
847 .{
848 .session = session,
849 .short = true,
850 .current_session = null,
851 .expected = "dev\n",
852 },
853 };
854
855 for (cases) |case| {
856 var builder: std.Io.Writer.Allocating = .init(testing.allocator);
857 defer builder.deinit();
858
859 try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
860 try testing.expectEqualStrings(case.expected, builder.writer.buffered());
861 }
862}
863
864test "shellNeedsQuoting" {
865 try testing.expect(shellNeedsQuoting(""));
866 try testing.expect(shellNeedsQuoting("hello world"));
867 try testing.expect(shellNeedsQuoting("hello!"));
868 try testing.expect(shellNeedsQuoting("$PATH"));
869 try testing.expect(shellNeedsQuoting("it's"));
870 try testing.expect(shellNeedsQuoting("a|b"));
871 try testing.expect(shellNeedsQuoting("a;b"));
872 try testing.expect(!shellNeedsQuoting("hello"));
873 try testing.expect(!shellNeedsQuoting("bash"));
874 try testing.expect(!shellNeedsQuoting("-c"));
875 try testing.expect(!shellNeedsQuoting("/usr/bin/env"));
876}
877
878test "shellQuote" {
879 const alloc = testing.allocator;
880
881 const empty = try shellQuote(alloc, "");
882 defer alloc.free(empty);
883 try testing.expectEqualStrings("''", empty);
884
885 const space = try shellQuote(alloc, "hello world");
886 defer alloc.free(space);
887 try testing.expectEqualStrings("'hello world'", space);
888
889 const bang = try shellQuote(alloc, "hello!");
890 defer alloc.free(bang);
891 try testing.expectEqualStrings("'hello!'", bang);
892
893 const dollar = try shellQuote(alloc, "$PATH");
894 defer alloc.free(dollar);
895 try testing.expectEqualStrings("'$PATH'", dollar);
896
897 const sq = try shellQuote(alloc, "it's");
898 defer alloc.free(sq);
899 try testing.expectEqualStrings("'it'\\''s'", sq);
900
901 const dq = try shellQuote(alloc, "say \"hi\"");
902 defer alloc.free(dq);
903 try testing.expectEqualStrings("'say \"hi\"'", dq);
904
905 const both = try shellQuote(alloc, "it's \"cool\"");
906 defer alloc.free(both);
907 try testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
908
909 // just a single quote
910 const lone_sq = try shellQuote(alloc, "'");
911 defer alloc.free(lone_sq);
912 try testing.expectEqualStrings("''\\'''", lone_sq);
913
914 // multiple consecutive single quotes
915 const triple_sq = try shellQuote(alloc, "'''");
916 defer alloc.free(triple_sq);
917 try testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
918
919 // backtick command substitution
920 const backtick = try shellQuote(alloc, "`whoami`");
921 defer alloc.free(backtick);
922 try testing.expectEqualStrings("'`whoami`'", backtick);
923
924 // dollar command substitution
925 const dollar_cmd = try shellQuote(alloc, "$(whoami)");
926 defer alloc.free(dollar_cmd);
927 try testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
928
929 // glob
930 const glob = try shellQuote(alloc, "*.txt");
931 defer alloc.free(glob);
932 try testing.expectEqualStrings("'*.txt'", glob);
933
934 // tilde
935 const tilde = try shellQuote(alloc, "~/file");
936 defer alloc.free(tilde);
937 try testing.expectEqualStrings("'~/file'", tilde);
938
939 // trailing backslash
940 const trailing_bs = try shellQuote(alloc, "path\\");
941 defer alloc.free(trailing_bs);
942 try testing.expectEqualStrings("'path\\'", trailing_bs);
943
944 // semicolon (command injection)
945 const semi = try shellQuote(alloc, "; rm -rf /");
946 defer alloc.free(semi);
947 try testing.expectEqualStrings("'; rm -rf /'", semi);
948
949 // embedded newline
950 const newline = try shellQuote(alloc, "line1\nline2");
951 defer alloc.free(newline);
952 try testing.expectEqualStrings("'line1\nline2'", newline);
953
954 // parentheses (subshell)
955 const parens = try shellQuote(alloc, "(echo hi)");
956 defer alloc.free(parens);
957 try testing.expectEqualStrings("'(echo hi)'", parens);
958
959 // heredoc marker
960 const heredoc = try shellQuote(alloc, "<<EOF");
961 defer alloc.free(heredoc);
962 try testing.expectEqualStrings("'<<EOF'", heredoc);
963
964 // no quoting needed -- plain word should still be quoted
965 // (shellQuote is only called when shellNeedsQuoting returns true,
966 // but verify it produces valid output anyway)
967 const plain = try shellQuote(alloc, "hello");
968 defer alloc.free(plain);
969 try testing.expectEqualStrings("'hello'", plain);
970}
971
972test "isCtrlBackslash" {
973 const expect = testing.expect;
974
975 // Basic: ctrl only (modifier 5 = 1 + 4)
976 try expect(isCtrlBackslash("\x1b[92;5u"));
977
978 // Explicit press event type (:1)
979 try expect(isCtrlBackslash("\x1b[92;5:1u"));
980
981 // Repeat event (:2) -- user holding Ctrl+\
982 try expect(isCtrlBackslash("\x1b[92;5:2u"));
983
984 // Release event (:3) -- must NOT trigger detach
985 try expect(!isCtrlBackslash("\x1b[92;5:3u"));
986
987 // Lock modifiers: caps_lock (bit 6) changes modifier value
988 // ctrl + caps_lock = 1 + (4 + 64) = 69
989 try expect(isCtrlBackslash("\x1b[92;69u"));
990 try expect(isCtrlBackslash("\x1b[92;69:1u"));
991 try expect(!isCtrlBackslash("\x1b[92;69:3u"));
992
993 // ctrl + num_lock = 1 + (4 + 128) = 133
994 try expect(isCtrlBackslash("\x1b[92;133u"));
995
996 // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
997 try expect(isCtrlBackslash("\x1b[92;197u"));
998
999 // Combined intentional modifiers -- must NOT match (ctrl+\ is the
1000 // detach key, not ctrl+shift+\ or ctrl+alt+\)
1001 // ctrl + shift = 1 + (4 + 1) = 6
1002 try expect(!isCtrlBackslash("\x1b[92;6u"));
1003
1004 // ctrl + alt = 1 + (4 + 2) = 7
1005 try expect(!isCtrlBackslash("\x1b[92;7u"));
1006
1007 // ctrl + super = 1 + (4 + 8) = 13
1008 try expect(!isCtrlBackslash("\x1b[92;13u"));
1009
1010 // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
1011 try expect(!isCtrlBackslash("\x1b[92;70u"));
1012
1013 // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
1014 try expect(!isCtrlBackslash("\x1b[92;134u"));
1015
1016 // Modifier without ctrl bit -- must NOT match
1017 // shift only = 1 + 1 = 2
1018 try expect(!isCtrlBackslash("\x1b[92;1u"));
1019 try expect(!isCtrlBackslash("\x1b[92;2u"));
1020
1021 // Alternate key sub-fields (report_alternates flag)
1022 // shifted key | (124): \x1b[92:124;5u
1023 try expect(isCtrlBackslash("\x1b[92:124;5u"));
1024
1025 // base layout key only (non-US keyboard): \x1b[92::92;5u
1026 try expect(isCtrlBackslash("\x1b[92::92;5u"));
1027
1028 // both shifted and base layout: \x1b[92:124:92;5u
1029 try expect(isCtrlBackslash("\x1b[92:124:92;5u"));
1030
1031 // Alternate keys + lock modifiers + event type
1032 try expect(isCtrlBackslash("\x1b[92:124;69:1u"));
1033 try expect(!isCtrlBackslash("\x1b[92:124;69:3u"));
1034
1035 // Text codepoints section (flag 0b10000) -- tolerated and skipped
1036 // Even though ctrl+\ text is typically empty, terminals may vary
1037 try expect(isCtrlBackslash("\x1b[92;5;28u"));
1038 try expect(isCtrlBackslash("\x1b[92;5;28:92u"));
1039
1040 // Wrong key code -- must NOT match
1041 try expect(!isCtrlBackslash("\x1b[91;5u"));
1042 try expect(!isCtrlBackslash("\x1b[93;5u"));
1043 try expect(!isCtrlBackslash("\x1b[9;5u"));
1044 try expect(!isCtrlBackslash("\x1b[920;5u"));
1045
1046 // Sequence embedded in larger buffer (e.g., preceded by other input)
1047 try expect(isCtrlBackslash("abc\x1b[92;5u"));
1048 try expect(isCtrlBackslash("\x1b[A\x1b[92;5u"));
1049
1050 // Garbage / malformed inputs
1051 try expect(!isCtrlBackslash("garbage"));
1052 try expect(!isCtrlBackslash(""));
1053 try expect(!isCtrlBackslash("\x1b["));
1054 try expect(!isCtrlBackslash("\x1b[92"));
1055 try expect(!isCtrlBackslash("\x1b[92;"));
1056 try expect(!isCtrlBackslash("\x1b[92;u"));
1057 try expect(!isCtrlBackslash("\x1b[;5u"));
1058
1059 // Other CSI u sequences that happen to contain '92' elsewhere
1060 try expect(!isCtrlBackslash("\x1b[65;92u"));
1061}
1062
1063test "isCtrlBackslash xterm modifyOtherKeys" {
1064 const expect = std.testing.expect;
1065
1066 // Basic: ctrl only (modifier 5 = 1 + 4), key 92 = '\'
1067 // Format: CSI 27 ; <mod> ; <key> ~
1068 try expect(isCtrlBackslash("\x1b[27;5;92~"));
1069
1070 // Lock modifiers tolerated
1071 // ctrl + caps_lock = 1 + (4 + 64) = 69
1072 try expect(isCtrlBackslash("\x1b[27;69;92~"));
1073 // ctrl + num_lock = 1 + (4 + 128) = 133
1074 try expect(isCtrlBackslash("\x1b[27;133;92~"));
1075 // ctrl + caps_lock + num_lock = 1 + (4 + 64 + 128) = 197
1076 try expect(isCtrlBackslash("\x1b[27;197;92~"));
1077
1078 // Combined intentional modifiers must NOT match
1079 // ctrl + shift = 1 + (4 + 1) = 6
1080 try expect(!isCtrlBackslash("\x1b[27;6;92~"));
1081 // ctrl + alt = 1 + (4 + 2) = 7
1082 try expect(!isCtrlBackslash("\x1b[27;7;92~"));
1083 // ctrl + super = 1 + (4 + 8) = 13
1084 try expect(!isCtrlBackslash("\x1b[27;13;92~"));
1085 // ctrl + shift + caps_lock = 1 + (1 + 4 + 64) = 70 -- shift is intentional
1086 try expect(!isCtrlBackslash("\x1b[27;70;92~"));
1087 // ctrl + shift + num_lock = 1 + (1 + 4 + 128) = 134 -- shift is intentional
1088 try expect(!isCtrlBackslash("\x1b[27;134;92~"));
1089
1090 // Modifier without ctrl bit -- must NOT match
1091 try expect(!isCtrlBackslash("\x1b[27;1;92~"));
1092 try expect(!isCtrlBackslash("\x1b[27;2;92~"));
1093
1094 // Wrong key code -- must NOT match
1095 try expect(!isCtrlBackslash("\x1b[27;5;91~"));
1096 try expect(!isCtrlBackslash("\x1b[27;5;93~"));
1097 try expect(!isCtrlBackslash("\x1b[27;5;65~"));
1098
1099 // Wrong sentinel -- must NOT match
1100 try expect(!isCtrlBackslash("\x1b[28;5;92~"));
1101 try expect(!isCtrlBackslash("\x1b[26;5;92~"));
1102
1103 // Wrong terminator -- must NOT match
1104 try expect(!isCtrlBackslash("\x1b[27;5;92u"));
1105 try expect(!isCtrlBackslash("\x1b[27;5;92m"));
1106
1107 // CSI sequences that look similar but are not modifyOtherKeys
1108 try expect(!isCtrlBackslash("\x1b[27m")); // SGR reset reverse
1109 try expect(!isCtrlBackslash("\x1b[27~")); // xterm F4
1110 try expect(!isCtrlBackslash("\x1b[27;5R")); // truncated cursor report
1111
1112 // Sequence embedded in larger buffer
1113 try expect(isCtrlBackslash("abc\x1b[27;5;92~"));
1114 try expect(isCtrlBackslash("\x1b[A\x1b[27;5;92~"));
1115
1116 // Garbage / malformed
1117 try expect(!isCtrlBackslash("\x1b[27"));
1118 try expect(!isCtrlBackslash("\x1b[27;"));
1119 try expect(!isCtrlBackslash("\x1b[27;5"));
1120 try expect(!isCtrlBackslash("\x1b[27;5;"));
1121 try expect(!isCtrlBackslash("\x1b[27;5;92"));
1122}
1123
1124test "isDetachKeyDisabled" {
1125 _ = cross.unsetenv("ZMX_NO_DETACH_KEY");
1126 try testing.expect(!isDetachKeyDisabled());
1127
1128 _ = cross.setenv("ZMX_NO_DETACH_KEY", "1", 1);
1129 defer _ = cross.unsetenv("ZMX_NO_DETACH_KEY");
1130 try testing.expect(isDetachKeyDisabled());
1131}
1132
1133test "parseOsc7Cwd" {
1134 const Case = struct {
1135 name: []const u8,
1136 value: []const u8,
1137 hostname: []const u8,
1138 expected: ?Cwd,
1139 };
1140
1141 const cases = [_]Case{
1142 .{
1143 .name = "local file uri",
1144 .value = "file://myhost/private/tmp",
1145 .hostname = "myhost",
1146 .expected = .{ .path = "/private/tmp", .is_local = true },
1147 },
1148 .{
1149 .name = "percent-encoded path is decoded",
1150 .value = "file://myhost/tmp/zmx%20spaced%20dir",
1151 .hostname = "myhost",
1152 .expected = .{ .path = "/tmp/zmx spaced dir", .is_local = true },
1153 },
1154 .{
1155 .name = "kitty scheme",
1156 .value = "kitty-shell-cwd://myhost/private/tmp",
1157 .hostname = "myhost",
1158 .expected = .{ .path = "/private/tmp", .is_local = true },
1159 },
1160 .{
1161 .name = "remote host keeps the path but is not local",
1162 .value = "file://otherhost/home/me",
1163 .hostname = "myhost",
1164 .expected = .{ .path = "/home/me", .is_local = false },
1165 },
1166 .{
1167 .name = "empty host means local",
1168 .value = "file:///private/tmp",
1169 .hostname = "myhost",
1170 .expected = .{ .path = "/private/tmp", .is_local = true },
1171 },
1172 .{
1173 .name = "localhost means local",
1174 .value = "file://localhost/private/tmp",
1175 .hostname = "myhost",
1176 .expected = .{ .path = "/private/tmp", .is_local = true },
1177 },
1178 .{
1179 .name = "fqdn matches a short hostname",
1180 .value = "file://myhost.local/private/tmp",
1181 .hostname = "myhost",
1182 .expected = .{ .path = "/private/tmp", .is_local = true },
1183 },
1184 .{
1185 .name = "short host matches an fqdn hostname",
1186 .value = "file://myhost/private/tmp",
1187 .hostname = "myhost.lan",
1188 .expected = .{ .path = "/private/tmp", .is_local = true },
1189 },
1190 .{
1191 .name = "host comparison ignores case",
1192 .value = "file://MyHost/private/tmp",
1193 .hostname = "myhost",
1194 .expected = .{ .path = "/private/tmp", .is_local = true },
1195 },
1196 .{
1197 .name = "plain absolute path passes through",
1198 .value = "/private/tmp",
1199 .hostname = "myhost",
1200 .expected = .{ .path = "/private/tmp", .is_local = true },
1201 },
1202 .{
1203 .name = "empty value",
1204 .value = "",
1205 .hostname = "myhost",
1206 .expected = null,
1207 },
1208 .{
1209 .name = "relative path",
1210 .value = "some/dir",
1211 .hostname = "myhost",
1212 .expected = null,
1213 },
1214 .{
1215 .name = "unsupported scheme",
1216 .value = "http://myhost/private/tmp",
1217 .hostname = "myhost",
1218 .expected = null,
1219 },
1220 .{
1221 .name = "uri without a path",
1222 .value = "file://myhost",
1223 .hostname = "myhost",
1224 .expected = null,
1225 },
1226 };
1227
1228 for (cases) |c| {
1229 var buf: [std.fs.max_path_bytes]u8 = undefined;
1230 const actual = parseOsc7Cwd(&buf, c.value, c.hostname);
1231 testing.expectEqualDeep(c.expected, actual) catch |err| {
1232 std.debug.print("case: {s}\n", .{c.name});
1233 return err;
1234 };
1235 }
1236}
1237
1238test "parseOsc7Cwd result survives the source value changing" {
1239 var buf: [std.fs.max_path_bytes]u8 = undefined;
1240 var value: [32]u8 = undefined;
1241 const src = "file://myhost/private/tmp";
1242 @memcpy(value[0..src.len], src);
1243
1244 const cwd = parseOsc7Cwd(&buf, value[0..src.len], "myhost") orelse
1245 return error.TestUnexpectedNull;
1246
1247 @memset(&value, 'x');
1248 try testing.expectEqualDeep(Cwd{ .path = "/private/tmp", .is_local = true }, cwd);
1249}
1250
1251test "parseOsc7Cwd rejects a path longer than the buffer" {
1252 var buf: [8]u8 = undefined;
1253 try testing.expectEqual(
1254 @as(?Cwd, null),
1255 parseOsc7Cwd(&buf, "file://myhost/a/very/long/path", "myhost"),
1256 );
1257 try testing.expectEqual(
1258 @as(?Cwd, null),
1259 parseOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1260 );
1261}
1262
1263test "toOsc7Cwd" {
1264 const Case = struct {
1265 name: []const u8,
1266 path: []const u8,
1267 expected: ?[]const u8,
1268 };
1269
1270 const cases = [_]Case{
1271 .{
1272 .name = "plain path",
1273 .path = "/private/tmp",
1274 .expected = "file://myhost/private/tmp",
1275 },
1276 .{
1277 .name = "space is encoded",
1278 .path = "/tmp/zmx spaced dir",
1279 .expected = "file://myhost/tmp/zmx%20spaced%20dir",
1280 },
1281 .{
1282 .name = "percent is encoded so it round-trips",
1283 .path = "/tmp/100%",
1284 .expected = "file://myhost/tmp/100%25",
1285 },
1286 .{
1287 .name = "unreserved characters are left alone",
1288 .path = "/tmp/a-b_c.d~e",
1289 .expected = "file://myhost/tmp/a-b_c.d~e",
1290 },
1291 };
1292
1293 for (cases) |c| {
1294 var buf: [std.fs.max_path_bytes]u8 = undefined;
1295 testing.expectEqualDeep(c.expected, toOsc7Cwd(&buf, c.path, "myhost")) catch |err| {
1296 std.debug.print("case: {s}\n", .{c.name});
1297 return err;
1298 };
1299 }
1300}
1301
1302test "toOsc7Cwd returns null when the result would not fit" {
1303 var buf: [8]u8 = undefined;
1304 try testing.expectEqual(
1305 @as(?[]const u8, null),
1306 toOsc7Cwd(&buf, "/a/very/long/path", "myhost"),
1307 );
1308}
1309
1310test "toOsc7Cwd round-trips through parseOsc7Cwd" {
1311 const paths = [_][]const u8{
1312 "/private/tmp",
1313 "/tmp/zmx spaced dir",
1314 "/tmp/100%",
1315 "/tmp/a-b_c.d~e",
1316 "/tmp/quote'and\"dquote",
1317 };
1318
1319 for (paths) |path| {
1320 var enc_buf: [std.fs.max_path_bytes]u8 = undefined;
1321 const uri = toOsc7Cwd(&enc_buf, path, "myhost") orelse
1322 return error.TestUnexpectedNull;
1323
1324 var dec_buf: [std.fs.max_path_bytes]u8 = undefined;
1325 testing.expectEqualDeep(
1326 Cwd{ .path = path, .is_local = true },
1327 parseOsc7Cwd(&dec_buf, uri, "myhost"),
1328 ) catch |err| {
1329 std.debug.print("path: {s} uri: {s}\n", .{ path, uri });
1330 return err;
1331 };
1332 }
1333}
1334
1335test "stripAnsi: plain text passes through" {
1336 const alloc = testing.allocator;
1337 const result = try stripAnsi(alloc, "hello world\n");
1338 defer alloc.free(result);
1339 try testing.expectEqualStrings("hello world\n", result);
1340}
1341
1342test "stripAnsi: removes SGR color codes" {
1343 const alloc = testing.allocator;
1344 // \e[31m = red, \e[0m = reset
1345 const result = try stripAnsi(alloc, "\x1b[31mred\x1b[0m");
1346 defer alloc.free(result);
1347 try testing.expectEqualStrings("red", result);
1348}
1349
1350test "stripAnsi: removes cursor movement" {
1351 const alloc = testing.allocator;
1352 // \e[2J = clear screen, \e[H = home cursor
1353 const result = try stripAnsi(alloc, "\x1b[2J\x1b[Hhello");
1354 defer alloc.free(result);
1355 try testing.expectEqualStrings("hello", result);
1356}
1357
1358test "stripAnsi: preserves newlines and tabs" {
1359 const alloc = testing.allocator;
1360 const result = try stripAnsi(alloc, "line1\nline2\ttab\r");
1361 defer alloc.free(result);
1362 try testing.expectEqualStrings("line1\nline2\ttab\r", result);
1363}
1364
1365test "stripAnsi: removes OSC sequences" {
1366 const alloc = testing.allocator;
1367 // OSC 0;title BEL = set window title
1368 const result = try stripAnsi(alloc, "\x1b]0;My Title\x07hello");
1369 defer alloc.free(result);
1370 try testing.expectEqualStrings("hello", result);
1371}
1372
1373test "stripAnsi: removes DA query and response" {
1374 const alloc = testing.allocator;
1375 // DA1 query: \e[c, DA1 response: \e[?62;22c
1376 const result = try stripAnsi(alloc, "\x1b[c\x1b[?62;22chello");
1377 defer alloc.free(result);
1378 try testing.expectEqualStrings("hello", result);
1379}
1380
1381test "stripAnsi: complex mixed content" {
1382 const alloc = testing.allocator;
1383 // Shell prompt with colors + command echo + output
1384 const input = "\x1b[0;32m[user@host ~]$\x1b[0m git log\n" ++
1385 "abc1234 commit message\n" ++
1386 "\x1b[0;32m[user@host ~]$\x1b[0m";
1387 const result = try stripAnsi(alloc, input);
1388 defer alloc.free(result);
1389 try testing.expectEqualStrings("[user@host ~]$ git log\nabc1234 commit message\n[user@host ~]$", result);
1390}
1391
1392test "stripAnsi: empty input" {
1393 const alloc = testing.allocator;
1394 const result = try stripAnsi(alloc, "");
1395 defer alloc.free(result);
1396 try testing.expectEqualStrings("", result);
1397}
1398
1399test "stripAnsi: only escape sequences" {
1400 const alloc = testing.allocator;
1401 const result = try stripAnsi(alloc, "\x1b[31m\x1b[1m\x1b[0m");
1402 defer alloc.free(result);
1403 try testing.expectEqualStrings("", result);
1404}
1405
1406test "stripAnsi: keeps UTF-8 text" {
1407 const alloc = testing.allocator;
1408 const result = try stripAnsi(alloc, "\x1b[1mcafé\x1b[0m – 日本 🎉\n");
1409 defer alloc.free(result);
1410 try testing.expectEqualStrings("café – 日本 🎉\n", result);
1411}
1412
1413test "stripAnsi: UTF-8 bytes do not start escape sequences" {
1414 const alloc = testing.allocator;
1415 // Ý is C3 9D, and 0x9D on its own is the 8-bit OSC introducer
1416 const result = try stripAnsi(alloc, "Ýmir\nline two\n");
1417 defer alloc.free(result);
1418 try testing.expectEqualStrings("Ýmir\nline two\n", result);
1419}