- commit
- bbbe245
- parent
- 6eea0f6
- author
- Shravan Sunder
- date
- 2026-04-14 15:08:36 -0400 EDT
fix: rewrite OSC 133;A with redraw=0 to prevent prompt loss on resize (#112) When zmx forwards shell output containing OSC 133;A (prompt start) to the outer terminal, the terminal sets shell_redraws_prompt=true. On resize, the terminal clears prompt rows expecting the shell to redraw them. But the shell's redraw goes through zmx's IPC relay chain with cursor coordinates relative to the inner PTY state, causing a cursor desync that makes the prompt invisible. Fix: rewrite OSC 133;A to include redraw=0 before forwarding. This tells the outer terminal that the process on its PTY (zmx client) cannot redraw prompts, so it should leave prompt rows intact on resize. This is a standard Kitty protocol extension. Also disables shell_redraws_prompt on the daemon's internal ghostty_vt terminal during resize to prevent snapshot state corruption. Fixes #111. Likely also fixes #99.
2 files changed,
+161,
-3
+22,
-3
1@@ -697,8 +697,12 @@ const Daemon = struct {
2 );
3 if (util.serializeTerminalState(self.alloc, term)) |term_output| {
4 std.log.debug("serialize terminal state", .{});
5+ // Rewrite OSC 133;A to include redraw=0 so the outer terminal
6+ // does not clear prompt lines on resize (issue #111).
7+ const restore_data = util.rewritePromptRedraw(self.alloc, term_output) orelse term_output;
8 defer self.alloc.free(term_output);
9- ipc.appendMessage(self.alloc, &client.write_buf, .Output, term_output) catch |err| {
10+ defer if (restore_data.ptr != term_output.ptr) self.alloc.free(restore_data);
11+ ipc.appendMessage(self.alloc, &client.write_buf, .Output, restore_data) catch |err| {
12 std.log.warn(
13 "failed to buffer terminal state for client err={s}",
14 .{@errorName(err)},
15@@ -723,6 +727,13 @@ const Daemon = struct {
16 .ws_ypixel = 0,
17 };
18 _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
19+ // Disable prompt_redraw before resize. The daemon's internal terminal
20+ // would otherwise clear prompt lines expecting the shell to redraw them,
21+ // but the shell's redraw goes to the PTY (forwarded to clients), not to
22+ // this daemon terminal. The clearing corrupts the daemon's snapshot state.
23+ const saved_prompt_redraw = term.flags.shell_redraws_prompt;
24+ term.flags.shell_redraws_prompt = .false;
25+ defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
26 try term.resize(self.alloc, resize.cols, resize.rows);
27
28 // Mark that we've had a client init, so subsequent clients get terminal state
29@@ -754,6 +765,10 @@ const Daemon = struct {
30 .ws_ypixel = 0,
31 };
32 _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
33+ // Disable prompt_redraw before resize (same rationale as handleInit).
34+ const saved_prompt_redraw = term.flags.shell_redraws_prompt;
35+ term.flags.shell_redraws_prompt = .false;
36+ defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
37 try term.resize(self.alloc, resize.cols, resize.rows);
38 std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
39 }
40@@ -1815,9 +1830,13 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
41 }
42 }
43
44- // Broadcast data to all clients
45+ // Broadcast data to all clients.
46+ // Rewrite OSC 133;A to include redraw=0 so the outer terminal
47+ // does not clear prompt lines on resize (issue #111).
48+ const broadcast_data = util.rewritePromptRedraw(daemon.alloc, buf[0..n]) orelse buf[0..n];
49+ defer if (broadcast_data.ptr != buf[0..n].ptr) daemon.alloc.free(broadcast_data);
50 for (daemon.clients.items) |client| {
51- ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| {
52+ ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, broadcast_data) catch |err| {
53 std.log.warn(
54 "failed to buffer output for client err={s}",
55 .{@errorName(err)},
+139,
-0
1@@ -184,6 +184,145 @@ fn matchSeq(data: []const u8, seq: []const u8) bool {
2 return std.mem.eql(u8, data[0..seq.len], seq);
3 }
4
5+/// OSC 133;A (prompt start) marker.
6+const OSC_133_A = "\x1b]133;A";
7+
8+/// Rewrite OSC 133;A sequences to include `redraw=0`, which tells the outer
9+/// terminal not to clear prompt lines on resize. This is necessary because
10+/// zmx sits between the shell and the outer terminal: from the outer terminal's
11+/// perspective, the foreground process (zmx client) cannot redraw prompts.
12+/// Without this, the outer terminal clears the prompt on resize expecting the
13+/// shell to redraw it, but the shell's redraw goes through zmx's IPC path with
14+/// cursor coordinates relative to the inner PTY, causing a cursor desync that
15+/// makes the prompt invisible.
16+/// See: https://github.com/neurosnap/zmx/issues/111
17+pub fn rewritePromptRedraw(alloc: std.mem.Allocator, data: []const u8) ?[]const u8 {
18+ // Quick scan: is there any OSC 133;A in this chunk?
19+ if (std.mem.indexOf(u8, data, OSC_133_A) == null) return null;
20+
21+ var result = std.ArrayList(u8).initCapacity(alloc, data.len + 200) catch return null;
22+ errdefer result.deinit(alloc);
23+ result.appendSlice(alloc, data) catch return null;
24+
25+ // Work backwards so index shifts don't invalidate later positions.
26+ var search_from: usize = result.items.len;
27+ while (search_from > 0) {
28+ const haystack = result.items[0..search_from];
29+ const pos = std.mem.lastIndexOf(u8, haystack, OSC_133_A) orelse break;
30+ search_from = pos;
31+
32+ const after = pos + OSC_133_A.len;
33+ if (after >= result.items.len) continue;
34+
35+ // Find the string terminator (BEL \x07 or ST \x1b\\).
36+ var term_pos: ?usize = null;
37+ var j = after;
38+ while (j < result.items.len) : (j += 1) {
39+ if (result.items[j] == '\x07') {
40+ term_pos = j;
41+ break;
42+ }
43+ if (result.items[j] == '\x1b' and j + 1 < result.items.len and result.items[j + 1] == '\\') {
44+ term_pos = j;
45+ break;
46+ }
47+ }
48+ const end = term_pos orelse continue;
49+
50+ // Check the parameter region between OSC_133_A and the terminator.
51+ const params = result.items[after..end];
52+
53+ // If redraw=0 already present, skip.
54+ if (std.mem.indexOf(u8, params, "redraw=0") != null) continue;
55+
56+ // If redraw= exists with a different value, replace it.
57+ if (std.mem.indexOf(u8, params, "redraw=")) |rdw_offset| {
58+ const abs_rdw = after + rdw_offset;
59+ const value_start = abs_rdw + "redraw=".len;
60+ var value_end = value_start;
61+ while (value_end < end and result.items[value_end] != ';') : (value_end += 1) {}
62+ result.replaceRange(alloc, value_start, value_end - value_start, "0") catch return null;
63+ continue;
64+ }
65+
66+ // No redraw= present. Insert ;redraw=0 before the terminator.
67+ result.replaceRange(alloc, end, 0, ";redraw=0") catch return null;
68+ }
69+
70+ // If nothing changed, free and return null.
71+ if (std.mem.eql(u8, result.items, data)) {
72+ result.deinit(alloc);
73+ return null;
74+ }
75+
76+ return result.toOwnedSlice(alloc) catch null;
77+}
78+
79+test "rewritePromptRedraw: no OSC 133;A returns null" {
80+ const result = rewritePromptRedraw(std.testing.allocator, "hello world");
81+ try std.testing.expect(result == null);
82+}
83+
84+test "rewritePromptRedraw: injects redraw=0 with BEL terminator" {
85+ const input = "\x1b]133;A\x07";
86+ const result = rewritePromptRedraw(std.testing.allocator, input).?;
87+ defer std.testing.allocator.free(result);
88+ try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
89+}
90+
91+test "rewritePromptRedraw: injects redraw=0 with ST terminator" {
92+ const input = "\x1b]133;A\x1b\\";
93+ const result = rewritePromptRedraw(std.testing.allocator, input).?;
94+ defer std.testing.allocator.free(result);
95+ try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x1b\\", result);
96+}
97+
98+test "rewritePromptRedraw: replaces existing redraw=1" {
99+ const input = "\x1b]133;A;redraw=1\x07";
100+ const result = rewritePromptRedraw(std.testing.allocator, input).?;
101+ defer std.testing.allocator.free(result);
102+ try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
103+}
104+
105+test "rewritePromptRedraw: replaces existing redraw=last" {
106+ const input = "\x1b]133;A;redraw=last\x07";
107+ const result = rewritePromptRedraw(std.testing.allocator, input).?;
108+ defer std.testing.allocator.free(result);
109+ try std.testing.expectEqualStrings("\x1b]133;A;redraw=0\x07", result);
110+}
111+
112+test "rewritePromptRedraw: preserves redraw=0 (no-op)" {
113+ const result = rewritePromptRedraw(std.testing.allocator, "\x1b]133;A;redraw=0\x07");
114+ try std.testing.expect(result == null);
115+}
116+
117+test "rewritePromptRedraw: preserves other parameters" {
118+ const input = "\x1b]133;A;aid=14;cl=line\x07";
119+ const result = rewritePromptRedraw(std.testing.allocator, input).?;
120+ defer std.testing.allocator.free(result);
121+ try std.testing.expectEqualStrings("\x1b]133;A;aid=14;cl=line;redraw=0\x07", result);
122+}
123+
124+test "rewritePromptRedraw: handles multiple markers" {
125+ const input = "before\x1b]133;A\x07middle\x1b]133;A;redraw=1\x07after";
126+ const result = rewritePromptRedraw(std.testing.allocator, input).?;
127+ defer std.testing.allocator.free(result);
128+ try std.testing.expectEqualStrings("before\x1b]133;A;redraw=0\x07middle\x1b]133;A;redraw=0\x07after", result);
129+}
130+
131+test "rewritePromptRedraw: does not touch OSC 133;B or 133;C" {
132+ const input = "\x1b]133;B\x07\x1b]133;C\x07";
133+ const result = rewritePromptRedraw(std.testing.allocator, input);
134+ try std.testing.expect(result == null);
135+}
136+
137+test "rewritePromptRedraw: embedded in larger output" {
138+ const input = "some output\r\n\x1b]133;A\x07prompt$ \x1b]133;B\x07";
139+ const result = rewritePromptRedraw(std.testing.allocator, input).?;
140+ defer std.testing.allocator.free(result);
141+ try std.testing.expectEqualStrings("some output\r\n\x1b]133;A;redraw=0\x07prompt$ \x1b]133;B\x07", result);
142+}
143+
144 pub fn findTaskExitMarker(output: []const u8) ?u8 {
145 const marker = "ZMX_TASK_COMPLETED:";
146