Commit adb11d6

Eric Bower  ·  2026-05-21 10:06:07 -0400 EDT
parent a25e2fe
refactor: strip ansi escape codes for tail cmd

refactor: pass program env vars to help prevent
2 files changed,  +139, -18
+33, -17
......@@ -1140,17 +1140,20 @@ const Daemon = struct {
11401140
11411141 const cmd = payload;
11421142
1143- // Redirect stdin from /dev/null to prevent interactive programs
1144- // (pagers, editors, prompts) from blocking the task. Programs that
1145- // detect a TTY and open a pager (e.g. git, man) or read stdin
1146- // (e.g. cat, head) will receive EOF instead of blocking. This
1147- // matches the behavior of CI runners and `tmux send-keys`.
1143+ // Prefix the command with environment variables to prevent it from
1144+ // blocking. Commands run in a PTY where stdout looks like a TTY,
1145+ // so programs like git/man/less will open a pager and hang. We set
1146+ // pager-related env vars to force non-interactive behavior:
1147+ // PAGER=cat — default pager fallback
1148+ // GIT_PAGER=cat — git ignores PAGER, uses its own
1149+ // LESS=-F — auto-exit if content fits (catches edge cases)
1150+ // MANPAGER=cat — man overrides PAGER with MANPAGER
1151+ // COLORTERM= — disable color (avoid ANSI in output)
1152+ // Plus < /dev/null to prevent programs that read stdin from blocking.
11481153 //
11491154 // Commands that legitimately need stdin should use `zmx send`
11501155 // instead, or pipe data directly: `echo data | zmx run dev cat`.
1151- // Here-documents still work because the shell processes the
1152- // here-document before applying the /dev/null redirection.
1153- const stdin_redirect = "< /dev/null ";
1156+ const cmd_prefix = "PAGER=cat GIT_PAGER=cat LESS=-F MANPAGER=cat COLORTERM= < /dev/null ";
11541157
11551158 // Chain the exit marker with `;` on the same line. `$?` captures the
11561159 // exit code of the command (not the `;`). The sole exception is when
......@@ -1160,7 +1163,7 @@ const Daemon = struct {
11601163 const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r";
11611164 const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null;
11621165
1163- self.queuePtyInput(stdin_redirect);
1166+ self.queuePtyInput(cmd_prefix);
11641167 if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
11651168 self.queuePtyInput(cmd[0 .. cmd.len - 1]);
11661169 } else {
......@@ -1451,17 +1454,30 @@ fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool)
14511454 },
14521455 .Output => {
14531456 if (msg.payload.len > 0) {
1454- // strip the first line since it is an echo of
1455- // the command.
1457+ // Strip the first line (command echo) for run mode.
1458+ var payload = msg.payload;
14561459 if (!detached and is_run_cmd and is_first_line) {
1457- if (std.mem.indexOfScalar(u8, msg.payload, '\n')) |nl| {
1460+ if (std.mem.indexOfScalar(u8, payload, '\n')) |nl| {
14581461 is_first_line = false;
1459- if (nl + 1 < msg.payload.len) {
1460- try stdout_buf.appendSlice(alloc, msg.payload[nl + 1 ..]);
1461- }
1462+ payload = payload[nl + 1 ..];
1463+ } else {
1464+ is_first_line = false;
1465+ payload = payload[payload.len..]; // consume entire echo line
1466+ }
1467+ }
1468+
1469+ if (payload.len > 0) {
1470+ // Strip ANSI escape sequences to produce plain text.
1471+ // This prevents shell prompts, colors, cursor movements,
1472+ // and other VT sequences from corrupting the caller's terminal.
1473+ const plain = util.stripAnsi(alloc, payload) catch |err| {
1474+ std.log.warn("stripAnsi failed: {s}", .{@errorName(err)});
1475+ continue;
1476+ };
1477+ defer alloc.free(plain);
1478+ if (plain.len > 0) {
1479+ try stdout_buf.appendSlice(alloc, plain);
14621480 }
1463- } else {
1464- try stdout_buf.appendSlice(alloc, msg.payload);
14651481 }
14661482 }
14671483 },
+106, -1
......@@ -350,7 +350,41 @@ pub fn findTaskExitMarker(output: []const u8) ?u8 {
350350 return null;
351351 }
352352
353-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\.
353+/// Strip ANSI escape sequences from data, returning only printable characters
354+/// and essential whitespace (CR, LF, tab, backspace). Uses the ghostty VT
355+/// parser to correctly handle multi-byte sequences (CSI, OSC, DCS, etc.).n/// The returned slice is owned by the caller and must be freed.
356+pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
357+ var result = std.ArrayList(u8).initCapacity(alloc, data.len) catch unreachable;
358+ defer result.deinit(alloc);
359+
360+ var parser = ghostty_vt.Parser.init();
361+ for (data) |c| {
362+ const actions = parser.next(c);
363+ for (actions) |action_opt| {
364+ const action = action_opt orelse continue;
365+ switch (action) {
366+ .print => {
367+ result.append(alloc, c) catch unreachable;
368+ },
369+ .execute => |code| {
370+ // Pass through essential whitespace/control chars
371+ switch (code) {
372+ '\r', '\n', '\t', 0x08 => { // CR, LF, TAB, BS
373+ result.append(alloc, @as(u8, @intCast(code))) catch unreachable;
374+ },
375+ else => {},
376+ }
377+ },
378+ // All other actions (CSI, OSC, DCS, etc.) are silently dropped
379+ else => {},
380+ }
381+ }
382+ }
383+
384+ return result.toOwnedSlice(alloc);
385+}
386+
387+/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
354388 pub fn isCtrlBackslash(buf: []const u8) bool {
355389 if (buf.len == 0) return false;
356390 return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100);
......@@ -1417,3 +1451,74 @@ test "isUserInput: bracketed paste included" {
14171451 // Content between start/end is also user input
14181452 try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
14191453 }
1454+
1455+test "stripAnsi: plain text passes through" {
1456+ const alloc = testing.allocator;
1457+ const result = try stripAnsi(alloc, "hello world\n");
1458+ defer alloc.free(result);
1459+ try testing.expectEqualStrings("hello world\n", result);
1460+}
1461+
1462+test "stripAnsi: removes SGR color codes" {
1463+ const alloc = testing.allocator;
1464+ // \e[31m = red, \e[0m = reset
1465+ const result = try stripAnsi(alloc, "\x1b[31mred\x1b[0m");
1466+ defer alloc.free(result);
1467+ try testing.expectEqualStrings("red", result);
1468+}
1469+
1470+test "stripAnsi: removes cursor movement" {
1471+ const alloc = testing.allocator;
1472+ // \e[2J = clear screen, \e[H = home cursor
1473+ const result = try stripAnsi(alloc, "\x1b[2J\x1b[Hhello");
1474+ defer alloc.free(result);
1475+ try testing.expectEqualStrings("hello", result);
1476+}
1477+
1478+test "stripAnsi: preserves newlines and tabs" {
1479+ const alloc = testing.allocator;
1480+ const result = try stripAnsi(alloc, "line1\nline2\ttab\r");
1481+ defer alloc.free(result);
1482+ try testing.expectEqualStrings("line1\nline2\ttab\r", result);
1483+}
1484+
1485+test "stripAnsi: removes OSC sequences" {
1486+ const alloc = testing.allocator;
1487+ // OSC 0;title BEL = set window title
1488+ const result = try stripAnsi(alloc, "\x1b]0;My Title\x07hello");
1489+ defer alloc.free(result);
1490+ try testing.expectEqualStrings("hello", result);
1491+}
1492+
1493+test "stripAnsi: removes DA query and response" {
1494+ const alloc = testing.allocator;
1495+ // DA1 query: \e[c, DA1 response: \e[?62;22c
1496+ const result = try stripAnsi(alloc, "\x1b[c\x1b[?62;22chello");
1497+ defer alloc.free(result);
1498+ try testing.expectEqualStrings("hello", result);
1499+}
1500+
1501+test "stripAnsi: complex mixed content" {
1502+ const alloc = testing.allocator;
1503+ // Shell prompt with colors + command echo + output
1504+ const input = "\x1b[0;32m[user@host ~]$\x1b[0m git log\n" ++
1505+ "abc1234 commit message\n" ++
1506+ "\x1b[0;32m[user@host ~]$\x1b[0m";
1507+ const result = try stripAnsi(alloc, input);
1508+ defer alloc.free(result);
1509+ try testing.expectEqualStrings("[user@host ~]$ git log\nabc1234 commit message\n[user@host ~]$", result);
1510+}
1511+
1512+test "stripAnsi: empty input" {
1513+ const alloc = testing.allocator;
1514+ const result = try stripAnsi(alloc, "");
1515+ defer alloc.free(result);
1516+ try testing.expectEqualStrings("", result);
1517+}
1518+
1519+test "stripAnsi: only escape sequences" {
1520+ const alloc = testing.allocator;
1521+ const result = try stripAnsi(alloc, "\x1b[31m\x1b[1m\x1b[0m");
1522+ defer alloc.free(result);
1523+ try testing.expectEqualStrings("", result);
1524+}