From a3e9304c5d19c8667f58a342830f774579c74472 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 24 Sep 2026 21:24:33 -0700 Subject: [PATCH 1/2] fix: preserve trailing rows for VT replay --- src/terminal/formatter.zig | 46 +++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index 1b562eb565d..d578dd1982c 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -89,6 +89,11 @@ pub const Options = struct { /// screen contents as it is rendered on the page in the given size. unwrap: bool = false, + /// Preserve blank rows at the end of VT selections when the caller also + /// requests cursor/state restoration. Replay consumers need those rows to + /// keep the active screen anchored after retained scrollback. + preserve_trailing_blank_rows: bool = false, + /// Trim trailing whitespace on lines with other text. Trailing blank /// lines are always trimmed. This only affects trailing whitespace /// on rows that have at least one other cell with text. Whitespace @@ -335,7 +340,12 @@ pub const TerminalFormatter = struct { } } - var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts); + var screen_opts = self.opts; + screen_opts.preserve_trailing_blank_rows = + screen_opts.preserve_trailing_blank_rows or + (self.opts.emit == .vt and self.extra.screen.cursor); + var screen_formatter: ScreenFormatter = + .init(self.terminal.screens.active, screen_opts); screen_formatter.content = self.content; screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map; @@ -1428,6 +1438,40 @@ pub const PageFormatter = struct { } } + // Plain dumps intentionally omit trailing blank rows because their + // meaning cannot be recovered without the cursor. VT replay asks for + // cursor restoration, so preserve those physical rows before the + // terminal formatter emits the cursor and other state footer. + if (self.opts.preserve_trailing_blank_rows and blank_rows > 0) { + const sequence: []const u8 = switch (self.opts.emit) { + .plain => "\n", + .vt => "\r\n", + .html => "\n", + }; + for (0..blank_rows) |_| try writer.writeAll(sequence); + + if (self.point_map) |*map| { + const start: Coordinate = if (map.map.items.len > 0) + map.map.items[map.map.items.len - 1] + else + .{ .x = 0, .y = 0 }; + map.map.appendNTimes( + map.alloc, + .{ .x = start.x, .y = start.y }, + sequence.len, + ) catch return error.WriteFailed; + for (1..blank_rows) |y_offset_usize| { + const y_offset: size.CellCountInt = @intCast(y_offset_usize); + map.map.appendNTimes( + map.alloc, + .{ .x = 0, .y = start.y + y_offset }, + sequence.len, + ) catch return error.WriteFailed; + } + } + blank_rows = 0; + } + // If the style is non-default, we need to close our style tag. if (!style.default()) try self.formatStyleClose(writer); From 8a6de6c2ea3571ebdef1c0fe671ca76cff9773cf Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 25 Sep 2026 04:46:10 -0700 Subject: [PATCH 2/2] fix(termio): keep reported color scheme independent of parsed config Initialize report state from the surface and update it under the renderer mutex when appearance changes. Plain config reloads can no longer reset CSI 996 to the parser default. Co-authored-by: lederniermagicien <162632566+lederniermagicien@users.noreply.github.com> --- src/Surface.zig | 4 ++++ src/termio/Options.zig | 4 ++++ src/termio/Termio.zig | 17 ++++++++++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 7a70c09862c..394fafb3bd7 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -930,6 +930,7 @@ pub fn init( .size = size, .full_config = config, .config = try termio.Termio.DerivedConfig.init(alloc, config), + .color_scheme = self.config_conditional_state.theme, .backend = io_backend, .suppress_terminal_responses = if (comptime @hasDecl(apprt.runtime.Surface, "suppressTerminalResponses")) rt_surface.suppressTerminalResponses() @@ -6847,6 +6848,9 @@ pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) !void { // Setup our conditional state which has the current color theme. self.config_conditional_state.theme = new_scheme; + // Reports must follow the surface even if a plain config has no conditional + // theme to reload, or the embedder coalesces the reload notification. + self.io.updateColorScheme(new_scheme); self.notifyConfigConditionalState(); // If mode 2031 is on, then we report the change live. diff --git a/src/termio/Options.zig b/src/termio/Options.zig index ffcbf517995..029144b5c7a 100644 --- a/src/termio/Options.zig +++ b/src/termio/Options.zig @@ -17,6 +17,10 @@ full_config: *const Config, /// The derived configuration for this termio implementation. config: termio.Termio.DerivedConfig, +/// The surface's current runtime scheme. This is independent of whether the +/// configuration contains any theme-conditional settings. +color_scheme: @import("../config.zig").ConditionalState.Theme, + /// The backend for termio that implements where reads/writes are sourced. backend: termio.Backend, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index f02d9c4c388..3a9261e34aa 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -44,6 +44,10 @@ backend: termio.Backend, /// The derived configuration for this termio implementation. config: DerivedConfig, +/// Runtime scheme used by CSI 996 queries and mode 2031 reports. Guarded by +/// renderer_state.mutex; config swaps must never overwrite appearance state. +color_scheme: configpkg.ConditionalState.Theme, + /// The terminal emulator internal state. This is the abstract "terminal" /// that manages input, grid updating, etc. and is renderer-agnostic. It /// just stores internal state about a grid. @@ -209,7 +213,6 @@ pub const DerivedConfig = struct { osc_color_report_format: configpkg.Config.OSCColorReportFormat, clipboard_write: configpkg.ClipboardAccess, enquiry_response: []const u8, - conditional_state: configpkg.ConditionalState, pub fn init( alloc_gpa: Allocator, @@ -249,7 +252,6 @@ pub const DerivedConfig = struct { .osc_color_report_format = config.@"osc-color-report-format", .clipboard_write = config.@"clipboard-write", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), - .conditional_state = config._conditional_state, // This has to be last so that we copy AFTER the arena allocations // above happen (Zig assigns in order). @@ -374,6 +376,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .alloc = alloc, .terminal = term, .config = opts.config, + .color_scheme = opts.color_scheme, .renderer_state = opts.renderer_state, .renderer_wakeup = opts.renderer_wakeup, .renderer_mailbox = opts.renderer_mailbox, @@ -625,6 +628,14 @@ pub fn changeConfig(self: *Termio, td: *ThreadData, config: *DerivedConfig) !voi self.terminal.setKittyGraphicsLoadingLimits(.allWithTempDir(global.tmpDirPath())); } +/// Update report state synchronously with the surface appearance callback. +/// The IO thread reads it under the same lock when it handles a queued query. +pub fn updateColorScheme(self: *Termio, scheme: configpkg.ConditionalState.Theme) void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + self.color_scheme = scheme; +} + /// Update only the terminal color defaults used by OSC resets. /// /// Manual-IO embedders call this on the same serial executor as processOutput. @@ -972,7 +983,7 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) { return; } - const scheme: terminalpkg.device_status.ColorScheme = switch (self.config.conditional_state.theme) { + const scheme: terminalpkg.device_status.ColorScheme = switch (self.color_scheme) { .light => .light, .dark => .dark, };