From 2d49f4fd413ff2c17847cf82047576b43456e278 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Mon, 18 May 2026 15:34:58 +0200 Subject: [PATCH 1/4] refactor(terminal): size only the focused session to full-window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: applyTerminalResize sized every session to the same dimensions — full-window cell count in Full mode, grid-cell count in Grid mode. In Full mode only the focused session is rendered; the eight invisible sessions still got the resize and were forced to redraw at the new width. For agent TUIs that meant a full top-to-bottom rescroll of chat history on every grid/full toggle, even though only one session was on screen. Solution: Make sizing per session. applyTerminalResize now takes Sizes (grid + full) and a FullSet (primary, secondary indices) and picks the target per session. fullSetForMode promotes the focused session in Full/Expanding/Collapsing, and the previous session as well during panning. Everyone else stays at grid-cell size, so a grid/full toggle reflows exactly one session. The renderer reads each session's own terminal cols/rows at the grid-tile, full, panning, expanding, collapsing, and grid-resizing callsites via a small sessionTermDims helper. Initial spawn seeds at grid-cell size; the first frame's applyTerminalLayoutIfSizeChanged promotes the focused session to full when the startup mode is Full. DEC mode 2048 in-band reports still fire from inside applyTerminalResize and therefore land only on sessions whose PTY actually changes. --- docs/ARCHITECTURE.md | 2 +- src/app/layout.zig | 112 ++++++++++++++++++++++++++-------------- src/app/runtime.zig | 112 ++++++++++++++++++++++++++-------------- src/render/renderer.zig | 42 +++++++++++---- 4 files changed, 179 insertions(+), 89 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7b7e880..e110d19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,7 @@ Platform Session Rendering UI Overlay - Runtime persistence is updated during the frame loop when runtime state changes (cwd changes, terminal spawn/despawn, window move/resize, font size changes), and finalization is explicit at the end of `app/runtime.zig`: final save and deinit `Persistence` before deferred subsystem teardown begins. - Font reload paths are transactional: acquire both replacement fonts first, then swap and destroy old fonts, so a partial reload failure cannot leave deinit hooks pointing at already-freed font resources. - Window-resize scale handling follows a single ordered path (`reload-if-needed`, then `resize`) to keep behavior consistent between changed-scale and unchanged-scale events. -- Terminal resizes use Ghostty's minimal flow: a single `ioctl(master, TIOCSWINSZ)` on the PTY master, which the kernel pairs with a SIGWINCH to the foreground process group of the slave's controlling terminal. Architect resizes the PTY and the ghostty-vt model whenever the effective terminal cell count changes — that includes both window resize and grid↔full view toggle, since grid tiles run at a smaller cell count than full view so content reflows into the visible area. The resize call is skipped when the cell count is unchanged. Grid view also accounts for the user's grid font scale and the reserved CWD-bar space when computing tile cell count. Synchronized-output mode is tracked per session purely as a stuck-mode safety net: if a session leaves `\e[?2026h` set without ever ending the sync, Architect force-clears the mode after a timeout. There is no output-hold or texture-reuse machinery beyond the standard render-epoch / view-mode cache invalidation. +- Terminal resizes use Ghostty's minimal flow: a single `ioctl(master, TIOCSWINSZ)` on the PTY master, which the kernel pairs with a SIGWINCH to the foreground process group of the slave's controlling terminal. Each session is sized independently. The focused session in `.Full`/`.Expanding`/`.Collapsing` mode (and additionally the previous session during a panning transition) is sized to the full-window cell count; every other session stays at grid-cell size. Grid↔full view toggle therefore reflows exactly one session — the one the user actually zoomed — instead of every session in the workspace. Window resize, font size change, and grid layout change all flow through the same per-session dispatch (`fullSetForMode` in `app/runtime.zig`, `applyTerminalResize` with `Sizes`/`FullSet` in `app/layout.zig`). Grid sizing accounts for the user's grid font scale and the reserved CWD-bar space when computing tile cell count. Synchronized-output mode is tracked per session purely as a stuck-mode safety net: if a session leaves `\e[?2026h` set without ever ending the sync, Architect force-clears the mode after a timeout. There is no output-hold or texture-reuse machinery beyond the standard render-epoch / view-mode cache invalidation. - Shared Utilities (`geom`, `colors`, `dpi`, `config`, `logging`, `metrics`, etc.) may be imported by any layer but never import from layers above them. - **Exception:** `app/*` modules may import `c.zig` directly for SDL type definitions used in input handling. This is a pragmatic shortcut for FFI constants, not a general license to depend on the Platform layer. diff --git a/src/app/layout.zig b/src/app/layout.zig index 7d67416..734b1fe 100644 --- a/src/app/layout.zig +++ b/src/app/layout.zig @@ -115,27 +115,46 @@ pub fn calculateGridCellTerminalSize(font: *const font_mod.Font, window_width: c return calculateTerminalSize(font, cell_width, cell_height, grid_font_scale, ui_scale); } -pub fn calculateTerminalSizeForMode( +pub const Sizes = struct { + grid: TerminalSize, + full: TerminalSize, +}; + +pub fn calculateTerminalSizes( font: *const font_mod.Font, window_width: c_int, window_height: c_int, - mode: app_state.ViewMode, grid_font_scale: f32, grid_cols: usize, grid_rows: usize, ui_scale: f32, -) TerminalSize { - return switch (mode) { - .Grid => { - const grid_dim = @max(grid_cols, grid_rows); - const base_grid_scale: f32 = 1.0 / @as(f32, @floatFromInt(grid_dim)); - const effective_scale: f32 = base_grid_scale * grid_font_scale; - return calculateGridCellTerminalSize(font, window_width, window_height, effective_scale, grid_cols, grid_rows, ui_scale); - }, - .Collapsing, .GridResizing, .Expanding, .Full, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => calculateTerminalSize(font, window_width, window_height, 1.0, ui_scale), +) Sizes { + const grid_dim = @max(grid_cols, grid_rows); + const base_grid_scale: f32 = 1.0 / @as(f32, @floatFromInt(grid_dim)); + const effective_scale: f32 = base_grid_scale * grid_font_scale; + return .{ + .grid = calculateGridCellTerminalSize(font, window_width, window_height, effective_scale, grid_cols, grid_rows, ui_scale), + .full = calculateTerminalSize(font, window_width, window_height, 1.0, ui_scale), }; } +/// Describes which sessions need full-window cell dimensions. All other +/// sessions remain at grid-cell size. Only the focused session in stable Full +/// mode (and the previous focused session during a panning transition) is at +/// full size; the rest are invisible in Full mode and rendered at grid-cell +/// scale in Grid mode, so paying for full-size PTYs would just force them to +/// redraw their content on every view toggle. +pub const FullSet = struct { + primary: ?usize = null, + secondary: ?usize = null, + + pub fn contains(self: FullSet, idx: usize) bool { + if (self.primary) |p| if (p == idx) return true; + if (self.secondary) |s| if (s == idx) return true; + return false; + } +}; + pub fn scaledFontSize(points: c_int, scale: f32) c_int { const scaled = std.math.round(@as(f32, @floatFromInt(points)) * scale); return @max(1, @as(c_int, @intFromFloat(scaled))); @@ -144,45 +163,53 @@ pub fn scaledFontSize(points: c_int, scale: f32) c_int { pub fn applyTerminalResize( sessions: []const *SessionState, allocator: std.mem.Allocator, - cols: u16, - rows: u16, + sizes: Sizes, + full_set: FullSet, render_width: c_int, render_height: c_int, ui_scale: f32, ) bool { - const usable_width = @max(0, render_width - dpi.scale(renderer_mod.terminal_padding, ui_scale) * 2); - const usable_height = @max(0, render_height - dpi.scale(renderer_mod.terminal_padding, ui_scale) * 2); - - const new_size = pty_mod.winsize{ - .ws_row = rows, - .ws_col = cols, - .ws_xpixel = @intCast(usable_width), - .ws_ypixel = @intCast(usable_height), + const padding = dpi.scale(renderer_mod.terminal_padding, ui_scale) * 2; + const usable_width: u16 = @intCast(@max(0, render_width - padding)); + const usable_height: u16 = @intCast(@max(0, render_height - padding)); + + const grid_size = pty_mod.winsize{ + .ws_row = sizes.grid.rows, + .ws_col = sizes.grid.cols, + .ws_xpixel = usable_width, + .ws_ypixel = usable_height, + }; + const full_size = pty_mod.winsize{ + .ws_row = sizes.full.rows, + .ws_col = sizes.full.cols, + .ws_xpixel = usable_width, + .ws_ypixel = usable_height, }; var terminal_resized = false; - for (sessions) |session| { + for (sessions, 0..) |session, idx| { + const target = if (full_set.contains(idx)) full_size else grid_size; if (!session.spawned) { - session.pty_size = new_size; + session.pty_size = target; continue; } const shell = &(session.shell orelse continue); const terminal = &(session.terminal orelse continue); - const winsize_changed = !std.meta.eql(session.pty_size, new_size); - const terminal_cells_changed = terminal.cols != cols or terminal.rows != rows; + const winsize_changed = !std.meta.eql(session.pty_size, target); + const terminal_cells_changed = terminal.cols != target.ws_col or terminal.rows != target.ws_row; if (winsize_changed) { - shell.pty.setSize(new_size) catch |err| { - log.warn("failed to resize PTY session={d} target={d}x{d}: {}", .{ session.id, cols, rows, err }); + shell.pty.setSize(target) catch |err| { + log.warn("failed to resize PTY session={d} target={d}x{d}: {}", .{ session.id, target.ws_col, target.ws_row, err }); continue; }; } if (terminal_cells_changed) { - resizeTerminal(allocator, terminal, cols, rows, new_size) catch |err| { - log.warn("failed to resize VT session={d} target={d}x{d}: {}", .{ session.id, cols, rows, err }); + resizeTerminal(allocator, terminal, target.ws_col, target.ws_row, target) catch |err| { + log.warn("failed to resize VT session={d} target={d}x{d}: {}", .{ session.id, target.ws_col, target.ws_row, err }); continue; }; @@ -197,10 +224,10 @@ pub fn applyTerminalResize( // DEC 2048 reports carry pixel fields, so apps tracking pixel // geometry need them even when the cell count is unchanged. if (winsize_changed and terminal.modes.get(.in_band_size_reports)) { - sendInBandSizeReport(shell, new_size); + sendInBandSizeReport(shell, target); } - session.pty_size = new_size; + session.pty_size = target; } return terminal_resized; } @@ -232,20 +259,25 @@ fn sendInBandSizeReport(shell: *shell_mod.Shell, size: pty_mod.winsize) void { }; } -test "grid mode sizes terminals to the rendered tile area" { +test "calculateTerminalSizes returns smaller grid than full and shrinks grid further when font scale shrinks" { var font: font_mod.Font = undefined; font.cell_width = 10; font.cell_height = 20; - const full = calculateTerminalSizeForMode(&font, 1200, 800, .Full, 1.0, 2, 1, 1.0); - const normal_grid = calculateTerminalSizeForMode(&font, 1200, 800, .Grid, 1.0, 2, 1, 1.0); - const enlarged_grid = calculateTerminalSizeForMode(&font, 1200, 800, .Grid, 2.0, 2, 1, 1.0); + const normal = calculateTerminalSizes(&font, 1200, 800, 1.0, 2, 1, 1.0); + const enlarged = calculateTerminalSizes(&font, 1200, 800, 2.0, 2, 1, 1.0); + + try std.testing.expect(normal.grid.cols < normal.full.cols); + try std.testing.expect(enlarged.grid.cols < normal.grid.cols); + try std.testing.expectEqual(normal.full, enlarged.full); +} - try std.testing.expect(normal_grid.cols < full.cols); - try std.testing.expect(enlarged_grid.cols < normal_grid.cols); - try std.testing.expectEqual(full, calculateTerminalSizeForMode(&font, 1200, 800, .Expanding, 1.0, 2, 1, 1.0)); - try std.testing.expectEqual(full, calculateTerminalSizeForMode(&font, 1200, 800, .Collapsing, 1.0, 2, 1, 1.0)); - try std.testing.expectEqual(full, calculateTerminalSizeForMode(&font, 1200, 800, .GridResizing, 1.0, 2, 1, 1.0)); +test "FullSet.contains identifies primary and secondary indices" { + try std.testing.expect(!(FullSet{}).contains(0)); + try std.testing.expect((FullSet{ .primary = 3 }).contains(3)); + try std.testing.expect(!(FullSet{ .primary = 3 }).contains(2)); + try std.testing.expect((FullSet{ .primary = 3, .secondary = 5 }).contains(5)); + try std.testing.expect(!(FullSet{ .primary = 3, .secondary = 5 }).contains(4)); } test "terminal resize preserves prompt contents when shell does not redraw" { diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 7d2a65b..f5703b4 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -301,6 +301,17 @@ fn adjustedRenderHeightForMode(mode: app_state.ViewMode, render_height: c_int, u }; } +/// Which sessions need full-window cell dimensions for the given view mode. +/// During Panning the previous session is still visible at full size on its way +/// off-screen, so it stays at full size until the pan completes. +fn fullSetForMode(mode: app_state.ViewMode, focused: usize, previous: usize) layout.FullSet { + return switch (mode) { + .Grid, .GridResizing => .{}, + .Full, .Expanding, .Collapsing => .{ .primary = focused }, + .PanningLeft, .PanningRight, .PanningUp, .PanningDown => .{ .primary = focused, .secondary = previous }, + }; +} + fn applyTerminalLayout( sessions: []const *SessionState, allocator: std.mem.Allocator, @@ -308,18 +319,19 @@ fn applyTerminalLayout( render_width: c_int, render_height: c_int, ui_scale: f32, - mode: app_state.ViewMode, + anim_state: *const AnimationState, grid_cols: usize, grid_rows: usize, grid_font_scale: f32, full_cols: *u16, full_rows: *u16, ) void { - const term_render_height = adjustedRenderHeightForMode(mode, render_height, ui_scale, grid_rows); - const term_size = layout.calculateTerminalSizeForMode(font, render_width, term_render_height, mode, grid_font_scale, grid_cols, grid_rows, ui_scale); - full_cols.* = term_size.cols; - full_rows.* = term_size.rows; - _ = layout.applyTerminalResize(sessions, allocator, full_cols.*, full_rows.*, render_width, term_render_height, ui_scale); + const term_render_height = adjustedRenderHeightForMode(anim_state.mode, render_height, ui_scale, grid_rows); + const sizes = layout.calculateTerminalSizes(font, render_width, term_render_height, grid_font_scale, grid_cols, grid_rows, ui_scale); + full_cols.* = sizes.full.cols; + full_rows.* = sizes.full.rows; + const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); + _ = layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, term_render_height, ui_scale); } fn applyTerminalLayoutIfSizeChanged( @@ -329,20 +341,19 @@ fn applyTerminalLayoutIfSizeChanged( render_width: c_int, render_height: c_int, ui_scale: f32, - mode: app_state.ViewMode, + anim_state: *const AnimationState, grid_cols: usize, grid_rows: usize, grid_font_scale: f32, full_cols: *u16, full_rows: *u16, ) bool { - const term_render_height = adjustedRenderHeightForMode(mode, render_height, ui_scale, grid_rows); - const term_size = layout.calculateTerminalSizeForMode(font, render_width, term_render_height, mode, grid_font_scale, grid_cols, grid_rows, ui_scale); - if (full_cols.* == term_size.cols and full_rows.* == term_size.rows) return false; - - full_cols.* = term_size.cols; - full_rows.* = term_size.rows; - return layout.applyTerminalResize(sessions, allocator, full_cols.*, full_rows.*, render_width, term_render_height, ui_scale); + const term_render_height = adjustedRenderHeightForMode(anim_state.mode, render_height, ui_scale, grid_rows); + const sizes = layout.calculateTerminalSizes(font, render_width, term_render_height, grid_font_scale, grid_cols, grid_rows, ui_scale); + full_cols.* = sizes.full.cols; + full_rows.* = sizes.full.rows; + const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); + return layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, term_render_height, ui_scale); } const SessionIndexSnapshot = struct { @@ -688,7 +699,7 @@ fn handleExternalSpawnRequest( render_width, render_height, ui_scale, - anim_state.mode, + anim_state, grid.cols, grid.rows, grid_font_scale, @@ -846,7 +857,7 @@ const RuntimeScaleChangeContext = struct { sessions: []const *SessionState, render_width: c_int, render_height: c_int, - mode: app_state.ViewMode, + anim_state: *const AnimationState, grid_cols: usize, grid_rows: usize, grid_font_scale: f32, @@ -870,24 +881,24 @@ fn reloadRuntimeFontsForScaleChange(ctx: *RuntimeScaleChangeContext) font_mod.Fo } fn applyRuntimeResizeForScaleChange(ctx: *RuntimeScaleChangeContext) void { - const term_render_height = adjustedRenderHeightForMode(ctx.mode, ctx.render_height, ctx.ui_scale, ctx.grid_rows); - const new_term_size = layout.calculateTerminalSizeForMode( + const term_render_height = adjustedRenderHeightForMode(ctx.anim_state.mode, ctx.render_height, ctx.ui_scale, ctx.grid_rows); + const sizes = layout.calculateTerminalSizes( ctx.font, ctx.render_width, term_render_height, - ctx.mode, ctx.grid_font_scale, ctx.grid_cols, ctx.grid_rows, ctx.ui_scale, ); - ctx.full_cols.* = new_term_size.cols; - ctx.full_rows.* = new_term_size.rows; + ctx.full_cols.* = sizes.full.cols; + ctx.full_rows.* = sizes.full.rows; + const full_set = fullSetForMode(ctx.anim_state.mode, ctx.anim_state.focused_session, ctx.anim_state.previous_session); _ = layout.applyTerminalResize( ctx.sessions, ctx.allocator, - ctx.full_cols.*, - ctx.full_rows.*, + sizes, + full_set, ctx.render_width, term_render_height, ctx.ui_scale, @@ -1329,11 +1340,11 @@ pub fn run() !void { const initial_view_mode: app_state.ViewMode = if (initial_terminal_count == 1) .Full else .Grid; const initial_term_render_height = adjustedRenderHeightForMode(initial_view_mode, render_height, ui_scale, grid.rows); - const initial_term_size = layout.calculateTerminalSizeForMode(&font, render_width, initial_term_render_height, initial_view_mode, config.grid.font_scale, grid.cols, grid.rows, ui_scale); - var full_cols: u16 = initial_term_size.cols; - var full_rows: u16 = initial_term_size.rows; + const initial_sizes = layout.calculateTerminalSizes(&font, render_width, initial_term_render_height, config.grid.font_scale, grid.cols, grid.rows, ui_scale); + var full_cols: u16 = initial_sizes.full.cols; + var full_rows: u16 = initial_sizes.full.rows; - std.debug.print("Grid cell terminal size: {d}x{d}\n", .{ full_cols, full_rows }); + std.debug.print("Grid cell terminal size: {d}x{d}; full size: {d}x{d}\n", .{ initial_sizes.grid.cols, initial_sizes.grid.rows, full_cols, full_rows }); const shell_path = std.posix.getenv("SHELL") orelse "/bin/zsh"; std.debug.print("Starting with {d}x{d} grid: {s}\n", .{ grid.cols, grid.rows, shell_path }); @@ -1345,9 +1356,11 @@ pub fn run() !void { const usable_width = @max(0, render_width - terminal_padding * 2); const usable_height = @max(0, initial_term_render_height - terminal_padding * 2); + // All sessions seed at grid-cell size. The first applyTerminalLayoutIfSizeChanged + // promotes the focused session to full size if the initial view is Full. const size = pty_mod.winsize{ - .ws_row = full_rows, - .ws_col = full_cols, + .ws_row = initial_sizes.grid.rows, + .ws_col = initial_sizes.grid.cols, .ws_xpixel = @intCast(usable_width), .ws_ypixel = @intCast(usable_height), }; @@ -1643,7 +1656,7 @@ pub fn run() !void { .sessions = sessions, .render_width = render_width, .render_height = render_height, - .mode = anim_state.mode, + .anim_state = &anim_state, .grid_cols = grid.cols, .grid_rows = grid.rows, .grid_font_scale = config.grid.font_scale, @@ -1872,7 +1885,7 @@ pub fn run() !void { cell_width_pixels = render_width; cell_height_pixels = render_height; anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else if (remaining_count == 1) { // Only 1 terminal remains - go directly to Full mode, no resize animation grid.cols = 1; @@ -1888,7 +1901,7 @@ pub fn run() !void { } } anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else { const new_dims = GridLayout.calculateDimensions(required_slots); const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; @@ -1928,7 +1941,7 @@ pub fn run() !void { cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); // Update focus to a valid session if (!sessions[anim_state.focused_session].spawned) { @@ -2006,7 +2019,7 @@ pub fn run() !void { font.metrics = metrics_ptr; font_size = target_size; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); persistence.font_size = font_size; @@ -2068,7 +2081,7 @@ pub fn run() !void { // Update cell dimensions for new grid cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); session_interaction_component.clearSelection(anim_state.focused_session); session_interaction_component.clearSelection(new_idx); @@ -2491,7 +2504,7 @@ pub fn run() !void { cell_width_pixels = render_width; cell_height_pixels = render_height; anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else if (remaining_count == 1) { // Only 1 terminal remains - go directly to Full mode, no resize animation grid.cols = 1; @@ -2507,7 +2520,7 @@ pub fn run() !void { } } anim_state.mode = .Full; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); } else { const new_dims = GridLayout.calculateDimensions(required_slots); const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; @@ -2546,7 +2559,7 @@ pub fn run() !void { cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, anim_state.mode, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); if (!sessions[anim_state.focused_session].spawned) { var new_focus: usize = 0; @@ -2925,7 +2938,7 @@ pub fn run() !void { render_width, render_height, ui_scale, - anim_state.mode, + &anim_state, grid.cols, grid.rows, config.grid.font_scale, @@ -3167,6 +3180,27 @@ test "computeFrameWaitDecision defers to vsync while active" { } } +test "fullSetForMode promotes focused (and previous during panning) to full size" { + try std.testing.expectEqual(@as(?usize, null), fullSetForMode(.Grid, 2, 7).primary); + try std.testing.expectEqual(@as(?usize, null), fullSetForMode(.GridResizing, 2, 7).primary); + + const full = fullSetForMode(.Full, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), full.primary); + try std.testing.expectEqual(@as(?usize, null), full.secondary); + + const expanding = fullSetForMode(.Expanding, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), expanding.primary); + try std.testing.expectEqual(@as(?usize, null), expanding.secondary); + + const collapsing = fullSetForMode(.Collapsing, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), collapsing.primary); + try std.testing.expectEqual(@as(?usize, null), collapsing.secondary); + + const pan = fullSetForMode(.PanningLeft, 2, 7); + try std.testing.expectEqual(@as(?usize, 2), pan.primary); + try std.testing.expectEqual(@as(?usize, 7), pan.secondary); +} + test "markTeardownComplete returns true only once" { var done = false; try std.testing.expect(markTeardownComplete(&done)); diff --git a/src/render/renderer.zig b/src/render/renderer.zig index caaeb9e..0e0faf1 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -156,7 +156,8 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderSessionCached(renderer, session, view, entry, cell_rect, grid_scale, i == anim_state.focused_session, true, true, currentWaveEffect(view, current_time), font, term_cols, term_rows, current_time, true, theme, ui_scale); + const session_dims = sessionTermDims(session, term_cols, term_rows); + try renderSessionCached(renderer, session, view, entry, cell_rect, grid_scale, i == anim_state.focused_session, true, true, currentWaveEffect(view, current_time), font, session_dims.cols, session_dims.rows, current_time, true, theme, ui_scale); } } }, @@ -164,7 +165,9 @@ pub fn render( releaseNonFocusedCaches(render_cache, anim_state.focused_session); const full_rect = Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height }; const entry = render_cache.entry(anim_state.focused_session); - try renderSessionCached(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, true, null, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const focused_session = sessions[anim_state.focused_session]; + const focused_dims = sessionTermDims(focused_session, term_cols, term_rows); + try renderSessionCached(renderer, focused_session, &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, true, null, font, focused_dims.cols, focused_dims.rows, current_time, false, theme, ui_scale); }, .PanningLeft, .PanningRight => { const elapsed = current_time - anim_state.start_time; @@ -176,7 +179,9 @@ pub fn render( const prev_rect = Rect{ .x = pan_offset, .y = 0, .w = window_width, .h = window_height }; const prev_entry = render_cache.entry(anim_state.previous_session); - try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const prev_session = sessions[anim_state.previous_session]; + const prev_dims = sessionTermDims(prev_session, term_cols, term_rows); + try renderSession(renderer, prev_session, &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, prev_dims.cols, prev_dims.rows, current_time, false, theme, ui_scale); const new_offset = if (anim_state.mode == .PanningLeft) window_width - offset @@ -184,7 +189,9 @@ pub fn render( -window_width + offset; const new_rect = Rect{ .x = new_offset, .y = 0, .w = window_width, .h = window_height }; const new_entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const new_session = sessions[anim_state.focused_session]; + const new_dims = sessionTermDims(new_session, term_cols, term_rows); + try renderSession(renderer, new_session, &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, new_dims.cols, new_dims.rows, current_time, false, theme, ui_scale); }, .PanningUp, .PanningDown => { const elapsed = current_time - anim_state.start_time; @@ -196,7 +203,9 @@ pub fn render( const prev_rect = Rect{ .x = 0, .y = pan_offset, .w = window_width, .h = window_height }; const prev_entry = render_cache.entry(anim_state.previous_session); - try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const prev_session = sessions[anim_state.previous_session]; + const prev_dims = sessionTermDims(prev_session, term_cols, term_rows); + try renderSession(renderer, prev_session, &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, prev_dims.cols, prev_dims.rows, current_time, false, theme, ui_scale); const new_offset = if (anim_state.mode == .PanningUp) window_height - offset @@ -204,7 +213,9 @@ pub fn render( -window_height + offset; const new_rect = Rect{ .x = 0, .y = new_offset, .w = window_width, .h = window_height }; const new_entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme, ui_scale); + const new_session = sessions[anim_state.focused_session]; + const new_dims = sessionTermDims(new_session, term_cols, term_rows); + try renderSession(renderer, new_session, &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, new_dims.cols, new_dims.rows, current_time, false, theme, ui_scale); }, .Expanding, .Collapsing => { const animating_rect = anim_state.getCurrentRect(current_time); @@ -232,13 +243,16 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, true, currentWaveEffect(&views[i], current_time), font, term_cols, term_rows, current_time, true, theme, ui_scale); + const session_dims = sessionTermDims(session, term_cols, term_rows); + try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, true, currentWaveEffect(&views[i], current_time), font, session_dims.cols, session_dims.rows, current_time, true, theme, ui_scale); } } const apply_effects = anim_scale < 0.999; const entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, true, theme, ui_scale); + const focused_session = sessions[anim_state.focused_session]; + const focused_dims = sessionTermDims(focused_session, term_cols, term_rows); + try renderSession(renderer, focused_session, &views[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, focused_dims.cols, focused_dims.rows, current_time, true, theme, ui_scale); }, .GridResizing => { // Render session contents first so borders draw on top. @@ -271,7 +285,8 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, false, currentWaveEffect(&views[i], current_time), font, term_cols, term_rows, current_time, true, theme, ui_scale); + const session_dims = sessionTermDims(session, term_cols, term_rows); + try renderSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, false, currentWaveEffect(&views[i], current_time), font, session_dims.cols, session_dims.rows, current_time, true, theme, ui_scale); } // Render borders and overlays on top of the animated content. @@ -825,6 +840,15 @@ fn releaseCacheTexture(cache_entry: *RenderCache.Entry) void { cache_entry.cache_render_mode = .full; } +/// Returns the session's own VT dimensions, or the passed-in fallback when the +/// session hasn't been spawned yet (no terminal). Used so each grid tile and +/// panning rect renders at the session's actual cell count instead of the +/// process-global "full size" hint. +fn sessionTermDims(session: *const SessionState, fallback_cols: u16, fallback_rows: u16) struct { cols: u16, rows: u16 } { + if (session.terminal) |t| return .{ .cols = t.cols, .rows = t.rows }; + return .{ .cols = fallback_cols, .rows = fallback_rows }; +} + fn releaseNonFocusedCaches(render_cache: *RenderCache, focused_session: usize) void { for (render_cache.entries, 0..) |*cache_entry, idx| { if (idx == focused_session) continue; From 482b487532585597d8c0e6e28cf3f6bf11a5eccf Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Mon, 18 May 2026 15:47:14 +0200 Subject: [PATCH 2/4] fix(terminal): keep grid size stable across view-mode toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: After the per-session sizing refactor, unfocused sessions still got resized on every grid/full toggle. adjustedRenderHeightForMode reserves CWD-bar height only in Grid mode and returns the raw render height in every other mode. Because the same height fed into both the grid_size and full_size computations, grid_size itself shifted on every view-mode change, and unfocused sessions saw pty_size != target and got the resize they were meant to be insulated from. Codex's chat scrolled top-to-bottom every time. Solution: calculateTerminalSizes now takes two heights — grid uses the Grid-mode CWD-reserved height (constant across view modes), full uses the raw render height. runtime.zig wraps this in computeTerminalSizes and passes the raw render_height to applyTerminalResize, so unfocused sessions stay at the same grid dims whenever the actual grid layout is unchanged. --- src/app/layout.zig | 29 ++++++++++++++++++++++++----- src/app/runtime.zig | 41 +++++++++++++++++++++++------------------ 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/app/layout.zig b/src/app/layout.zig index 734b1fe..6d6d554 100644 --- a/src/app/layout.zig +++ b/src/app/layout.zig @@ -120,10 +120,16 @@ pub const Sizes = struct { full: TerminalSize, }; +/// `grid_window_height` should be the render height with the Grid-mode CWD-bar +/// reservation applied (and should NOT vary across view modes — unfocused +/// sessions stay at grid size permanently, so the grid dims must be stable). +/// `full_window_height` is the raw render height (the focused session uses the +/// whole window when at full size). pub fn calculateTerminalSizes( font: *const font_mod.Font, window_width: c_int, - window_height: c_int, + grid_window_height: c_int, + full_window_height: c_int, grid_font_scale: f32, grid_cols: usize, grid_rows: usize, @@ -133,8 +139,8 @@ pub fn calculateTerminalSizes( const base_grid_scale: f32 = 1.0 / @as(f32, @floatFromInt(grid_dim)); const effective_scale: f32 = base_grid_scale * grid_font_scale; return .{ - .grid = calculateGridCellTerminalSize(font, window_width, window_height, effective_scale, grid_cols, grid_rows, ui_scale), - .full = calculateTerminalSize(font, window_width, window_height, 1.0, ui_scale), + .grid = calculateGridCellTerminalSize(font, window_width, grid_window_height, effective_scale, grid_cols, grid_rows, ui_scale), + .full = calculateTerminalSize(font, window_width, full_window_height, 1.0, ui_scale), }; } @@ -264,14 +270,27 @@ test "calculateTerminalSizes returns smaller grid than full and shrinks grid fur font.cell_width = 10; font.cell_height = 20; - const normal = calculateTerminalSizes(&font, 1200, 800, 1.0, 2, 1, 1.0); - const enlarged = calculateTerminalSizes(&font, 1200, 800, 2.0, 2, 1, 1.0); + const normal = calculateTerminalSizes(&font, 1200, 800, 800, 1.0, 2, 1, 1.0); + const enlarged = calculateTerminalSizes(&font, 1200, 800, 800, 2.0, 2, 1, 1.0); try std.testing.expect(normal.grid.cols < normal.full.cols); try std.testing.expect(enlarged.grid.cols < normal.grid.cols); try std.testing.expectEqual(normal.full, enlarged.full); } +test "calculateTerminalSizes grid dims stay stable when only full height changes" { + var font: font_mod.Font = undefined; + font.cell_width = 10; + font.cell_height = 20; + + // grid_window_height held constant; full_window_height varies (the typical + // case across view-mode toggles where the CWD-bar reservation only changes + // when the actual grid layout changes). + const a = calculateTerminalSizes(&font, 1200, 700, 800, 1.0, 2, 1, 1.0); + const b = calculateTerminalSizes(&font, 1200, 700, 750, 1.0, 2, 1, 1.0); + try std.testing.expectEqual(a.grid, b.grid); +} + test "FullSet.contains identifies primary and secondary indices" { try std.testing.expect(!(FullSet{}).contains(0)); try std.testing.expect((FullSet{ .primary = 3 }).contains(3)); diff --git a/src/app/runtime.zig b/src/app/runtime.zig index f5703b4..cdf48d1 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -326,12 +326,11 @@ fn applyTerminalLayout( full_cols: *u16, full_rows: *u16, ) void { - const term_render_height = adjustedRenderHeightForMode(anim_state.mode, render_height, ui_scale, grid_rows); - const sizes = layout.calculateTerminalSizes(font, render_width, term_render_height, grid_font_scale, grid_cols, grid_rows, ui_scale); + const sizes = computeTerminalSizes(font, render_width, render_height, ui_scale, grid_cols, grid_rows, grid_font_scale); full_cols.* = sizes.full.cols; full_rows.* = sizes.full.rows; const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); - _ = layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, term_render_height, ui_scale); + _ = layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, render_height, ui_scale); } fn applyTerminalLayoutIfSizeChanged( @@ -348,12 +347,27 @@ fn applyTerminalLayoutIfSizeChanged( full_cols: *u16, full_rows: *u16, ) bool { - const term_render_height = adjustedRenderHeightForMode(anim_state.mode, render_height, ui_scale, grid_rows); - const sizes = layout.calculateTerminalSizes(font, render_width, term_render_height, grid_font_scale, grid_cols, grid_rows, ui_scale); + const sizes = computeTerminalSizes(font, render_width, render_height, ui_scale, grid_cols, grid_rows, grid_font_scale); full_cols.* = sizes.full.cols; full_rows.* = sizes.full.rows; const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); - return layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, term_render_height, ui_scale); + return layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, render_height, ui_scale); +} + +/// Computes both terminal sizes from the raw render dimensions. grid_size +/// always uses the Grid-mode CWD-bar reservation so unfocused sessions stay at +/// stable dims across view-mode toggles; full_size uses the raw render height. +fn computeTerminalSizes( + font: *font_mod.Font, + render_width: c_int, + render_height: c_int, + ui_scale: f32, + grid_cols: usize, + grid_rows: usize, + grid_font_scale: f32, +) layout.Sizes { + const grid_render_height = adjustedRenderHeightForMode(.Grid, render_height, ui_scale, grid_rows); + return layout.calculateTerminalSizes(font, render_width, grid_render_height, render_height, grid_font_scale, grid_cols, grid_rows, ui_scale); } const SessionIndexSnapshot = struct { @@ -881,16 +895,7 @@ fn reloadRuntimeFontsForScaleChange(ctx: *RuntimeScaleChangeContext) font_mod.Fo } fn applyRuntimeResizeForScaleChange(ctx: *RuntimeScaleChangeContext) void { - const term_render_height = adjustedRenderHeightForMode(ctx.anim_state.mode, ctx.render_height, ctx.ui_scale, ctx.grid_rows); - const sizes = layout.calculateTerminalSizes( - ctx.font, - ctx.render_width, - term_render_height, - ctx.grid_font_scale, - ctx.grid_cols, - ctx.grid_rows, - ctx.ui_scale, - ); + const sizes = computeTerminalSizes(ctx.font, ctx.render_width, ctx.render_height, ctx.ui_scale, ctx.grid_cols, ctx.grid_rows, ctx.grid_font_scale); ctx.full_cols.* = sizes.full.cols; ctx.full_rows.* = sizes.full.rows; const full_set = fullSetForMode(ctx.anim_state.mode, ctx.anim_state.focused_session, ctx.anim_state.previous_session); @@ -900,7 +905,7 @@ fn applyRuntimeResizeForScaleChange(ctx: *RuntimeScaleChangeContext) void { sizes, full_set, ctx.render_width, - term_render_height, + ctx.render_height, ctx.ui_scale, ); } @@ -1340,7 +1345,7 @@ pub fn run() !void { const initial_view_mode: app_state.ViewMode = if (initial_terminal_count == 1) .Full else .Grid; const initial_term_render_height = adjustedRenderHeightForMode(initial_view_mode, render_height, ui_scale, grid.rows); - const initial_sizes = layout.calculateTerminalSizes(&font, render_width, initial_term_render_height, config.grid.font_scale, grid.cols, grid.rows, ui_scale); + const initial_sizes = computeTerminalSizes(&font, render_width, render_height, ui_scale, grid.cols, grid.rows, config.grid.font_scale); var full_cols: u16 = initial_sizes.full.cols; var full_rows: u16 = initial_sizes.full.rows; From 6f60d17269b01c9912cdc2071f91587f0e7cf998 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Mon, 18 May 2026 16:16:25 +0200 Subject: [PATCH 3/4] fix(terminal): hold cached frame during DEC 2026 synchronized output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: When the focused session toggled grid<->full, Codex's redraw of its chat history was visible top-to-bottom — line by line — instead of appearing as one atomic update like in Ghostty. Codex brackets the redraw with `\e[?2026h` ... `\e[?2026l` to ask the terminal to suppress intermediate frames; we were rendering every intermediate state from the in-progress vt model. Solution: In the cached render path, treat synchronized-output mode as a signal to reuse the last rendered texture instead of refreshing from the vt model. The hold is skipped when the cache is empty (initial render must run) or when the cached render mode doesn't match the requested one (grid-sized cache can't fill a full-window rect). When the app sends `\e[?2026l` the next frame refreshes once and snaps to the final state. Matches ghostty's behavior of pausing briefly while the agent reflows. --- src/render/renderer.zig | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 0e0faf1..499d7fd 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -933,6 +933,20 @@ fn cacheNeedsRefresh( return cache_entry.cache_epoch != session_epoch or cache_entry.cache_composition != composition or cache_entry.cache_render_mode != render_mode; } +/// DEC mode 2026 (synchronized output): while a session has the mode set, the +/// app expects the terminal to suppress intermediate frames until it sends the +/// closing `\e[?2026l`. We do that by reusing the last cached texture for that +/// session instead of refreshing from the in-progress vt model. Skipped when +/// the cache has no texture yet (initial render must run) or when the cached +/// render mode doesn't match the requested one (a grid-sized cache can't fill +/// a full-window rect cleanly). +fn synchronizedOutputHoldsCache(session: *const SessionState, cache_entry: *const RenderCache.Entry, requested_render_mode: RenderCache.CacheRenderMode) bool { + if (cache_entry.cache_epoch == 0) return false; + if (cache_entry.cache_render_mode != requested_render_mode) return false; + const terminal = session.terminal orelse return false; + return terminal.modes.get(.synchronized_output); +} + fn refreshSessionCacheTexture( renderer: *c.SDL_Renderer, session: *SessionState, @@ -1014,7 +1028,8 @@ fn renderSessionCached( const can_cache = ensureCacheTexture(renderer, cache_entry, session, rect.w, rect.h); if (can_cache) { if (cache_entry.texture) |tex| { - if (cacheNeedsRefresh(cache_entry, session.render_epoch, composition, render_mode)) { + const sync_holding = synchronizedOutputHoldsCache(session, cache_entry, render_mode); + if (!sync_holding and cacheNeedsRefresh(cache_entry, session.render_epoch, composition, render_mode)) { try refreshSessionCacheTexture(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, cache_overlays, composition, is_grid_view, theme, ui_scale); } From 58e80336833cf4ca0b593b06c0cd9ea038b2e5f1 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Mon, 18 May 2026 16:57:43 +0200 Subject: [PATCH 4/4] fix(terminal): address PR #312 review comments Issue: Four unresolved review threads on PR #312 flagged real edge cases in the synchronized-output cache hold and the per-session size plumbing: the render-mode mismatch guard dropped the hold across a grid/full toggle that happened mid-sync (so reflow frames became visible after the transition); the predicate did not consider cache_composition (so a wave/overlay started mid-sync would be skipped); grid-tile sessions reported the full window's pixel dimensions in their winsize and DEC 2048 reports; and ARCHITECTURE.md still said there was no output-hold machinery. Solution: synchronizedOutputHoldsCache now ignores render-mode mismatches (SDL stretches the cached texture for the brief sync window) and instead drops the hold on composition mismatch so a wave or overlay forces a fresh frame. TerminalSize gains width_px/height_px computed from cols * cell_w and rows * cell_h, so grid_size carries the grid tile's pixel dimensions and full_size carries the full-window pixels. applyTerminalResize reads pixel dims from the per-target Sizes entry and no longer needs the render-area arguments. ARCHITECTURE.md describes the synchronized-output cache hold. --- docs/ARCHITECTURE.md | 2 +- src/app/layout.zig | 23 ++++++++++++----------- src/app/runtime.zig | 7 ++----- src/render/renderer.zig | 20 ++++++++++++++------ 4 files changed, 29 insertions(+), 23 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e110d19..8b604dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,7 @@ Platform Session Rendering UI Overlay - Runtime persistence is updated during the frame loop when runtime state changes (cwd changes, terminal spawn/despawn, window move/resize, font size changes), and finalization is explicit at the end of `app/runtime.zig`: final save and deinit `Persistence` before deferred subsystem teardown begins. - Font reload paths are transactional: acquire both replacement fonts first, then swap and destroy old fonts, so a partial reload failure cannot leave deinit hooks pointing at already-freed font resources. - Window-resize scale handling follows a single ordered path (`reload-if-needed`, then `resize`) to keep behavior consistent between changed-scale and unchanged-scale events. -- Terminal resizes use Ghostty's minimal flow: a single `ioctl(master, TIOCSWINSZ)` on the PTY master, which the kernel pairs with a SIGWINCH to the foreground process group of the slave's controlling terminal. Each session is sized independently. The focused session in `.Full`/`.Expanding`/`.Collapsing` mode (and additionally the previous session during a panning transition) is sized to the full-window cell count; every other session stays at grid-cell size. Grid↔full view toggle therefore reflows exactly one session — the one the user actually zoomed — instead of every session in the workspace. Window resize, font size change, and grid layout change all flow through the same per-session dispatch (`fullSetForMode` in `app/runtime.zig`, `applyTerminalResize` with `Sizes`/`FullSet` in `app/layout.zig`). Grid sizing accounts for the user's grid font scale and the reserved CWD-bar space when computing tile cell count. Synchronized-output mode is tracked per session purely as a stuck-mode safety net: if a session leaves `\e[?2026h` set without ever ending the sync, Architect force-clears the mode after a timeout. There is no output-hold or texture-reuse machinery beyond the standard render-epoch / view-mode cache invalidation. +- Terminal resizes use Ghostty's minimal flow: a single `ioctl(master, TIOCSWINSZ)` on the PTY master, which the kernel pairs with a SIGWINCH to the foreground process group of the slave's controlling terminal. Each session is sized independently. The focused session in `.Full`/`.Expanding`/`.Collapsing` mode (and additionally the previous session during a panning transition) is sized to the full-window cell count; every other session stays at grid-cell size. Grid↔full view toggle therefore reflows exactly one session — the one the user actually zoomed — instead of every session in the workspace. Window resize, font size change, and grid layout change all flow through the same per-session dispatch (`fullSetForMode` in `app/runtime.zig`, `applyTerminalResize` with `Sizes`/`FullSet` in `app/layout.zig`). Grid sizing accounts for the user's grid font scale and the reserved CWD-bar space when computing tile cell count. While DEC mode 2026 (`\e[?2026h`) is active for a session, the renderer reuses the last cached texture for that session via `synchronizedOutputHoldsCache` in `render/renderer.zig` instead of refreshing from the in-progress vt model, so reflows from agents like Codex appear as one atomic frame change rather than a top-to-bottom rescroll. The hold is dropped when the cached composition mismatches the requested one (an overlay or wave needs to bake into the next frame) or when the app sends the closing `\e[?2026l`; the next frame after the close refreshes once and snaps to the final state. A timeout-based safety net in `session/state.zig` force-clears the mode if a session leaves `\e[?2026h` set without ever closing it. - Shared Utilities (`geom`, `colors`, `dpi`, `config`, `logging`, `metrics`, etc.) may be imported by any layer but never import from layers above them. - **Exception:** `app/*` modules may import `c.zig` directly for SDL type definitions used in input handling. This is a pragmatic shortcut for FFI constants, not a general license to depend on the Platform layer. diff --git a/src/app/layout.zig b/src/app/layout.zig index 6d6d554..73f3e12 100644 --- a/src/app/layout.zig +++ b/src/app/layout.zig @@ -17,6 +17,12 @@ const SessionState = session_state.SessionState; pub const TerminalSize = struct { cols: u16, rows: u16, + /// Pixel area the cells actually occupy (cols * cell_width, rows * cell_height). + /// Mirrored into `ws_xpixel`/`ws_ypixel` and DEC 2048 reports so sessions get the + /// pixel dimensions of their own rendered area (matters for grid-tile sessions, + /// which draw into a small tile rather than the whole window). + width_px: u16, + height_px: u16, }; pub fn updateRenderSizes( @@ -106,6 +112,8 @@ pub fn calculateTerminalSize(font: *const font_mod.Font, window_width: c_int, wi return .{ .cols = @intCast(cols), .rows = @intCast(rows), + .width_px = @intCast(cols * scaled_cell_w), + .height_px = @intCast(rows * scaled_cell_h), }; } @@ -171,25 +179,18 @@ pub fn applyTerminalResize( allocator: std.mem.Allocator, sizes: Sizes, full_set: FullSet, - render_width: c_int, - render_height: c_int, - ui_scale: f32, ) bool { - const padding = dpi.scale(renderer_mod.terminal_padding, ui_scale) * 2; - const usable_width: u16 = @intCast(@max(0, render_width - padding)); - const usable_height: u16 = @intCast(@max(0, render_height - padding)); - const grid_size = pty_mod.winsize{ .ws_row = sizes.grid.rows, .ws_col = sizes.grid.cols, - .ws_xpixel = usable_width, - .ws_ypixel = usable_height, + .ws_xpixel = sizes.grid.width_px, + .ws_ypixel = sizes.grid.height_px, }; const full_size = pty_mod.winsize{ .ws_row = sizes.full.rows, .ws_col = sizes.full.cols, - .ws_xpixel = usable_width, - .ws_ypixel = usable_height, + .ws_xpixel = sizes.full.width_px, + .ws_ypixel = sizes.full.height_px, }; var terminal_resized = false; diff --git a/src/app/runtime.zig b/src/app/runtime.zig index cdf48d1..8f74c14 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -330,7 +330,7 @@ fn applyTerminalLayout( full_cols.* = sizes.full.cols; full_rows.* = sizes.full.rows; const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); - _ = layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, render_height, ui_scale); + _ = layout.applyTerminalResize(sessions, allocator, sizes, full_set); } fn applyTerminalLayoutIfSizeChanged( @@ -351,7 +351,7 @@ fn applyTerminalLayoutIfSizeChanged( full_cols.* = sizes.full.cols; full_rows.* = sizes.full.rows; const full_set = fullSetForMode(anim_state.mode, anim_state.focused_session, anim_state.previous_session); - return layout.applyTerminalResize(sessions, allocator, sizes, full_set, render_width, render_height, ui_scale); + return layout.applyTerminalResize(sessions, allocator, sizes, full_set); } /// Computes both terminal sizes from the raw render dimensions. grid_size @@ -904,9 +904,6 @@ fn applyRuntimeResizeForScaleChange(ctx: *RuntimeScaleChangeContext) void { ctx.allocator, sizes, full_set, - ctx.render_width, - ctx.render_height, - ctx.ui_scale, ); } diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 499d7fd..605276b 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -937,12 +937,20 @@ fn cacheNeedsRefresh( /// app expects the terminal to suppress intermediate frames until it sends the /// closing `\e[?2026l`. We do that by reusing the last cached texture for that /// session instead of refreshing from the in-progress vt model. Skipped when -/// the cache has no texture yet (initial render must run) or when the cached -/// render mode doesn't match the requested one (a grid-sized cache can't fill -/// a full-window rect cleanly). -fn synchronizedOutputHoldsCache(session: *const SessionState, cache_entry: *const RenderCache.Entry, requested_render_mode: RenderCache.CacheRenderMode) bool { +/// the cache has no texture yet (initial render must run), or when the +/// requested composition differs from the cached one — a composition change +/// means an overlay/wave needs to bake into the next frame, and that takes +/// priority over sync atomicity. Render-mode mismatches do NOT drop the hold: +/// holding the previous cache at a different size keeps the display stable +/// across grid↔full toggles that happen mid-sync, and one clean refresh runs +/// when `\e[?2026l` arrives. +fn synchronizedOutputHoldsCache( + session: *const SessionState, + cache_entry: *const RenderCache.Entry, + requested_composition: RenderCache.CacheComposition, +) bool { if (cache_entry.cache_epoch == 0) return false; - if (cache_entry.cache_render_mode != requested_render_mode) return false; + if (cache_entry.cache_composition != requested_composition) return false; const terminal = session.terminal orelse return false; return terminal.modes.get(.synchronized_output); } @@ -1028,7 +1036,7 @@ fn renderSessionCached( const can_cache = ensureCacheTexture(renderer, cache_entry, session, rect.w, rect.h); if (can_cache) { if (cache_entry.texture) |tex| { - const sync_holding = synchronizedOutputHoldsCache(session, cache_entry, render_mode); + const sync_holding = synchronizedOutputHoldsCache(session, cache_entry, composition); if (!sync_holding and cacheNeedsRefresh(cache_entry, session.render_epoch, composition, render_mode)) { try refreshSessionCacheTexture(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, cache_overlays, composition, is_grid_view, theme, ui_scale); }