main zmx / src / util.zig
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}