From 7fa6fffbca802897047186eed7b43faa3bcb87cf Mon Sep 17 00:00:00 2001 From: "zongyuan.li" Date: Wed, 3 Jun 2026 13:59:40 +0800 Subject: [PATCH 1/2] terminal: saturate cursor subtraction in resizeCols PageList.resize takes the .lt branch when columns shrink, which calls resizeWithoutReflow (mutating self.rows to the new smaller value) and then resizeCols with the original opts.cursor.y. When both axes shrink in one call and the cursor sits at or past the new bottom row, the expression `self.rows - c.y - 1` underflows and panics in safety builds. Use saturating subtraction; "remaining rows below cursor" is 0 once the cursor sits at or past the new bottom. --- src/terminal/PageList.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 8e5cd1934..dba3ca73f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1059,7 +1059,7 @@ fn resizeCols( break :cursor .{ .tracked_pin = c.pin orelse try self.trackPin(p), .untrack = c.pin == null, - .remaining_rows = self.rows - c.y - 1, + .remaining_rows = self.rows -| (c.y + 1), .wrapped_rows = wrapped, }; } else null; @@ -1192,7 +1192,7 @@ fn resizeCols( break :wrapped wrapped; }; - const current = self.rows - active_pt.active.y - 1; + const current = self.rows -| (active_pt.active.y + 1); var req_rows = c.remaining_rows; req_rows -|= wrapped -| c.wrapped_rows; From f135b950989cbf404ef6dc52affc5c2f9060bba3 Mon Sep 17 00:00:00 2001 From: Zongyuan Li Date: Thu, 4 Jun 2026 00:01:03 +0800 Subject: [PATCH 2/2] terminal: test shrinking both axes with cursor past new bottom Adds a PageList regression test exercising the underflow path fixed in 7fa6fffbc, and a libghostty-vt C API test mirroring the original repro through ghostty_terminal_resize. --- src/terminal/PageList.zig | 31 +++++++++++++++++++++++++++++++ src/terminal/c/terminal.zig | 24 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index dba3ca73f..5df2c2b91 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" { } } +test "PageList resize less rows and cols cursor at bottom" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + const cursor_pin = try s.trackPin(s.pin(.{ .active = .{ + .x = 0, + .y = s.rows - 1, + } }).?); + defer s.untrackPin(cursor_pin); + + // Shrink both axes such that the original cursor.y is strictly past the + // new row count, so resizeWithoutReflow leaves self.rows < c.y + 1. + try s.resize(.{ + .cols = 79, + .rows = 20, + .reflow = true, + .cursor = .{ .x = 0, .y = 23, .pin = cursor_pin }, + }); + try testing.expectEqual(@as(usize, 79), s.cols); + try testing.expectEqual(@as(usize, 20), s.rows); + + // remaining_rows saturates to 0, so the cursor lands on the new bottom row. + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = s.rows - 1, + } }, s.pointFromPin(.active, cursor_pin.*).?); +} + test "PageList resize (no reflow) more rows and less cols" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index 5a5db2d6b..3832be62b 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -1005,6 +1005,30 @@ test "resize invalid value" { try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18)); } +test "resize shrinks both axes with cursor at bottom" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // CSI 24;1H -> park the cursor on the bottom row (1-based). + const move = "\x1b[24;1H"; + vt_write(t, move, move.len); + + // Shrink both axes; pre-resize cursor.y sits past the new bottom row. + // Previously this underflowed in PageList.resizeCols. + try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16)); + try testing.expectEqual(79, t.?.terminal.cols); + try testing.expectEqual(23, t.?.terminal.rows); +} + test "mode_get and mode_set" { var t: Terminal = null; try testing.expectEqual(Result.success, new(