diff --git a/nix/profiles/development.nix b/nix/profiles/development.nix index 64660909..21385d81 100644 --- a/nix/profiles/development.nix +++ b/nix/profiles/development.nix @@ -103,6 +103,11 @@ in }; }; remoteSyncthingDevices = lib.filterAttrs (name: _: name != hostName) syncthingDevices; + # Backport the standalone scrollback restoration fix from + # https://github.com/neurosnap/zmx/pull/271 until it reaches a ZMX release. + zmx = pkgs.zmx.overrideAttrs (old: { + patches = (old.patches or [ ]) ++ [ ./development/zmx-preserve-scrollback-tail.patch ]; + }); # Syncthing uses decimal units; 5.4 GB keeps slightly more than 5 GiB free. syncthingMinDiskFree = { value = 5.4; @@ -163,7 +168,7 @@ in ''} ${config.home.homeDirectory}/.pi/agent/sessions/.stignore ''; packages = [ - pkgs.zmx + zmx ] ++ (with pkgs; [ curl diff --git a/nix/profiles/development/zmx-preserve-scrollback-tail.patch b/nix/profiles/development/zmx-preserve-scrollback-tail.patch new file mode 100644 index 00000000..cb05c148 --- /dev/null +++ b/nix/profiles/development/zmx-preserve-scrollback-tail.patch @@ -0,0 +1,93 @@ +From bd4d4c98c153ff343f71886bb646fa2771531a8d Mon Sep 17 00:00:00 2001 +From: Matthias Bertschy +Date: Mon, 28 Sep 2026 10:18:47 +0200 +Subject: [PATCH] fix: preserve scrollback tail during terminal restoration + +--- + src/util.zig | 52 +++++++++++++++++++++++++++++++++++++++++++++++----- + 1 file changed, 47 insertions(+), 5 deletions(-) + +diff --git a/src/util.zig b/src/util.zig +index e83ccdbb..40148d65 100644 +--- a/src/util.zig ++++ b/src/util.zig +@@ -782,6 +782,7 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin + if (had_synchronized_output) { + term.modes.set(.synchronized_output, false); + } ++ defer if (had_synchronized_output) term.modes.set(.synchronized_output, true); + + // If state contains color override, restore it. + writeColorOverrides(&builder.writer, term); +@@ -823,6 +824,14 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin + }; + } + ++ // The formatter leaves its final history rows in the viewport. Move ++ // every one into scrollback before clearing that viewport, including ++ // when the entire history is shorter than the terminal height. ++ for (0..pages.rows) |_| builder.writer.writeAll("\r\n") catch |err| { ++ std.log.warn("failed to preserve scrollback err={s}", .{@errorName(err)}); ++ return null; ++ }; ++ + // Clear visible screen after scrollback. \x1b[2J clears only the visible + // rows (not the scrollback buffer). \x1b[H homes the cursor. \x1b[0m resets + // SGR style so phase 1 styles don't bleed into phase 2. +@@ -882,11 +891,6 @@ pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Termin + const output = builder.writer.buffered(); + if (output.len == 0) return null; + +- // Restore the original synchronized_output mode before returning +- if (had_synchronized_output) { +- term.modes.set(.synchronized_output, true); +- } +- + return alloc.dupe(u8, output) catch |err| { + std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)}); + return null; +@@ -1791,6 +1795,44 @@ test "serializeTerminalState roundtrip preserves CUP-positioned markers" { + try expectCursorAt(&client, 15, 19); + } + ++test "serializeTerminalState preserves all scrollback rows before clearing viewport" { ++ const alloc = testing.allocator; ++ const io = testing.io; ++ for ([_]u16{ 1, 3, 24 }) |rows| { ++ for ([_]usize{ 1, 2, 23, 24, 25, 80 }) |lines| { ++ var term = try testCreateTerminal(alloc, io, 40, rows, ""); ++ defer term.deinit(alloc); ++ { ++ var stream = term.vtStream(); ++ defer stream.deinit(); ++ var buf: [32]u8 = undefined; ++ for (0..lines) |index| { ++ stream.nextSlice(try std.fmt.bufPrint(&buf, "ROW_{d}\r\n", .{index})); ++ } ++ stream.nextSlice("\x1b[2J\x1b[Hvisible"); ++ } ++ var restored = try serializeRoundtrip(alloc, io, &term); ++ defer restored.deinit(alloc); ++ const expected = try term.screens.active.dumpStringAlloc(alloc, .{ .screen = .{ .x = 0, .y = 0 } }); ++ defer alloc.free(expected); ++ const actual = try restored.screens.active.dumpStringAlloc(alloc, .{ .screen = .{ .x = 0, .y = 0 } }); ++ defer alloc.free(actual); ++ try testing.expectEqualStrings(expected, actual); ++ try expectCursorAt(&restored, term.screens.active.cursor.y, term.screens.active.cursor.x); ++ } ++ } ++} ++ ++test "serializeTerminalState allocation failure preserves synchronized output mode" { ++ const alloc = testing.allocator; ++ var term = try testCreateTerminal(alloc, testing.io, 40, 3, "one\r\ntwo\r\nthree\r\nfour\r\n"); ++ defer term.deinit(alloc); ++ term.modes.set(.synchronized_output, true); ++ var failing = testing.FailingAllocator.init(alloc, .{ .fail_index = 0 }); ++ try testing.expect(serializeTerminalState(failing.allocator(), &term) == null); ++ try testing.expect(term.modes.get(.synchronized_output)); ++} ++ + test "serializeTerminalState with scrollback preserves visible content" { + const alloc = testing.allocator; + const io = testing.io;