diff --git a/crates/ghostty-vt/src/bindings.rs b/crates/ghostty-vt/src/bindings.rs index 33443aca4..ef89f4be9 100644 --- a/crates/ghostty-vt/src/bindings.rs +++ b/crates/ghostty-vt/src/bindings.rs @@ -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; diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index 4bf36754b..1e56ba734 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -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, diff --git a/src/pane.rs b/src/pane.rs index f80baeb82..1d2cb4d63 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -2704,6 +2704,8 @@ impl PaneRuntime { let (response_tx, _response_rx) = mpsc::channel::(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() diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d4b7ef575..6eeffd632 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -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); diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 21a1e8310..b0cd3b71e 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -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::{ diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 97d623307..e0eb490fd 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -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. diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index 6404af532..2d09aa94e 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 94ef9bb0f..cf36eef83 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 70b921243..0c1784b68 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 e9cc3b8e6..3f1244173 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 [ 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 e825183e9..c027ebb73 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, diff --git a/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch b/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch index e9964ab09..d9a8ef09e 100644 --- a/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch +++ b/vendor/patches/libghostty-vt/0007-experimental-png-retention.patch @@ -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) { diff --git a/vendor/patches/libghostty-vt/0009-conpty-finite-buffer-resize.patch b/vendor/patches/libghostty-vt/0009-conpty-finite-buffer-resize.patch new file mode 100644 index 000000000..1d1c17322 --- /dev/null +++ b/vendor/patches/libghostty-vt/0009-conpty-finite-buffer-resize.patch @@ -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 [ 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,