fix: preserve ConPTY live buffer during pane resize

refs #5108
This commit is contained in:
Jonathan Liebig
2026-10-18 20:56:59 +02:00
parent 143bf5fd47
commit a97964c792
13 changed files with 749 additions and 6 deletions
+3 -1
View File
@@ -3207,7 +3207,9 @@ pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG:
#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."]
pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE:
GhosttyTerminalOption = 41;
#[doc = " Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables."]
#[doc = " Reflow a finite ConPTY buffer separately from history. bool*; NULL disables."]
pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CONPTY_RESIZE: GhosttyTerminalOption = 42;
#[doc = " Reflow a finite ConPTY buffer separately from history. bool*; NULL disables."]
pub const GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_MAX_VALUE: GhosttyTerminalOption = 2147483647;
#[doc = " Terminal option identifiers.\n\n These values are used with ghostty_terminal_set() to configure\n terminal callbacks and associated state.\n\n @ingroup terminal"]
pub type GhosttyTerminalOption = ::std::os::raw::c_int;
+13
View File
@@ -1043,6 +1043,19 @@ impl Terminal {
Ok(out.map(Into::into))
}
#[cfg(windows)]
pub fn set_conpty_resize(&mut self, enabled: bool) -> Result<(), Error> {
// SAFETY: self.raw is valid and the option takes a pointer to bool.
unsafe {
ffi::ghostty_terminal_set(
self.raw,
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CONPTY_RESIZE,
(&enabled as *const bool).cast(),
)
.into_result()
}
}
pub fn resize(
&mut self,
cols: u16,
+2
View File
@@ -2704,6 +2704,8 @@ impl PaneRuntime {
let (response_tx, _response_rx) = mpsc::channel::<Bytes>(1);
let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes)
.map_err(|e| std::io::Error::other(e.to_string()))?;
#[cfg(windows)]
crate::platform::configure_conpty_terminal(&mut terminal)?;
if crate::kitty_graphics::is_enabled() {
terminal
.enable_kitty_graphics()
+49
View File
@@ -6024,6 +6024,55 @@ mod tests {
);
}
#[cfg(windows)]
#[test]
fn windows_conpty_resize_preserves_live_buffer_and_scrollback() {
let (tx, _rx) = mpsc::channel(4);
let mut bytes = Vec::new();
for line in 0..3 {
bytes.extend_from_slice(format!("L{line:04} {}\r\n", "x".repeat(121)).as_bytes());
}
bytes.extend_from_slice(b"M001!");
let mut terminal = crate::ghostty::Terminal::new(25, 17, 100_000).unwrap();
crate::platform::configure_conpty_terminal(&mut terminal).unwrap();
terminal.write(&bytes);
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
pane.resize(17, 33, 8, 16);
// Native ReadConsoleOutputCharacterW capture: the live first line
// retains 77 characters after its first 50 scrolled into history.
let mut expected = vec!["x".repeat(33), "x".repeat(33), "x".repeat(11)];
for line in 1..3 {
expected.push(format!("L{line:04} {}", "x".repeat(27)));
expected.extend(["x".repeat(33), "x".repeat(33), "x".repeat(28)]);
}
expected.push("M001!".to_owned());
assert_eq!(pane.visible_text(), expected.join("\n") + "\n");
{
let core = pane.core.lock().unwrap();
assert_eq!(core.terminal.cursor_y().unwrap(), 11);
let total = core.terminal.scrollbar().unwrap().total;
let history = core
.terminal
.read_text_screen((0, 0), (32, (total - 1) as u32), false)
.unwrap();
assert!(history
.replace('\n', "")
.contains(&format!("L0000 {}", "x".repeat(121))));
}
pane.process_pty_bytes(PaneId::from_raw(1), 0, b"\x1b[12;1HP> HELLO\x1b[K", &tx);
expected[11] = "P> HELLO".to_owned();
assert_eq!(pane.visible_text(), expected.join("\n") + "\n");
assert!(!pane.visible_text().contains("M001!"));
let mut unchanged = crate::ghostty::Terminal::new(25, 17, 100_000).unwrap();
unchanged.write(&bytes);
unchanged.resize(33, 17, 8, 16).unwrap();
assert_eq!(unchanged.cursor_y().unwrap(), 12);
}
#[test]
fn resize_reflow_keeps_scrolled_viewport_and_bottom_detection_sane() {
let (tx, _rx) = mpsc::channel(4);
+8
View File
@@ -234,6 +234,14 @@ pub(crate) fn local_server_connection_error(error: std::io::Error) -> std::io::E
)
}
pub(crate) fn configure_conpty_terminal(
terminal: &mut crate::ghostty::Terminal,
) -> std::io::Result<()> {
terminal
.set_conpty_resize(true)
.map_err(|error| std::io::Error::other(error.to_string()))
}
pub(crate) fn windows_virtual_terminal_input_active() -> bool {
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::System::Console::{
+51
View File
@@ -272,3 +272,54 @@ verification:
(cd vendor/libghostty-vt && zig build test-lib-vt -Dtest-filter='cursorAbsolute keeps the hyperlink when style migration splits the page')
just maintenance-test
```
## 0009 ConPTY finite-buffer resize
status: active
patch: `vendor/patches/libghostty-vt/0009-conpty-finite-buffer-resize.patch`
herdr issue: https://github.com/herdrdev/herdr/issues/5108
upstream discussion: none
upstream pr: none
vendored base: `44f2a44df7e8c4a0c6df3f7d872ef3d7ead88e51`
local files:
- `vendor/libghostty-vt/include/ghostty/vt/terminal.h`
- `vendor/libghostty-vt/src/terminal/c/terminal.zig`
- `vendor/libghostty-vt/src/terminal/Terminal.zig`
- `vendor/libghostty-vt/src/terminal/Screen.zig`
- `vendor/libghostty-vt/src/terminal/PageList.zig`
reason: Windows ConPTY reflows only its finite live buffer, while ordinary
Ghostty reflow joins archived scrollback back into the active grid. Widening a
25x17 pane containing wrapped output to 33x17 reproducibly puts the child cursor
on row 11 and the pane cursor on row 12. Subsequent PSReadLine positioning then
overwrites output and leaves a stale prompt. The opt-in policy keeps archived
characters outside live-buffer reflow, appends blank rows on height growth, and
matches ConPTY's grow-height/reflow-width/shrink-height order and saved cursor
coordinates. The default policy and alternate-screen non-reflow are unchanged.
A split wrapped line remains archived as a separate fragment rather than being
rejoined to the live suffix during later resizes.
remove when: upstream offers a finite-buffer resize policy matching the native
ConPTY cursor and live rows without discarding archived text, or Herdr no longer
uses a ConPTY-backed terminal grid.
verification:
```sh
just test-one windows_conpty_resize_preserves_live_buffer_and_scrollback
(cd vendor/libghostty-vt && zig build test-lib-vt -Dtest-filter='ConPTY resize')
just check
just bench-render-scale
```
Native Windows verification must compare `ReadConsoleOutputCharacterW` and
cursor coordinates with the candidate pane for width growth/shrink, height
growth/shrink, and simultaneous changes. Repeat the wrapped-output PSReadLine
typing reproduction in an isolated named session using the packaged ConPTY runtime.
+2
View File
@@ -1586,6 +1586,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40,
/** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41,
/** Reflow a finite ConPTY buffer separately from history. bool*; NULL disables. */
GHOSTTY_TERMINAL_OPT_CONPTY_RESIZE = 42,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
+65 -3
View File
@@ -1230,6 +1230,9 @@ pub const Resize = struct {
/// be truncated if the new size is smaller than the old size.
reflow: bool = true,
/// Reflow the finite ConPTY buffer independently from archived history.
conpty_resize: bool = false,
/// Set this to the current cursor position in the active area. Some
/// resize/reflow behavior depends on the cursor position.
cursor: ?Cursor = null,
@@ -1276,6 +1279,13 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
return;
}
const active_top = if (opts.conpty_resize and
(opts.cols orelse self.cols) != self.cols)
try self.trackPin(self.pin(.{ .active = .{} }).?)
else
null;
defer if (active_top) |p| self.untrackPin(p);
// Recalculate our minimum limits. This allows grow to work properly when
// increasing beyond the explicit limits to fit the active area.
const old_limits = self.limits;
@@ -1289,13 +1299,25 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
// only rows change, reflow is impossible. So we change our behavior based
// on the change of columns.
const cols = opts.cols orelse self.cols;
switch (std.math.order(cols, self.cols)) {
if (opts.conpty_resize) {
// ConPTY grows its buffer before reflow and shrinks it afterward.
if (opts.rows) |rows| {
if (rows > self.rows) try self.resizeWithoutReflow(.{
.rows = rows,
.conpty_resize = true,
});
}
if (cols != self.cols) try self.resizeCols(cols, opts.cursor, active_top);
if (opts.rows) |rows| {
if (rows < self.rows) try self.resizeWithoutReflow(.{ .rows = rows });
}
} else switch (std.math.order(cols, self.cols)) {
.eq => try self.resizeWithoutReflow(opts),
.gt => {
// We grow rows after cols so that we can do our unwrapping/reflow
// before we do a no-reflow grow.
try self.resizeCols(cols, opts.cursor);
try self.resizeCols(cols, opts.cursor, null);
try self.resizeWithoutReflow(opts);
},
@@ -1307,7 +1329,7 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
copy.cols = self.cols;
break :opts copy;
});
try self.resizeCols(cols, opts.cursor);
try self.resizeCols(cols, opts.cursor, null);
},
}
@@ -1332,8 +1354,11 @@ fn resizeCols(
self: *PageList,
cols: size.CellCountInt,
cursor: ?Resize.Cursor,
active_top: ?*Pin,
) Allocator.Error!void {
assert(cols != self.cols);
const old_active_top = if (active_top) |p| p.* else null;
const has_history = self.total_rows > self.rows;
// If we have a cursor position (x,y), then we try under any col resizing
// to keep the same number remaining active rows beneath it. This is a
@@ -1456,6 +1481,13 @@ fn resizeCols(
{
var reflow_cursor: ReflowCursor = .init(first_rewritten_node);
while (it.next()) |row| {
if (old_active_top) |boundary| {
if (has_history and row.node == boundary.node and row.y == boundary.y) {
// Archived text must not rejoin a live partial line.
reflow_cursor.page_row.wrap = false;
reflow_cursor.new_rows = @max(reflow_cursor.new_rows, 1);
}
}
try reflow_cursor.reflowRow(
self,
row,
@@ -1485,6 +1517,22 @@ fn resizeCols(
self.total_rows = reflow_cursor.total_rows;
}
if (active_top) |p| {
// Padding temporarily leaves a pinned viewport shorter than self.rows.
self.pauseIntegrityChecks(true);
defer self.pauseIntegrityChecks(false);
const start = self.pointFromPin(.screen, p.*).?.screen.y;
const missing = self.rows -| (self.total_rows - start);
for (0..missing) |_| _ = try self.grow();
switch (self.viewport) {
.pin => if (self.pinIsActive(self.viewport_pin.*)) {
self.viewport = .active;
},
.active, .top => {},
}
return;
}
// If our total rows is less than our active rows, we need to grow.
// This can happen if you're growing columns such that enough active
// rows unwrap that we no longer have enough.
@@ -2851,6 +2899,20 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void {
// Making rows larger we adjust our row count, and then grow
// to the row count.
.gt => gt: {
if (opts.conpty_resize) {
const delta = rows - self.rows;
self.pauseIntegrityChecks(true);
defer self.pauseIntegrityChecks(false);
self.rows = rows;
for (0..delta) |_| _ = try self.grow();
switch (self.viewport) {
.pin => if (self.pinIsActive(self.viewport_pin.*)) {
self.viewport = .active;
},
.active, .top => {},
}
break :gt;
}
// If our rows increased and our cursor is NOT at the bottom,
// we want to try to preserve the y value of the old cursor.
// In other words, we don't want to "pull down" scrollback.
+6
View File
@@ -2018,6 +2018,8 @@ pub const Resize = struct {
/// lost from the top of the scrollback.
reflow: bool = true,
conpty_resize: bool = false,
/// Set this to enable prompt redraw on resize. This signals
/// that the running program can redraw the prompt if the cursor is
/// currently at a prompt. This detects OSC133 prompts lines and clears
@@ -2043,6 +2045,8 @@ pub inline fn resize(
// modify its X/Y for reflow. Do this before changing any state since
// tracking the pin can fail.
const saved_cursor_pin: ?*Pin = saved_cursor: {
// ConPTY retains saved cursor coordinates across buffer reflow.
if (opts.conpty_resize) break :saved_cursor null;
const sc = self.saved_cursor orelse break :saved_cursor null;
const pin = self.pages.pin(.{ .active = .{
.x = sc.x,
@@ -2114,6 +2118,7 @@ pub inline fn resize(
.rows = opts.rows,
.cols = opts.cols,
.reflow = opts.reflow,
.conpty_resize = opts.conpty_resize,
.cursor = .{
.x = self.cursor.x,
.y = self.cursor.y,
@@ -2136,6 +2141,7 @@ pub inline fn resize(
// If our cursor was updated, we do a full reload so all our cursor
// state is correct.
self.cursorReload();
if (opts.conpty_resize) self.cursor.pending_wrap = false;
// Clear any redrawable prompt after the fallible resize but before
// restoring the cursor style and hyperlink, so cleared cells retain the
+151
View File
@@ -79,6 +79,9 @@ colors: Colors,
/// char CSI (ESC [ <n> b).
previous_char: ?u21 = null,
/// Host resize policy, retained across terminal resets.
conpty_resize: bool = false,
/// The modes that this terminal currently has active.
modes: modespkg.ModeState = .{},
@@ -4088,6 +4091,7 @@ pub fn resize(
.cols = opts.cols,
.rows = opts.rows,
.reflow = self.modes.get(.wraparound),
.conpty_resize = self.conpty_resize,
.prompt_redraw = self.flags.shell_redraws_prompt,
});
@@ -15875,6 +15879,153 @@ test "Terminal: resize with high unique style per cell with wrapping" {
try t.resize(alloc, .{ .cols = 60, .rows = 30 });
}
test "Terminal: ConPTY resize preserves the finite buffer across both axes" {
const alloc = testing.allocator;
// ReadConsoleOutputCharacterW captures, with three 127-character lines.
const cases = [_][6]u16{
.{ 25, 17, 33, 17, 11, 1 },
.{ 33, 17, 25, 17, 16, 1 },
.{ 25, 17, 25, 23, 16, 1 },
.{ 25, 23, 25, 17, 16, 1 },
.{ 25, 17, 33, 23, 11, 1 },
.{ 33, 23, 25, 17, 16, 1 },
.{ 25, 23, 33, 17, 12, 0 },
.{ 33, 17, 25, 23, 18, 0 },
};
for (cases) |case| {
var t = try init(testing.io, alloc, .{ .cols = case[0], .rows = case[1] });
defer t.deinit(alloc);
t.conpty_resize = true;
t.fullReset();
for (0..3) |line| {
var buf: [127]u8 = undefined;
try t.printString(try std.fmt.bufPrint(&buf, "L{d:0>4} {s}", .{ line, "x" ** 121 }));
t.carriageReturn();
try t.linefeed();
}
try t.printString("M001!");
try t.resize(alloc, .{ .cols = case[2], .rows = case[3] });
try testing.expectEqual(case[4], t.screens.active.cursor.y);
const body = if (case[2] == 33)
"x" ** 27 ++ "\n" ++ "x" ** 33 ++ "\n" ++ "x" ** 33 ++ "\n" ++ "x" ** 28 ++ "\n"
else
"x" ** 19 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\nxx\n";
const prefix = if (case[5] == 0)
try std.fmt.allocPrint(alloc, "L0000 {s}", .{body})
else if (case[2] == 33)
try alloc.dupe(u8, "x" ** 33 ++ "\n" ++ "x" ** 33 ++ "\n" ++ "x" ** 11 ++ "\n")
else
try alloc.dupe(u8, "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\nxx\n");
defer alloc.free(prefix);
const expected = try std.fmt.allocPrint(alloc, "{s}L0001 {s}L0002 {s}M001!", .{ prefix, body, body });
defer alloc.free(expected);
const actual = try t.screens.active.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(actual);
try testing.expectEqualStrings(expected, actual);
}
}
test "Terminal: ConPTY resize clears pending wrap only when geometry changes" {
const alloc = testing.allocator;
const cases = [_][4]u16{
.{ 33, 17, 29, 0 },
.{ 20, 17, 9, 1 },
.{ 25, 23, 4, 1 },
.{ 25, 12, 4, 1 },
.{ 33, 23, 29, 0 },
.{ 25, 17, 5, 1 },
};
for (cases) |case| {
var t = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
defer t.deinit(alloc);
t.conpty_resize = true;
try t.printString("x" ** 20 ++ "M001!");
try testing.expect(t.screens.active.cursor.pending_wrap);
try t.resize(alloc, .{ .cols = case[0], .rows = case[1] });
try t.printString("M002!");
try testing.expectEqual(case[2], t.screens.active.cursor.x);
try testing.expectEqual(case[3], t.screens.active.cursor.y);
const expected = if (case[0] == 33)
"x" ** 20 ++ "M001M002!"
else if (case[0] == 20)
"x" ** 20 ++ "\nM001M002!"
else if (case[1] == 17)
"x" ** 20 ++ "M001!\nM002!"
else
"x" ** 20 ++ "M001M\n002!";
const actual = try t.screens.active.dumpStringAlloc(alloc, .{ .active = .{} });
defer alloc.free(actual);
try testing.expectEqualStrings(expected, actual);
}
}
test "Terminal: ConPTY resize keeps saved cursor coordinates" {
const alloc = testing.allocator;
var t = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
defer t.deinit(alloc);
t.conpty_resize = true;
t.setCursorPos(4, 5);
t.saveCursor();
t.setCursorPos(1, 1);
for (0..3) |_| {
try t.printString("x" ** 127);
t.carriageReturn();
try t.linefeed();
}
try t.resize(alloc, .{ .cols = 33, .rows = 17 });
t.restoreCursor();
try testing.expectEqual(@as(u16, 3), t.screens.active.cursor.y);
try testing.expectEqual(@as(u16, 4), t.screens.active.cursor.x);
// Native ConPTY retains saved pending wrap even after the right edge moves.
for ([_][2]u16{ .{ 33, 17 }, .{ 20, 17 }, .{ 25, 23 }, .{ 25, 17 } }) |geometry| {
var pending = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
defer pending.deinit(alloc);
pending.conpty_resize = true;
try pending.printString("x" ** 25);
pending.saveCursor();
pending.setCursorPos(3, 1);
try pending.printString("M001!");
try pending.resize(alloc, .{ .cols = geometry[0], .rows = geometry[1] });
pending.restoreCursor();
try pending.printString("Z");
try testing.expectEqual(@as(u16, 1), pending.screens.active.cursor.y);
try testing.expectEqual(@as(u16, 1), pending.screens.active.cursor.x);
}
}
test "Terminal: ConPTY resize preserves a pinned history viewport" {
const alloc = testing.allocator;
for ([_][2]u16{ .{ 32, 4 }, .{ 4, 8 } }) |geometry| {
var t = try init(testing.io, alloc, .{ .cols = 4, .rows = 4 });
defer t.deinit(alloc);
t.conpty_resize = true;
try t.printString("x" ** 32);
t.screens.active.scroll(.{ .delta_row = -1 });
try testing.expect(t.screens.active.pages.viewport == .pin);
try t.resize(alloc, .{ .cols = geometry[0], .rows = geometry[1] });
try testing.expect(t.screens.active.pages.viewport == .pin);
t.screens.active.pages.assertIntegrity();
}
}
test "Terminal: ConPTY resize leaves alternate screen without reflow" {
const alloc = testing.allocator;
var t = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
defer t.deinit(alloc);
t.conpty_resize = true;
_ = try t.switchScreen(.alternate);
for (0..3) |_| {
try t.printString("x" ** 127);
t.carriageReturn();
try t.linefeed();
}
try t.printString("M001!");
try t.resize(alloc, .{ .cols = 33, .rows = 17 });
try testing.expectEqual(@as(u16, 16), t.screens.active.cursor.y);
try testing.expectEqual(@as(u16, 5), t.screens.active.cursor.x);
}
test "Terminal: resize with reflow and saved cursor" {
const alloc = testing.allocator;
const io_impl = testing.io;
+4
View File
@@ -1187,6 +1187,8 @@ pub const Option = enum(c_int) {
kitty_image_preserve_png = 40,
kitty_image_snapshot_file = 41,
conpty_resize = 42,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
return switch (self) {
@@ -1215,6 +1217,7 @@ pub const Option = enum(c_int) {
.kitty_image_medium_shared_mem,
.glyph_protocol,
.title_report,
.conpty_resize,
=> ?*const bool,
.kitty_image_medium_temp_file => ?*const lib.String,
.apc_max_bytes,
@@ -1441,6 +1444,7 @@ fn setTyped(
.scrollback_max_lines => wrapper.terminal.setScrollbackMaxLines(
if (value) |ptr| ptr.* else null,
),
.conpty_resize => wrapper.terminal.conpty_resize = if (value) |ptr| ptr.* else false,
.continuation_max_bytes => setContinuationMaxBytes(
wrapper,
if (value) |ptr| ptr.* else default_continuation_max_bytes,
@@ -102,8 +102,9 @@ index 81f9ebd0..6404af53 100644
+ GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40,
+ /** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */
+ GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41,
/** Reflow a finite ConPTY buffer separately from history. bool*; NULL disables. */
GHOSTTY_TERMINAL_OPT_CONPTY_RESIZE = 42,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
index 2bd5e862..c66b13be 100644
@@ -197,13 +198,14 @@ index 91bbe310..e825183e 100644
/// C function pointer type for the write_pty callback.
pub const WritePtyFn = *const fn (Terminal, ?*anyopaque, [*]const u8, usize) callconv(lib.calling_conv) void;
@@ -1174,12 +1184,15 @@ pub const Option = enum(c_int) {
@@ -1174,4 +1184,6 @@ pub const Option = enum(c_int) {
terminfo_name = 37,
clipboard_read = 38,
clipboard_write_max_bytes = 39,
+ kitty_image_preserve_png = 40,
+ kitty_image_snapshot_file = 41,
@@ -1178,8 +1190,9 @@ pub const Option = enum(c_int) {
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
return switch (self) {
@@ -0,0 +1,391 @@
diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h
index 6404af53..2d09aa94 100644
--- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h
+++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h
@@ -1586,6 +1586,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_PRESERVE_PNG = 40,
/** Optional synchronous GhosttyKittyImageSnapshotFileFn; NULL disables. */
GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_SNAPSHOT_FILE = 41,
+ /** Reflow a finite ConPTY buffer separately from history. bool*; NULL disables. */
+ GHOSTTY_TERMINAL_OPT_CONPTY_RESIZE = 42,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig
index 94ef9bb0..cf36eef8 100644
--- a/vendor/libghostty-vt/src/terminal/PageList.zig
+++ b/vendor/libghostty-vt/src/terminal/PageList.zig
@@ -1230,6 +1230,9 @@ pub const Resize = struct {
/// be truncated if the new size is smaller than the old size.
reflow: bool = true,
+ /// Reflow the finite ConPTY buffer independently from archived history.
+ conpty_resize: bool = false,
+
/// Set this to the current cursor position in the active area. Some
/// resize/reflow behavior depends on the cursor position.
cursor: ?Cursor = null,
@@ -1276,6 +1279,13 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
return;
}
+ const active_top = if (opts.conpty_resize and
+ (opts.cols orelse self.cols) != self.cols)
+ try self.trackPin(self.pin(.{ .active = .{} }).?)
+ else
+ null;
+ defer if (active_top) |p| self.untrackPin(p);
+
// Recalculate our minimum limits. This allows grow to work properly when
// increasing beyond the explicit limits to fit the active area.
const old_limits = self.limits;
@@ -1289,13 +1299,25 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
// only rows change, reflow is impossible. So we change our behavior based
// on the change of columns.
const cols = opts.cols orelse self.cols;
- switch (std.math.order(cols, self.cols)) {
+ if (opts.conpty_resize) {
+ // ConPTY grows its buffer before reflow and shrinks it afterward.
+ if (opts.rows) |rows| {
+ if (rows > self.rows) try self.resizeWithoutReflow(.{
+ .rows = rows,
+ .conpty_resize = true,
+ });
+ }
+ if (cols != self.cols) try self.resizeCols(cols, opts.cursor, active_top);
+ if (opts.rows) |rows| {
+ if (rows < self.rows) try self.resizeWithoutReflow(.{ .rows = rows });
+ }
+ } else switch (std.math.order(cols, self.cols)) {
.eq => try self.resizeWithoutReflow(opts),
.gt => {
// We grow rows after cols so that we can do our unwrapping/reflow
// before we do a no-reflow grow.
- try self.resizeCols(cols, opts.cursor);
+ try self.resizeCols(cols, opts.cursor, null);
try self.resizeWithoutReflow(opts);
},
@@ -1307,7 +1329,7 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
copy.cols = self.cols;
break :opts copy;
});
- try self.resizeCols(cols, opts.cursor);
+ try self.resizeCols(cols, opts.cursor, null);
},
}
@@ -1332,8 +1354,11 @@ fn resizeCols(
self: *PageList,
cols: size.CellCountInt,
cursor: ?Resize.Cursor,
+ active_top: ?*Pin,
) Allocator.Error!void {
assert(cols != self.cols);
+ const old_active_top = if (active_top) |p| p.* else null;
+ const has_history = self.total_rows > self.rows;
// If we have a cursor position (x,y), then we try under any col resizing
// to keep the same number remaining active rows beneath it. This is a
@@ -1456,6 +1481,13 @@ fn resizeCols(
{
var reflow_cursor: ReflowCursor = .init(first_rewritten_node);
while (it.next()) |row| {
+ if (old_active_top) |boundary| {
+ if (has_history and row.node == boundary.node and row.y == boundary.y) {
+ // Archived text must not rejoin a live partial line.
+ reflow_cursor.page_row.wrap = false;
+ reflow_cursor.new_rows = @max(reflow_cursor.new_rows, 1);
+ }
+ }
try reflow_cursor.reflowRow(
self,
row,
@@ -1485,6 +1517,22 @@ fn resizeCols(
self.total_rows = reflow_cursor.total_rows;
}
+ if (active_top) |p| {
+ // Padding temporarily leaves a pinned viewport shorter than self.rows.
+ self.pauseIntegrityChecks(true);
+ defer self.pauseIntegrityChecks(false);
+ const start = self.pointFromPin(.screen, p.*).?.screen.y;
+ const missing = self.rows -| (self.total_rows - start);
+ for (0..missing) |_| _ = try self.grow();
+ switch (self.viewport) {
+ .pin => if (self.pinIsActive(self.viewport_pin.*)) {
+ self.viewport = .active;
+ },
+ .active, .top => {},
+ }
+ return;
+ }
+
// If our total rows is less than our active rows, we need to grow.
// This can happen if you're growing columns such that enough active
// rows unwrap that we no longer have enough.
@@ -2851,6 +2899,20 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void {
// Making rows larger we adjust our row count, and then grow
// to the row count.
.gt => gt: {
+ if (opts.conpty_resize) {
+ const delta = rows - self.rows;
+ self.pauseIntegrityChecks(true);
+ defer self.pauseIntegrityChecks(false);
+ self.rows = rows;
+ for (0..delta) |_| _ = try self.grow();
+ switch (self.viewport) {
+ .pin => if (self.pinIsActive(self.viewport_pin.*)) {
+ self.viewport = .active;
+ },
+ .active, .top => {},
+ }
+ break :gt;
+ }
// If our rows increased and our cursor is NOT at the bottom,
// we want to try to preserve the y value of the old cursor.
// In other words, we don't want to "pull down" scrollback.
diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig
index 70b92124..0c1784b6 100644
--- a/vendor/libghostty-vt/src/terminal/Screen.zig
+++ b/vendor/libghostty-vt/src/terminal/Screen.zig
@@ -2018,6 +2018,8 @@ pub const Resize = struct {
/// lost from the top of the scrollback.
reflow: bool = true,
+ conpty_resize: bool = false,
+
/// Set this to enable prompt redraw on resize. This signals
/// that the running program can redraw the prompt if the cursor is
/// currently at a prompt. This detects OSC133 prompts lines and clears
@@ -2043,6 +2045,8 @@ pub inline fn resize(
// modify its X/Y for reflow. Do this before changing any state since
// tracking the pin can fail.
const saved_cursor_pin: ?*Pin = saved_cursor: {
+ // ConPTY retains saved cursor coordinates across buffer reflow.
+ if (opts.conpty_resize) break :saved_cursor null;
const sc = self.saved_cursor orelse break :saved_cursor null;
const pin = self.pages.pin(.{ .active = .{
.x = sc.x,
@@ -2114,6 +2118,7 @@ pub inline fn resize(
.rows = opts.rows,
.cols = opts.cols,
.reflow = opts.reflow,
+ .conpty_resize = opts.conpty_resize,
.cursor = .{
.x = self.cursor.x,
.y = self.cursor.y,
@@ -2136,6 +2141,7 @@ pub inline fn resize(
// If our cursor was updated, we do a full reload so all our cursor
// state is correct.
self.cursorReload();
+ if (opts.conpty_resize) self.cursor.pending_wrap = false;
// Clear any redrawable prompt after the fallible resize but before
// restoring the cursor style and hyperlink, so cleared cells retain the
diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig
index e9cc3b8e..3f124417 100644
--- a/vendor/libghostty-vt/src/terminal/Terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/Terminal.zig
@@ -79,6 +79,9 @@ colors: Colors,
/// char CSI (ESC [ <n> b).
previous_char: ?u21 = null,
+/// Host resize policy, retained across terminal resets.
+conpty_resize: bool = false,
+
/// The modes that this terminal currently has active.
modes: modespkg.ModeState = .{},
@@ -4088,6 +4091,7 @@ pub fn resize(
.cols = opts.cols,
.rows = opts.rows,
.reflow = self.modes.get(.wraparound),
+ .conpty_resize = self.conpty_resize,
.prompt_redraw = self.flags.shell_redraws_prompt,
});
@@ -15875,6 +15879,153 @@ test "Terminal: resize with high unique style per cell with wrapping" {
try t.resize(alloc, .{ .cols = 60, .rows = 30 });
}
+test "Terminal: ConPTY resize preserves the finite buffer across both axes" {
+ const alloc = testing.allocator;
+ // ReadConsoleOutputCharacterW captures, with three 127-character lines.
+ const cases = [_][6]u16{
+ .{ 25, 17, 33, 17, 11, 1 },
+ .{ 33, 17, 25, 17, 16, 1 },
+ .{ 25, 17, 25, 23, 16, 1 },
+ .{ 25, 23, 25, 17, 16, 1 },
+ .{ 25, 17, 33, 23, 11, 1 },
+ .{ 33, 23, 25, 17, 16, 1 },
+ .{ 25, 23, 33, 17, 12, 0 },
+ .{ 33, 17, 25, 23, 18, 0 },
+ };
+ for (cases) |case| {
+ var t = try init(testing.io, alloc, .{ .cols = case[0], .rows = case[1] });
+ defer t.deinit(alloc);
+ t.conpty_resize = true;
+ t.fullReset();
+ for (0..3) |line| {
+ var buf: [127]u8 = undefined;
+ try t.printString(try std.fmt.bufPrint(&buf, "L{d:0>4} {s}", .{ line, "x" ** 121 }));
+ t.carriageReturn();
+ try t.linefeed();
+ }
+ try t.printString("M001!");
+ try t.resize(alloc, .{ .cols = case[2], .rows = case[3] });
+ try testing.expectEqual(case[4], t.screens.active.cursor.y);
+ const body = if (case[2] == 33)
+ "x" ** 27 ++ "\n" ++ "x" ** 33 ++ "\n" ++ "x" ** 33 ++ "\n" ++ "x" ** 28 ++ "\n"
+ else
+ "x" ** 19 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\nxx\n";
+ const prefix = if (case[5] == 0)
+ try std.fmt.allocPrint(alloc, "L0000 {s}", .{body})
+ else if (case[2] == 33)
+ try alloc.dupe(u8, "x" ** 33 ++ "\n" ++ "x" ** 33 ++ "\n" ++ "x" ** 11 ++ "\n")
+ else
+ try alloc.dupe(u8, "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\n" ++ "x" ** 25 ++ "\nxx\n");
+ defer alloc.free(prefix);
+ const expected = try std.fmt.allocPrint(alloc, "{s}L0001 {s}L0002 {s}M001!", .{ prefix, body, body });
+ defer alloc.free(expected);
+ const actual = try t.screens.active.dumpStringAlloc(alloc, .{ .active = .{} });
+ defer alloc.free(actual);
+ try testing.expectEqualStrings(expected, actual);
+ }
+}
+
+test "Terminal: ConPTY resize clears pending wrap only when geometry changes" {
+ const alloc = testing.allocator;
+ const cases = [_][4]u16{
+ .{ 33, 17, 29, 0 },
+ .{ 20, 17, 9, 1 },
+ .{ 25, 23, 4, 1 },
+ .{ 25, 12, 4, 1 },
+ .{ 33, 23, 29, 0 },
+ .{ 25, 17, 5, 1 },
+ };
+ for (cases) |case| {
+ var t = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
+ defer t.deinit(alloc);
+ t.conpty_resize = true;
+ try t.printString("x" ** 20 ++ "M001!");
+ try testing.expect(t.screens.active.cursor.pending_wrap);
+ try t.resize(alloc, .{ .cols = case[0], .rows = case[1] });
+ try t.printString("M002!");
+ try testing.expectEqual(case[2], t.screens.active.cursor.x);
+ try testing.expectEqual(case[3], t.screens.active.cursor.y);
+ const expected = if (case[0] == 33)
+ "x" ** 20 ++ "M001M002!"
+ else if (case[0] == 20)
+ "x" ** 20 ++ "\nM001M002!"
+ else if (case[1] == 17)
+ "x" ** 20 ++ "M001!\nM002!"
+ else
+ "x" ** 20 ++ "M001M\n002!";
+ const actual = try t.screens.active.dumpStringAlloc(alloc, .{ .active = .{} });
+ defer alloc.free(actual);
+ try testing.expectEqualStrings(expected, actual);
+ }
+}
+
+test "Terminal: ConPTY resize keeps saved cursor coordinates" {
+ const alloc = testing.allocator;
+ var t = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
+ defer t.deinit(alloc);
+ t.conpty_resize = true;
+ t.setCursorPos(4, 5);
+ t.saveCursor();
+ t.setCursorPos(1, 1);
+ for (0..3) |_| {
+ try t.printString("x" ** 127);
+ t.carriageReturn();
+ try t.linefeed();
+ }
+ try t.resize(alloc, .{ .cols = 33, .rows = 17 });
+ t.restoreCursor();
+ try testing.expectEqual(@as(u16, 3), t.screens.active.cursor.y);
+ try testing.expectEqual(@as(u16, 4), t.screens.active.cursor.x);
+
+ // Native ConPTY retains saved pending wrap even after the right edge moves.
+ for ([_][2]u16{ .{ 33, 17 }, .{ 20, 17 }, .{ 25, 23 }, .{ 25, 17 } }) |geometry| {
+ var pending = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
+ defer pending.deinit(alloc);
+ pending.conpty_resize = true;
+ try pending.printString("x" ** 25);
+ pending.saveCursor();
+ pending.setCursorPos(3, 1);
+ try pending.printString("M001!");
+ try pending.resize(alloc, .{ .cols = geometry[0], .rows = geometry[1] });
+ pending.restoreCursor();
+ try pending.printString("Z");
+ try testing.expectEqual(@as(u16, 1), pending.screens.active.cursor.y);
+ try testing.expectEqual(@as(u16, 1), pending.screens.active.cursor.x);
+ }
+}
+
+test "Terminal: ConPTY resize preserves a pinned history viewport" {
+ const alloc = testing.allocator;
+ for ([_][2]u16{ .{ 32, 4 }, .{ 4, 8 } }) |geometry| {
+ var t = try init(testing.io, alloc, .{ .cols = 4, .rows = 4 });
+ defer t.deinit(alloc);
+ t.conpty_resize = true;
+ try t.printString("x" ** 32);
+ t.screens.active.scroll(.{ .delta_row = -1 });
+ try testing.expect(t.screens.active.pages.viewport == .pin);
+ try t.resize(alloc, .{ .cols = geometry[0], .rows = geometry[1] });
+ try testing.expect(t.screens.active.pages.viewport == .pin);
+ t.screens.active.pages.assertIntegrity();
+ }
+}
+
+test "Terminal: ConPTY resize leaves alternate screen without reflow" {
+ const alloc = testing.allocator;
+ var t = try init(testing.io, alloc, .{ .cols = 25, .rows = 17 });
+ defer t.deinit(alloc);
+ t.conpty_resize = true;
+ _ = try t.switchScreen(.alternate);
+ for (0..3) |_| {
+ try t.printString("x" ** 127);
+ t.carriageReturn();
+ try t.linefeed();
+ }
+ try t.printString("M001!");
+ try t.resize(alloc, .{ .cols = 33, .rows = 17 });
+ try testing.expectEqual(@as(u16, 16), t.screens.active.cursor.y);
+ try testing.expectEqual(@as(u16, 5), t.screens.active.cursor.x);
+}
+
test "Terminal: resize with reflow and saved cursor" {
const alloc = testing.allocator;
const io_impl = testing.io;
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
index e825183e..c027ebb7 100644
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
@@ -1187,6 +1187,8 @@ pub const Option = enum(c_int) {
kitty_image_preserve_png = 40,
kitty_image_snapshot_file = 41,
+ conpty_resize = 42,
+
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
return switch (self) {
@@ -1215,6 +1217,7 @@ pub const Option = enum(c_int) {
.kitty_image_medium_shared_mem,
.glyph_protocol,
.title_report,
+ .conpty_resize,
=> ?*const bool,
.kitty_image_medium_temp_file => ?*const lib.String,
.apc_max_bytes,
@@ -1441,6 +1444,7 @@ fn setTyped(
.scrollback_max_lines => wrapper.terminal.setScrollbackMaxLines(
if (value) |ptr| ptr.* else null,
),
+ .conpty_resize => wrapper.terminal.conpty_resize = if (value) |ptr| ptr.* else false,
.continuation_max_bytes => setContinuationMaxBytes(
wrapper,
if (value) |ptr| ptr.* else default_continuation_max_bytes,