diff --git a/README.md b/README.md index 190ce3c..bc86c63 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - Scrollback with trackpad/wheel support and an auto-hiding draggable scrollbar in terminal views - OSC 8 hyperlink support (Cmd+Click to open) - Replies to OSC 4/10/11 color queries using the live terminal palette/default colors so Codex and similar CLIs do not stall on startup probes +- VT-compatible 80/132-column mode handling for applications that use DECCOLM - Kitty keyboard protocol for enhanced key handling - Persistent window state and font size across sessions diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2bb6041..161191a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -97,7 +97,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. Every change site only marks a dirty flag and records the time it first became dirty (`markPersistenceDirty`); the actual TOML write happens at most once per frame and only once the dirty state is at least 500ms old (`shouldSavePersistenceNow`), so a window drag or resize does not trigger a synchronous file write per mouse tick. The dirty timestamp is set once per dirty period (not refreshed by later changes), which caps the deferral at the debounce window even under continuous events. - 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. 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. +- 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. When an application enables DEC mode 40 and switches DECCOLM (`\e[?3h`/`\e[?3l`), `applyTerminalResize` preserves the ghostty-vt logical 80/132-column width while the Architect layout target is unchanged; a real Architect layout change resets the terminal model to the computed grid/full size. 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 73f3e12..253dc40 100644 --- a/src/app/layout.zig +++ b/src/app/layout.zig @@ -9,6 +9,7 @@ const dpi = @import("../dpi.zig"); const session_state = @import("../session/state.zig"); const shell_mod = @import("../shell.zig"); const vt_stream = @import("../vt_stream.zig"); +const colors_mod = @import("../colors.zig"); const log = std.log.scoped(.layout); const AnimationState = app_state.AnimationState; @@ -206,6 +207,7 @@ pub fn applyTerminalResize( 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; + const preserve_deccolm_width = !winsize_changed and isDeccolmWidthOverride(terminal, target); if (winsize_changed) { shell.pty.setSize(target) catch |err| { @@ -214,7 +216,7 @@ pub fn applyTerminalResize( }; } - if (terminal_cells_changed) { + if (terminal_cells_changed and !preserve_deccolm_width) { 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; @@ -239,6 +241,12 @@ pub fn applyTerminalResize( return terminal_resized; } +fn isDeccolmWidthOverride(terminal: *const ghostty_vt.Terminal, target: pty_mod.winsize) bool { + if (!terminal.modes.get(.enable_mode_3)) return false; + if (terminal.rows != target.ws_row) return false; + return (terminal.cols == 80 or terminal.cols == 132) and terminal.cols != target.ws_col; +} + fn resizeTerminal( allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, @@ -300,6 +308,114 @@ test "FullSet.contains identifies primary and secondary indices" { try std.testing.expect(!(FullSet{ .primary = 3, .secondary = 5 }).contains(4)); } +const TestSessionFixture = struct { + session: SessionState, + slave_fd: pty_mod.Pty.Fd, + + fn deinit(self: *TestSessionFixture, allocator: std.mem.Allocator) void { + self.session.dead = true; + self.session.deinit(allocator); + std.posix.close(self.slave_fd); + } +}; + +fn initSpawnedTestSession( + allocator: std.mem.Allocator, + pty_size: pty_mod.winsize, + terminal_cols: u16, + terminal_rows: u16, +) !TestSessionFixture { + var pty = try pty_mod.Pty.open(pty_size); + const slave_fd = pty.slave; + errdefer { + pty.deinit(); + std.posix.close(slave_fd); + } + + var terminal = try ghostty_vt.Terminal.init(allocator, .{ + .cols = terminal_cols, + .rows = terminal_rows, + .max_scrollback = 5, + }); + errdefer terminal.deinit(allocator); + + var session = try SessionState.init(allocator, 0, "/bin/zsh", pty_size, "sock", colors_mod.Theme.default()); + session.shell = .{ + .pty = pty, + .child_pid = 0, + }; + session.terminal = terminal; + session.spawned = true; + + return .{ + .session = session, + .slave_fd = slave_fd, + }; +} + +fn testSizes(cols: u16, rows: u16) Sizes { + return .{ + .grid = .{ .cols = cols, .rows = rows, .width_px = cols * 10, .height_px = rows * 20 }, + .full = .{ .cols = cols, .rows = rows, .width_px = cols * 10, .height_px = rows * 20 }, + }; +} + +fn testTerminal(session: *SessionState) !*ghostty_vt.Terminal { + if (session.terminal) |*terminal| return terminal; + return error.TestUnexpectedResult; +} + +test "applyTerminalResize preserves DECCOLM width while layout target is unchanged" { + const allocator = std.testing.allocator; + const target = pty_mod.winsize{ .ws_col = 100, .ws_row = 24, .ws_xpixel = 1000, .ws_ypixel = 480 }; + var fixture = try initSpawnedTestSession(allocator, target, 80, 24); + defer fixture.deinit(allocator); + + const terminal = try testTerminal(&fixture.session); + terminal.modes.set(.enable_mode_3, true); + + var sessions = [_]*SessionState{&fixture.session}; + const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 24), .{ .primary = 0 }); + + try std.testing.expect(!changed); + try std.testing.expectEqual(@as(u16, 80), terminal.cols); + try std.testing.expectEqual(@as(u16, 24), terminal.rows); + try std.testing.expectEqual(@as(u16, 100), fixture.session.pty_size.ws_col); +} + +test "applyTerminalResize resets DECCOLM width when layout target changes" { + const allocator = std.testing.allocator; + const old_target = pty_mod.winsize{ .ws_col = 100, .ws_row = 24, .ws_xpixel = 1000, .ws_ypixel = 480 }; + var fixture = try initSpawnedTestSession(allocator, old_target, 80, 24); + defer fixture.deinit(allocator); + + const terminal = try testTerminal(&fixture.session); + terminal.modes.set(.enable_mode_3, true); + + var sessions = [_]*SessionState{&fixture.session}; + const changed = applyTerminalResize(&sessions, allocator, testSizes(120, 24), .{ .primary = 0 }); + + try std.testing.expect(changed); + try std.testing.expectEqual(@as(u16, 120), terminal.cols); + try std.testing.expectEqual(@as(u16, 24), terminal.rows); + try std.testing.expectEqual(@as(u16, 120), fixture.session.pty_size.ws_col); +} + +test "applyTerminalResize corrects non-DECCOLM terminal width drift" { + const allocator = std.testing.allocator; + const target = pty_mod.winsize{ .ws_col = 100, .ws_row = 24, .ws_xpixel = 1000, .ws_ypixel = 480 }; + var fixture = try initSpawnedTestSession(allocator, target, 80, 24); + defer fixture.deinit(allocator); + + var sessions = [_]*SessionState{&fixture.session}; + const changed = applyTerminalResize(&sessions, allocator, testSizes(100, 24), .{ .primary = 0 }); + + try std.testing.expect(changed); + const terminal = try testTerminal(&fixture.session); + try std.testing.expectEqual(@as(u16, 100), terminal.cols); + try std.testing.expectEqual(@as(u16, 24), terminal.rows); +} + test "terminal resize preserves prompt contents when shell does not redraw" { const allocator = std.testing.allocator;