From 81f537e9927c94c84c5356424d6baabfabaee1b6 Mon Sep 17 00:00:00 2001 From: benodiwal Date: Wed, 17 Dec 2025 16:45:18 +0530 Subject: [PATCH 1/5] feat: implement scroll-to-bottom on output option Implements the `output` option for the `scroll-to-bottom` configuration, which scrolls the viewport to the bottom when new lines are printed. Co-Authored-By: Sachin --- src/config/Config.zig | 2 +- src/termio/Termio.zig | 3 +++ src/termio/stream_handler.zig | 9 +++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index bb86b6bd5..d409fb6fa 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -906,7 +906,7 @@ palette: Palette = .{}, /// anything but modifiers or keybinds that are processed by Ghostty). /// /// - `output` If set, scroll the surface to the bottom if there is new data -/// to display. (Currently unimplemented.) +/// to display (e.g., when new lines are printed to the terminal). /// /// The default is `keystroke, no-output`. @"scroll-to-bottom": ScrollToBottom = .default, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dee58dc22..a5b3cbed3 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -166,6 +166,7 @@ pub const DerivedConfig = struct { clipboard_write: configpkg.ClipboardAccess, enquiry_response: []const u8, conditional_state: configpkg.ConditionalState, + scroll_to_bottom: configpkg.Config.ScrollToBottom, pub fn init( alloc_gpa: Allocator, @@ -207,6 +208,7 @@ pub const DerivedConfig = struct { .clipboard_write = config.@"clipboard-write", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), .conditional_state = config._conditional_state, + .scroll_to_bottom = config.@"scroll-to-bottom", // This has to be last so that we copy AFTER the arena allocations // above happen (Zig assigns in order). @@ -301,6 +303,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .enquiry_response = opts.config.enquiry_response, .default_cursor_style = opts.config.cursor_style, .default_cursor_blink = opts.config.cursor_blink, + .scroll_to_bottom_on_output = opts.config.scroll_to_bottom.output, }; const thread_enter_state = try ThreadEnterState.create( diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index bc3edd185..76de83c08 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -56,6 +56,9 @@ pub const StreamHandler = struct { /// The clipboard write access configuration. clipboard_write: configpkg.ClipboardAccess, + /// The scroll-to-bottom behavior configuration. + scroll_to_bottom_on_output: bool, + //--------------------------------------------------------------- // Internal state @@ -112,6 +115,7 @@ pub const StreamHandler = struct { self.enquiry_response = config.enquiry_response; self.default_cursor_style = config.cursor_style; self.default_cursor_blink = config.cursor_blink; + self.scroll_to_bottom_on_output = config.scroll_to_bottom.output; // If our cursor is the default, then we update it immediately. if (self.default_cursor) self.setCursorStyle(.default) catch |err| { @@ -576,6 +580,11 @@ pub const StreamHandler = struct { // Small optimization: call index instead of linefeed because they're // identical and this avoids one layer of function call overhead. try self.terminal.index(); + + // If configured, scroll to bottom on output so the user sees new content + if (self.scroll_to_bottom_on_output) { + try self.terminal.scrollViewport(.bottom); + } } pub inline fn reverseIndex(self: *StreamHandler) !void { From e197b95c32f12abe97fbc1b0ca1f0514367ef702 Mon Sep 17 00:00:00 2001 From: benodiwal Date: Thu, 18 Dec 2025 05:44:46 +0530 Subject: [PATCH 2/5] feat: scroll-to-bottom on output via renderer pin tracking Co-Authored-By: Sachin --- src/Surface.zig | 8 ++++++++ src/apprt/surface.zig | 4 ++++ src/renderer/generic.zig | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index 588d52968..fea5b2a87 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1070,6 +1070,14 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), + .scroll_to_bottom => { + self.queueIo(.{ + .scroll_viewport = .{ .bottom = {} }, + }, .unlocked); + }, + + .report_color_scheme => |force| self.reportColorScheme(force), + .present_surface => try self.presentSurface(), .password_input => |v| try self.passwordInput(v), diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 5c25281c8..159e8db16 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -108,6 +108,10 @@ pub const Message = union(enum) { /// Selected search index change search_selected: ?usize, + /// Scroll the viewport to the bottom. This is triggered by the renderer + /// when new output is detected and scroll-to-bottom on output is enabled. + scroll_to_bottom, + pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 19c7b3375..4d986336b 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -125,6 +125,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { scrollbar: terminal.Scrollbar, scrollbar_dirty: bool, + /// Tracks the last bottom-right pin of the screen to detect new output. + /// When the final line changes (node or y differs), new content was added. + /// Used for scroll-to-bottom on output feature. + last_bottom_node: ?*terminal.PageList.List.Node, + last_bottom_y: terminal.size.CellCountInt, + /// The most recent viewport matches so that we can render search /// matches in the visible frame. This is provided asynchronously /// from the search thread so we have the dirty flag to also note @@ -563,6 +569,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { colorspace: configpkg.Config.WindowColorspace, blending: configpkg.Config.AlphaBlending, background_blur: configpkg.Config.BackgroundBlur, + scroll_to_bottom_on_output: bool, pub fn init( alloc_gpa: Allocator, @@ -636,6 +643,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .colorspace = config.@"window-colorspace", .blending = config.@"alpha-blending", .background_blur = config.@"background-blur", + .scroll_to_bottom_on_output = config.@"scroll-to-bottom".output, .arena = arena, }; } @@ -699,6 +707,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .focused = true, .scrollbar = .zero, .scrollbar_dirty = false, + .last_bottom_node = null, + .last_bottom_y = 0, .search_matches = null, .search_selected_match = null, .search_matches_dirty = false, @@ -1182,6 +1192,25 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // cross-thread mailbox message within the IO path. const scrollbar = state.terminal.screens.active.pages.scrollbar(); + // If scroll-to-bottom on output is enabled, check if the final line + // changed by comparing the bottom-right pin. If the node pointer or + // y offset changed, new content was added to the screen. + if (self.config.scroll_to_bottom_on_output) { + const bottom_right = state.terminal.screens.active.pages.getBottomRight(.screen); + if (bottom_right) |br| { + const pin_changed = (self.last_bottom_node != br.node) or + (self.last_bottom_y != br.y); + + if (pin_changed and !state.terminal.screens.active.viewportIsBottom()) { + _ = self.surface_mailbox.push(.scroll_to_bottom, .instant); + } + + // Update tracked pin state for next frame + self.last_bottom_node = br.node; + self.last_bottom_y = br.y; + } + } + // Get our preedit state const preedit: ?renderer.State.Preedit = preedit: { const p = state.preedit orelse break :preedit null; From 263469755c56fa79474be2a56f2c85478cb1e25e Mon Sep 17 00:00:00 2001 From: benodiwal Date: Thu, 18 Dec 2025 05:50:47 +0530 Subject: [PATCH 3/5] refactor: remove unused stream handler scroll-to-bottom code The renderer approach doesn't need termio changes. Co-Authored-By: Sachin --- src/Surface.zig | 2 -- src/termio/Termio.zig | 3 --- src/termio/stream_handler.zig | 9 --------- 3 files changed, 14 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index fea5b2a87..6e01daba2 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1076,8 +1076,6 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .unlocked); }, - .report_color_scheme => |force| self.reportColorScheme(force), - .present_surface => try self.presentSurface(), .password_input => |v| try self.passwordInput(v), diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index a5b3cbed3..dee58dc22 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -166,7 +166,6 @@ pub const DerivedConfig = struct { clipboard_write: configpkg.ClipboardAccess, enquiry_response: []const u8, conditional_state: configpkg.ConditionalState, - scroll_to_bottom: configpkg.Config.ScrollToBottom, pub fn init( alloc_gpa: Allocator, @@ -208,7 +207,6 @@ pub const DerivedConfig = struct { .clipboard_write = config.@"clipboard-write", .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), .conditional_state = config._conditional_state, - .scroll_to_bottom = config.@"scroll-to-bottom", // This has to be last so that we copy AFTER the arena allocations // above happen (Zig assigns in order). @@ -303,7 +301,6 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .enquiry_response = opts.config.enquiry_response, .default_cursor_style = opts.config.cursor_style, .default_cursor_blink = opts.config.cursor_blink, - .scroll_to_bottom_on_output = opts.config.scroll_to_bottom.output, }; const thread_enter_state = try ThreadEnterState.create( diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 76de83c08..bc3edd185 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -56,9 +56,6 @@ pub const StreamHandler = struct { /// The clipboard write access configuration. clipboard_write: configpkg.ClipboardAccess, - /// The scroll-to-bottom behavior configuration. - scroll_to_bottom_on_output: bool, - //--------------------------------------------------------------- // Internal state @@ -115,7 +112,6 @@ pub const StreamHandler = struct { self.enquiry_response = config.enquiry_response; self.default_cursor_style = config.cursor_style; self.default_cursor_blink = config.cursor_blink; - self.scroll_to_bottom_on_output = config.scroll_to_bottom.output; // If our cursor is the default, then we update it immediately. if (self.default_cursor) self.setCursorStyle(.default) catch |err| { @@ -580,11 +576,6 @@ pub const StreamHandler = struct { // Small optimization: call index instead of linefeed because they're // identical and this avoids one layer of function call overhead. try self.terminal.index(); - - // If configured, scroll to bottom on output so the user sees new content - if (self.scroll_to_bottom_on_output) { - try self.terminal.scrollViewport(.bottom); - } } pub inline fn reverseIndex(self: *StreamHandler) !void { From eb335fb8ddc8cc088c2c96924aaec979e1fea39f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Feb 2026 14:06:52 -0800 Subject: [PATCH 4/5] cleanup by just scrolling in the renderer --- src/Surface.zig | 14 ++++-------- src/apprt/surface.zig | 4 ---- src/renderer/generic.zig | 41 ++++++++++++++++++----------------- src/terminal/Terminal.zig | 2 +- src/termio/Termio.zig | 7 ++++-- src/termio/Thread.zig | 2 +- src/termio/stream_handler.zig | 2 +- 7 files changed, 33 insertions(+), 39 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 6e01daba2..fbb4d9119 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1070,12 +1070,6 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .scrollbar => |scrollbar| self.updateScrollbar(scrollbar), - .scroll_to_bottom => { - self.queueIo(.{ - .scroll_viewport = .{ .bottom = {} }, - }, .unlocked); - }, - .present_surface => try self.presentSurface(), .password_input => |v| try self.passwordInput(v), @@ -1180,7 +1174,7 @@ fn selectionScrollTick(self: *Surface) !void { } // Scroll the viewport as required - try t.scrollViewport(.{ .delta = delta }); + t.scrollViewport(.{ .delta = delta }); // Next, trigger our drag behavior const pin = t.screens.active.pages.pin(.{ @@ -2785,7 +2779,7 @@ pub fn keyCallback( try self.setSelection(null); } - if (self.config.scroll_to_bottom.keystroke) try self.io.terminal.scrollViewport(.bottom); + if (self.config.scroll_to_bottom.keystroke) self.io.terminal.scrollViewport(.bottom); try self.queueRender(); } @@ -3538,7 +3532,7 @@ pub fn scrollCallback( // Modify our viewport, this requires a lock since it affects // rendering. We have to switch signs here because our delta // is negative down but our viewport is positive down. - try self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 }); + self.io.terminal.scrollViewport(.{ .delta = y.delta * -1 }); } } @@ -5069,7 +5063,7 @@ pub fn posToViewport(self: Surface, xpos: f64, ypos: f64) terminal.point.Coordin /// /// Precondition: the render_state mutex must be held. fn scrollToBottom(self: *Surface) !void { - try self.io.terminal.scrollViewport(.{ .bottom = {} }); + self.io.terminal.scrollViewport(.{ .bottom = {} }); try self.queueRender(); } diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 159e8db16..5c25281c8 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -108,10 +108,6 @@ pub const Message = union(enum) { /// Selected search index change search_selected: ?usize, - /// Scroll the viewport to the bottom. This is triggered by the renderer - /// when new output is detected and scroll-to-bottom on output is enabled. - scroll_to_bottom, - pub const ReportTitleStyle = enum { csi_21_t, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 4d986336b..83417429e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -128,7 +128,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Tracks the last bottom-right pin of the screen to detect new output. /// When the final line changes (node or y differs), new content was added. /// Used for scroll-to-bottom on output feature. - last_bottom_node: ?*terminal.PageList.List.Node, + last_bottom_node: ?usize, last_bottom_y: terminal.size.CellCountInt, /// The most recent viewport matches so that we can render search @@ -1176,6 +1176,26 @@ pub fn Renderer(comptime GraphicsAPI: type) type { return; } + // If scroll-to-bottom on output is enabled, check if the final line + // changed by comparing the bottom-right pin. If the node pointer or + // y offset changed, new content was added to the screen. + // Update this BEFORE we update our render state so we can + // draw the new scrolled data immediately. + if (self.config.scroll_to_bottom_on_output) scroll: { + const br = state.terminal.screens.active.pages.getBottomRight(.screen) orelse break :scroll; + + // If the pin hasn't changed, then don't scroll. + if (self.last_bottom_node == @intFromPtr(br.node) and + self.last_bottom_y == br.y) break :scroll; + + // Update tracked pin state for next frame + self.last_bottom_node = @intFromPtr(br.node); + self.last_bottom_y = br.y; + + // Scroll + state.terminal.scrollViewport(.bottom); + } + // Update our terminal state try self.terminal_state.update(self.alloc, state.terminal); @@ -1192,25 +1212,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // cross-thread mailbox message within the IO path. const scrollbar = state.terminal.screens.active.pages.scrollbar(); - // If scroll-to-bottom on output is enabled, check if the final line - // changed by comparing the bottom-right pin. If the node pointer or - // y offset changed, new content was added to the screen. - if (self.config.scroll_to_bottom_on_output) { - const bottom_right = state.terminal.screens.active.pages.getBottomRight(.screen); - if (bottom_right) |br| { - const pin_changed = (self.last_bottom_node != br.node) or - (self.last_bottom_y != br.y); - - if (pin_changed and !state.terminal.screens.active.viewportIsBottom()) { - _ = self.surface_mailbox.push(.scroll_to_bottom, .instant); - } - - // Update tracked pin state for next frame - self.last_bottom_node = br.node; - self.last_bottom_y = br.y; - } - } - // Get our preedit state const preedit: ?renderer.State.Preedit = preedit: { const p = state.preedit orelse break :preedit null; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 81c21d3b0..248a2c512 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -1625,7 +1625,7 @@ pub const ScrollViewport = union(enum) { }; /// Scroll the viewport of the terminal grid. -pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) !void { +pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { self.screens.active.scroll(switch (behavior) { .top => .{ .top = {} }, .bottom => .{ .active = {} }, diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index dee58dc22..55621854c 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -641,10 +641,13 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { } /// Scroll the viewport -pub fn scrollViewport(self: *Termio, scroll: terminalpkg.Terminal.ScrollViewport) !void { +pub fn scrollViewport( + self: *Termio, + scroll: terminalpkg.Terminal.ScrollViewport, +) void { self.renderer_state.mutex.lock(); defer self.renderer_state.mutex.unlock(); - try self.terminal.scrollViewport(scroll); + self.terminal.scrollViewport(scroll); } /// Jump the viewport to the prompt. diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 6aa5e1c26..ce4c1f4af 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -321,7 +321,7 @@ fn drainMailbox( .resize => |v| self.handleResize(cb, v), .size_report => |v| try io.sizeReport(data, v), .clear_screen => |v| try io.clearScreen(data, v.history), - .scroll_viewport => |v| try io.scrollViewport(v), + .scroll_viewport => |v| io.scrollViewport(v), .selection_scroll => |v| { if (v) { self.startScrollTimer(cb); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index bc3edd185..8c1b5b8ab 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -232,7 +232,7 @@ pub const StreamHandler = struct { .erase_display_below => self.terminal.eraseDisplay(.below, value), .erase_display_above => self.terminal.eraseDisplay(.above, value), .erase_display_complete => { - try self.terminal.scrollViewport(.{ .bottom = {} }); + self.terminal.scrollViewport(.{ .bottom = {} }); self.terminal.eraseDisplay(.complete, value); }, .erase_display_scrollback => self.terminal.eraseDisplay(.scrollback, value), From 2a62f21bf079f253245e0b9f752dbcdbb49eb95a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Feb 2026 14:10:32 -0800 Subject: [PATCH 5/5] fix tests --- src/renderer/cursor.zig | 2 +- src/terminal/render.zig | 2 +- src/terminal/search/viewport.zig | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index bfa92f31d..cddda9871 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -143,7 +143,7 @@ test "cursor: always block with preedit" { // If we're scrolled though, then we don't show the cursor. for (0..100) |_| try term.index(); - try term.scrollViewport(.{ .top = {} }); + term.scrollViewport(.{ .top = {} }); try state.update(alloc, &term); // In any bool state diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 9d75fe4b7..2332866ac 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -1092,7 +1092,7 @@ test "cursor state out of viewport" { try testing.expectEqual(1, state.cursor.viewport.?.y); // Scroll the viewport - try t.scrollViewport(.top); + t.scrollViewport(.top); try state.update(alloc, &t); // Set a style on the cursor diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 76deebcec..f5e6c8601 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -358,7 +358,7 @@ test "history search, no active area" { try testing.expect(t.screens.active.pages.pages.first != t.screens.active.pages.pages.last); try s.nextSlice("Buzz\r\nFizz"); - try t.scrollViewport(.top); + t.scrollViewport(.top); var search: ViewportSearch = try .init(alloc, "Fizz"); defer search.deinit();