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
1@@ -1140,17 +1140,20 @@ const Daemon = struct {
2
3 const cmd = payload;
4
5- // Redirect stdin from /dev/null to prevent interactive programs
6- // (pagers, editors, prompts) from blocking the task. Programs that
7- // detect a TTY and open a pager (e.g. git, man) or read stdin
8- // (e.g. cat, head) will receive EOF instead of blocking. This
9- // matches the behavior of CI runners and `tmux send-keys`.
10+ // Prefix the command with environment variables to prevent it from
11+ // blocking. Commands run in a PTY where stdout looks like a TTY,
12+ // so programs like git/man/less will open a pager and hang. We set
13+ // pager-related env vars to force non-interactive behavior:
14+ // PAGER=cat — default pager fallback
15+ // GIT_PAGER=cat — git ignores PAGER, uses its own
16+ // LESS=-F — auto-exit if content fits (catches edge cases)
17+ // MANPAGER=cat — man overrides PAGER with MANPAGER
18+ // COLORTERM= — disable color (avoid ANSI in output)
19+ // Plus < /dev/null to prevent programs that read stdin from blocking.
20 //
21 // Commands that legitimately need stdin should use `zmx send`
22 // instead, or pipe data directly: `echo data | zmx run dev cat`.
23- // Here-documents still work because the shell processes the
24- // here-document before applying the /dev/null redirection.
25- const stdin_redirect = "< /dev/null ";
26+ const cmd_prefix = "PAGER=cat GIT_PAGER=cat LESS=-F MANPAGER=cat COLORTERM= < /dev/null ";
27
28 // Chain the exit marker with `;` on the same line. `$?` captures the
29 // exit code of the command (not the `;`). The sole exception is when
30@@ -1160,7 +1163,7 @@ const Daemon = struct {
31 const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r";
32 const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null;
33
34- self.queuePtyInput(stdin_redirect);
35+ self.queuePtyInput(cmd_prefix);
36 if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
37 self.queuePtyInput(cmd[0 .. cmd.len - 1]);
38 } else {
39@@ -1451,17 +1454,30 @@ fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool)
40 },
41 .Output => {
42 if (msg.payload.len > 0) {
43- // strip the first line since it is an echo of
44- // the command.
45+ // Strip the first line (command echo) for run mode.
46+ var payload = msg.payload;
47 if (!detached and is_run_cmd and is_first_line) {
48- if (std.mem.indexOfScalar(u8, msg.payload, '\n')) |nl| {
49+ if (std.mem.indexOfScalar(u8, payload, '\n')) |nl| {
50 is_first_line = false;
51- if (nl + 1 < msg.payload.len) {
52- try stdout_buf.appendSlice(alloc, msg.payload[nl + 1 ..]);
53- }
54+ payload = payload[nl + 1 ..];
55+ } else {
56+ is_first_line = false;
57+ payload = payload[payload.len..]; // consume entire echo line
58+ }
59+ }
60+
61+ if (payload.len > 0) {
62+ // Strip ANSI escape sequences to produce plain text.
63+ // This prevents shell prompts, colors, cursor movements,
64+ // and other VT sequences from corrupting the caller's terminal.
65+ const plain = util.stripAnsi(alloc, payload) catch |err| {
66+ std.log.warn("stripAnsi failed: {s}", .{@errorName(err)});
67+ continue;
68+ };
69+ defer alloc.free(plain);
70+ if (plain.len > 0) {
71+ try stdout_buf.appendSlice(alloc, plain);
72 }
73- } else {
74- try stdout_buf.appendSlice(alloc, msg.payload);
75 }
76 }
77 },
+106,
-1
1@@ -350,7 +350,41 @@ pub fn findTaskExitMarker(output: []const u8) ?u8 {
2 return null;
3 }
4
5-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\.
6+/// Strip ANSI escape sequences from data, returning only printable characters
7+/// and essential whitespace (CR, LF, tab, backspace). Uses the ghostty VT
8+/// parser to correctly handle multi-byte sequences (CSI, OSC, DCS, etc.).n/// The returned slice is owned by the caller and must be freed.
9+pub fn stripAnsi(alloc: std.mem.Allocator, data: []const u8) ![]const u8 {
10+ var result = std.ArrayList(u8).initCapacity(alloc, data.len) catch unreachable;
11+ defer result.deinit(alloc);
12+
13+ var parser = ghostty_vt.Parser.init();
14+ for (data) |c| {
15+ const actions = parser.next(c);
16+ for (actions) |action_opt| {
17+ const action = action_opt orelse continue;
18+ switch (action) {
19+ .print => {
20+ result.append(alloc, c) catch unreachable;
21+ },
22+ .execute => |code| {
23+ // Pass through essential whitespace/control chars
24+ switch (code) {
25+ '\r', '\n', '\t', 0x08 => { // CR, LF, TAB, BS
26+ result.append(alloc, @as(u8, @intCast(code))) catch unreachable;
27+ },
28+ else => {},
29+ }
30+ },
31+ // All other actions (CSI, OSC, DCS, etc.) are silently dropped
32+ else => {},
33+ }
34+ }
35+ }
36+
37+ return result.toOwnedSlice(alloc);
38+}
39+
40+/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
41 pub fn isCtrlBackslash(buf: []const u8) bool {
42 if (buf.len == 0) return false;
43 return buf[0] == 0x1C or isKeyPressed(buf, 0x5c, 0b100);
44@@ -1417,3 +1451,74 @@ test "isUserInput: bracketed paste included" {
45 // Content between start/end is also user input
46 try testing.expect(isUserInput("\x1b[200~hello\x1b[201~"));
47 }
48+
49+test "stripAnsi: plain text passes through" {
50+ const alloc = testing.allocator;
51+ const result = try stripAnsi(alloc, "hello world\n");
52+ defer alloc.free(result);
53+ try testing.expectEqualStrings("hello world\n", result);
54+}
55+
56+test "stripAnsi: removes SGR color codes" {
57+ const alloc = testing.allocator;
58+ // \e[31m = red, \e[0m = reset
59+ const result = try stripAnsi(alloc, "\x1b[31mred\x1b[0m");
60+ defer alloc.free(result);
61+ try testing.expectEqualStrings("red", result);
62+}
63+
64+test "stripAnsi: removes cursor movement" {
65+ const alloc = testing.allocator;
66+ // \e[2J = clear screen, \e[H = home cursor
67+ const result = try stripAnsi(alloc, "\x1b[2J\x1b[Hhello");
68+ defer alloc.free(result);
69+ try testing.expectEqualStrings("hello", result);
70+}
71+
72+test "stripAnsi: preserves newlines and tabs" {
73+ const alloc = testing.allocator;
74+ const result = try stripAnsi(alloc, "line1\nline2\ttab\r");
75+ defer alloc.free(result);
76+ try testing.expectEqualStrings("line1\nline2\ttab\r", result);
77+}
78+
79+test "stripAnsi: removes OSC sequences" {
80+ const alloc = testing.allocator;
81+ // OSC 0;title BEL = set window title
82+ const result = try stripAnsi(alloc, "\x1b]0;My Title\x07hello");
83+ defer alloc.free(result);
84+ try testing.expectEqualStrings("hello", result);
85+}
86+
87+test "stripAnsi: removes DA query and response" {
88+ const alloc = testing.allocator;
89+ // DA1 query: \e[c, DA1 response: \e[?62;22c
90+ const result = try stripAnsi(alloc, "\x1b[c\x1b[?62;22chello");
91+ defer alloc.free(result);
92+ try testing.expectEqualStrings("hello", result);
93+}
94+
95+test "stripAnsi: complex mixed content" {
96+ const alloc = testing.allocator;
97+ // Shell prompt with colors + command echo + output
98+ const input = "\x1b[0;32m[user@host ~]$\x1b[0m git log\n" ++
99+ "abc1234 commit message\n" ++
100+ "\x1b[0;32m[user@host ~]$\x1b[0m";
101+ const result = try stripAnsi(alloc, input);
102+ defer alloc.free(result);
103+ try testing.expectEqualStrings("[user@host ~]$ git log\nabc1234 commit message\n[user@host ~]$", result);
104+}
105+
106+test "stripAnsi: empty input" {
107+ const alloc = testing.allocator;
108+ const result = try stripAnsi(alloc, "");
109+ defer alloc.free(result);
110+ try testing.expectEqualStrings("", result);
111+}
112+
113+test "stripAnsi: only escape sequences" {
114+ const alloc = testing.allocator;
115+ const result = try stripAnsi(alloc, "\x1b[31m\x1b[1m\x1b[0m");
116+ defer alloc.free(result);
117+ try testing.expectEqualStrings("", result);
118+}