From 7837563ed6583d9b29565b926a0272daa251a887 Mon Sep 17 00:00:00 2001 From: "Claude Opus 4.8" Date: Fri, 5 Jun 2026 21:03:11 +0200 Subject: [PATCH] fix(terminal): guard wrap count when resize pushes cursor to scrollback In the column-shrink (.lt) branch of PageList.resize, resizeWithoutReflow lowers self.rows before resizeCols runs. Because the active area is anchored to the bottom, shrinking rows moves the active-area top down; a cursor near the top of the old active area then ends up above the new active area (in scrollback). resizeCols counts wrap continuations from the cursor pin up to the active-area top via a .left_up rowIterator. When the cursor pin is above the limit, the range is reversed and the iterator's order assertion fires (SIGABRT in debug; silently iterates empty in release). Count zero wraps when the cursor pin is above the active area, mirroring the post-reflow preserved-cursor block which already no-ops for a non-active cursor. Add a regression test. --- src/terminal/PageList.zig | 62 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 5df2c2b91..2888010b2 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1047,6 +1047,16 @@ fn resizeCols( const wrapped = wrapped: { var wrapped: usize = 0; + // If shrinking rows (in the .lt branch of resize, rows shrink + // before we get here) pushed the cursor pin above the new active + // area, there are no rows to count and iterating .left_up toward + // the active-area top would be an invalid (reversed) range. The + // preserved-cursor growth below already no-ops for a cursor that + // isn't in the active area, so we just count zero here. + if (active_pin) |ap| { + if (p.before(ap)) break :wrapped 0; + } + var row_it = p.rowIterator(.left_up, active_pin); while (row_it.next()) |next| { const row = next.rowAndCell().row; @@ -10859,6 +10869,58 @@ test "PageList resize less rows and cols cursor at bottom" { } }, s.pointFromPin(.active, cursor_pin.*).?); } +test "PageList resize less rows and cols cursor near top pushed to scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Fill every active row with non-blank content so that shrinking rows + // can't trim trailing blank lines and instead pushes the top rows into + // scrollback. + { + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cells = p.node.data.getCells(rac.row); + for (cells, 0..) |*cell, x| cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast('A' + (x % 26)) }, + }; + } + } + + // Cursor near the top of the active area. After we shrink rows the active + // area top moves down past this pin, so it ends up in scrollback. + const cursor_pin = try s.trackPin(s.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?); + defer s.untrackPin(cursor_pin); + + // Shrink both axes with reflow. resizeWithoutReflow shrinks self.rows + // first, leaving the cursor pin above the new active area, then resizeCols + // walks .left_up from the cursor pin toward the active-area top. + try s.resize(.{ + .cols = 79, + .rows = 20, + .reflow = true, + .cursor = .{ .x = 0, .y = 0, .pin = cursor_pin }, + }); + try testing.expectEqual(@as(usize, 79), s.cols); + try testing.expectEqual(@as(usize, 20), s.rows); + + // The active area is anchored to the bottom, so shrinking rows pushed the + // top-of-screen cursor into scrollback: it no longer resolves to an + // active-area coordinate, but it remains a valid screen pin. + try testing.expect(s.pointFromPin(.active, cursor_pin.*) == null); + try testing.expect(s.pointFromPin(.screen, cursor_pin.*) != null); + + // Integrity must hold after the resize. + s.assertIntegrity(); +} + test "PageList resize (no reflow) more rows and less cols" { const testing = std.testing; const alloc = testing.allocator;