From a78e2173921b12196a12ab8399cc3d1a213bc733 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 16 Aug 2026 14:03:18 -0700 Subject: [PATCH 1/4] config: preserve bytes in hex escapes Fixes #13855 Make the config string parser preserve hexadecimal escapes as bytes. Previously all escaped values were encoded as Unicode codepoints. (cherry picked from commit 29b82dd80c46de16f5aaa405e51b2148831e2061) --- src/config/string.zig | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/config/string.zig b/src/config/string.zig index 45079937321..8d4fc7b4551 100644 --- a/src/config/string.zig +++ b/src/config/string.zig @@ -20,16 +20,23 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 { continue; } + // Zig's \xNN syntax represents a single byte, while the other + // escape sequences represent Unicode codepoints. + const is_byte_escape = src_i + 1 < bytes.len and + bytes[src_i + 1] == 'x'; + // Parse the escape sequence switch (std.zig.string_literal.parseEscapeSequence( bytes, &src_i, )) { .failure => return error.InvalidString, - .success => |cp| dst_i += try std.unicode.utf8Encode( - cp, - out[dst_i..], - ), + .success => |cp| if (is_byte_escape) { + out[dst_i] = @intCast(cp); + dst_i += 1; + } else { + dst_i += try std.unicode.utf8Encode(cp, out[dst_i..]); + }, } } @@ -100,6 +107,15 @@ test "parse: escapes" { } } +test "parse: hex escapes are bytes" { + var buf: [128]u8 = undefined; + const result = try parse( + &buf, + "\\xe6\\x97\\xa5\\xe6\\x9c\\xac\\xe8\\xaa\\x9e", + ); + try std.testing.expectEqualStrings("日本語", result); +} + test "codepointIterator: empty" { var it = codepointIterator(""); try std.testing.expectEqual(null, try it.next()); From e168fd31c0fc5893cdac933dc665307b3a760554 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 16:50:03 -0700 Subject: [PATCH 2/4] config: test that escaped initial input round-trips its bytes The embedded apprt escapes `initial_input` with std.zig.stringEscape and stores it as a raw input value, which termio parses back with config/string.zig. Before the preceding cherry-pick, every non-ASCII byte came back as its Latin-1 codepoint re-encoded as UTF-8, so text a host typed as startup input reached the child as mojibake (manaflow-ai/cmux#12915). Cover the issue's OSC title command, Japanese, emoji with ZWJ sequences, combining marks and invalid UTF-8. Co-Authored-By: Claude Opus 5.5 --- src/config/io.zig | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/config/io.zig b/src/config/io.zig index 056dc87446d..c984b81034f 100644 --- a/src/config/io.zig +++ b/src/config/io.zig @@ -150,6 +150,30 @@ pub const ReadableIO = union(enum) { try v.formatEntry(formatterpkg.entryFormatter("a", &buf.writer)); try std.testing.expectEqualSlices(u8, "a = raw:foo\n", buf.written()); } + + // The embedded apprt stores initial input as a Zig-escaped raw + // value, so parsing it must give back the exact input bytes. + test "cloneParsed restores Zig-escaped bytes" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + const inputs = [_][]const u8{ + "printf '\x1b]0;라마바OSC테스트\x07'\r", + "日本語のタイトル", + "\u{1F680} \u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", + "e\u{301}cole \u{1112}\u{1161}\u{11AB}", + "invalid \xff\xfe\x80 bytes", + }; + for (inputs) |input| { + var escaped: std.Io.Writer.Allocating = .init(alloc); + try std.zig.stringEscape(input, &escaped.writer); + const v: Self = .{ .raw = try escaped.toOwnedSliceSentinel(0) }; + const parsed = try v.cloneParsed(alloc); + try testing.expectEqualSlices(u8, input, parsed.raw); + } + } }; pub const RepeatableReadableIO = struct { From 9961d09be3faf962b6e50541c3b709d5cd234472 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 17:45:12 -0700 Subject: [PATCH 3/4] fix: preserve styled blank VT replay rows Cloud replay must serialize a full-width background row even when no cell has text. Keep text-free rows collapsed for plain output, but treat styled cells as content for VT and HTML formatting so replay retains the compositor background. --- src/terminal/formatter.zig | 47 +++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index e2332113773..9a8e7bcbaff 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -1143,9 +1143,15 @@ pub const PageFormatter = struct { }; // If this row is blank, accumulate to avoid a bunch of extra - // work later. If it isn't blank, make sure we dump all our - // blanks. - if (!Cell.hasTextAny(cells_subset)) { + // work later. A styled blank row is content for styled output: + // dropping it loses full-width background bands during replay. + const has_styled_content = if (formatStyled(self.opts.emit)) styled: { + for (cells_subset) |cell| { + if (!cell.isEmpty() or cell.hasStyling()) break :styled true; + } + break :styled false; + } else false; + if (!Cell.hasTextAny(cells_subset) and !has_styled_content) { blank_rows += 1; continue; } @@ -6718,6 +6724,41 @@ test "Page VT background color on trailing blank cells" { try testing.expect(has_red_bg_line1); } +test "Page VT preserves a fully styled blank row" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(io, alloc, .{ + .cols = 20, + .rows = 5, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + // Erase the complete first row with a background color and then put text + // on the next row. The first row has no text cells, but every cell carries + // the background style that a replay must preserve. + s.nextSlice("\x1b[41m\x1b[2K\x1b[0m\r\nline2"); + + const page = t.screens.active.pages.pages.last.?.page(); + var opts: Options = .vt; + opts.trim = false; + var formatter: PageFormatter = .init(page, opts); + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + const first_break = std.mem.indexOf(u8, output, "\r\n") orelse { + return error.TestUnexpectedResult; + }; + try testing.expect(std.mem.indexOf(u8, output[0..first_break], "\x1b[41m") != null); + try testing.expectEqual(@as(usize, 20), std.mem.count(u8, output[0..first_break], " ")); +} + test "Page HTML with hyperlinks" { const testing = std.testing; const alloc = testing.allocator; From 9d8d4031930ea347bea349681604f841f707f615 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:38:53 -0700 Subject: [PATCH 4/4] test: expect the indexed background form for a styled blank row SGR 41 is stored as palette background 1, and the VT formatter writes palette colors as 48;5;N. The row itself was preserved (red background, 20 cells); only the assertion looked for the 41 spelling, so the test failed on every run and blocked GhosttyKit publication in cmux. --- src/terminal/formatter.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 9a8e7bcbaff..8b87f2b50a7 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -6755,7 +6755,9 @@ test "Page VT preserves a fully styled blank row" { const first_break = std.mem.indexOf(u8, output, "\r\n") orelse { return error.TestUnexpectedResult; }; - try testing.expect(std.mem.indexOf(u8, output[0..first_break], "\x1b[41m") != null); + // SGR 41 is stored as palette background 1, which the VT formatter + // writes in its indexed form. + try testing.expect(std.mem.indexOf(u8, output[0..first_break], "\x1b[48;5;1m") != null); try testing.expectEqual(@as(usize, 20), std.mem.count(u8, output[0..first_break], " ")); }